Files
windmill/frontend/src/lib/components/copilot/chat/files/fileTools.ts
T
AlexRV12andClaude Opus 5 6cd70a8e08 feat: filter ai session tools to the user's workspace capabilities (#10719)
* feat: filter ai session tools to the user's workspace capabilities

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct and tighten comments on the session capability filter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate session deploy tools on DisableDirectDeployment and the pipeline prompt

* fix: gate create_folder on the deploy capability

* refactor: assemble session prompt and tools through one seam

* docs: state the capability filter as best-effort, not a guarantee

* refactor: take the whole deploy gate from the shared preflight

`checkDeployPermission` now evaluates `DisableDirectDeployment` and folds superadmin
into the admin bypass itself, so the resolver's local composition of those two terms
is redundant. Delegate outright and drop the protection-rule fetch it needed, along
with the two tests that restated rule semantics the preflight's own suite now pins.

The preflight's per-kind narrowing stays unused: the filter runs on tool names, before
the model has named a kind, so a direct-deployment lock withholds the deploy tools for
schedules and triggers too.

* fix: address review findings on the session capability filter

Six findings from the Claude and Codex review rounds.

- `discard_local_draft` is ungated. The backend exempts discarding your OWN draft
  from `require_can_write_path` precisely so drafts stay cleanable after a role
  change; gating it stranded that cleanup.
- `deploy` splits into `deploy` and `deploy_gated_kinds`, mirroring
  `deployPermissionForKind`. A direct-deployment lock stops only the kinds that
  reach `check_deploy_rules`, so schedules and triggers stay deployable and the
  two kind-taking deploy tools survive the lock; `create_folder` does not, folder
  being a gated kind. The prompt now names the lock and what it leaves deployable,
  instead of implying nothing can be deployed.
- `COVERED_ENDPOINTS` keyed `createApp` / `updateApp`, which the MCP catalog does
  not expose; the app-authoring endpoints it does expose, `createAppRawSource` and
  `updateAppRawSource`, were uncovered and reachable through `call_api_endpoint`.
- The YOLO tooltip listed tools a restricted session never ships. Both it and the
  token estimate now read one `shippedTools`, and `sessionAccess` is reactive so
  the UI follows the resolution.

* fix: restore the covered API-catalog names for the raw-app endpoints

`COVERED_ENDPOINTS` is matched against `EndpointTool.name`, which openapi.yaml
overrides with `x-mcp-tool-name` for these two operations: `createAppRawSource`
and `updateAppRawSource` are served as `createApp` and `updateApp`
(`mcp/auto_generated_endpoints.rs`). Keying them by operationId left both raw-app
POST endpoints discoverable and callable through the API catalog tools. Restore
the exposed names and record why they differ from the operationIds.

* docs: state each capability invariant once, and document the draft discard

The asymmetric admin/operator precedence was restated three times in
sessionAccess.ts and again in its test, the fail-open rationale twice, and the
deploy split across four sites. Each now lives at the one place someone would
break it, within the four-line budget, with the other sites pointing at it.

Ungating discard_local_draft left it undocumented for the read-only profile,
which is the profile the backend exemption exists for: the only bullet naming it
sits under the draft-writing gate, beneath an opener saying no change is
possible. Add the one line that profile needs.

* docs: record why the session tool filter runs unconditionally

The filter would strip everything from a non-GLOBAL toolset, whose names carry no
policy entries. That cannot happen — `changeMode` refuses to move a session chat
out of GLOBAL, and `sessionAccess` is only ever set for session chats — but the
dependency was not visible at the filter itself.

* fix: match the server's deploy gate exactly, never exceed it

The filter must be as strict as the server and no stricter. Schedules and triggers
reach no deploy rule — `check_deploy_rules` runs only from the gated kinds' handlers
— so no workspace refuses `deploy_workspace_item` or `delete_workspace_item`
outright, whatever refusal `checkDeployPermission` reports. Gating them on a deploy
capability withheld operations the server performs.

Neither tool now requires a capability. Deploying still needs a draft to deploy, so
it keeps the authoring relevance; deleting a deployed item does not, so it is
ungated. `deploy` returns to one capability, covering the kinds the rules gate, and
`create_folder` — whose kind is one of them — is the only tool that names it. The
session-state note now states which kinds a refusing workspace still accepts.

* feat: gate the new app-runnable preview tool on run_preview

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: fail open when whoami resolves without a role

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: count plan-mode tools in the shipped toolset

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* docs: drop the dead capability assertion and the repeated deploy rationale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: drop dead code and a duplicated invariant from the session filter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: reduce SessionAccess to the capability set it is read for

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: gate session tools on permission alone, never on relevance

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* test: pin the filter to the outbound request and widen the description sweep

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: collapse SessionAccess to a capability set and merge adjacent gates

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: gate get_db_schema on run_preview, it runs a query script

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: reuse the cached workspace role and derive the exhaustiveness list from assembly

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* revert: keep tool names in descriptions that ship with them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: let an admin who is also an operator deploy, as the server does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: derive the deploy capability from the protection rules alone

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: stop the datatable instructions naming a tool a session may not have

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: keep the prompt and tool results honest for a profile that cannot draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: move tool policies onto the tools and gate kinds per handler

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* chore: tighten stale comments and name deploy in the operator prompt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: keep the assembled tool list raw so narrowing can clone its defs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: point an operator at a workspace admin for code the role refuses

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: resolve session permissions when the assistant settings modal opens

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: return per-chat tool schemas instead of writing them to shared tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: forward this through the eval tool wrapper so tools see their sent def

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: pin identity and contents in the session filter and schema tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: note why the overhead estimate skips per-chat tool schemas

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:59:54 +02:00

273 lines
12 KiB
TypeScript

/**
* AI tools and system-prompt roster for files attached to the GLOBAL chat.
*
* The model is made aware of attached files via a metadata-only roster appended to
* the system message (see `appendAttachedFilesRoster`). Their contents are NEVER
* inlined — the model pulls only the slices it needs through these two read-only
* tools, which stream from disk via ./fileEngine.
*/
import { z } from 'zod'
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
import { createToolDef } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import {
readFile,
searchFilesInWorker,
numberLines,
FileReadError,
type SearchHit
} from './fileEngine'
import type { AttachedFile, AttachedFilesStore } from './attachedFiles.svelte'
import { sanitizeAttachmentName } from '../textFileUtils'
/** Slice of the GLOBAL tool helpers that exposes the attached-files store. */
export interface AttachedFilesHelper {
attachedFiles?: AttachedFilesStore
}
function storeFrom(helpers: unknown): AttachedFilesStore | undefined {
return (helpers as AttachedFilesHelper | undefined)?.attachedFiles
}
/**
* For a specifically requested attached file, a message describing why it can't be read /
* searched yet (still indexing, locked, unavailable, errored) or that it isn't attached —
* or undefined when it's `ready`. Shared by read_file and search_files so both report the
* same accurate status instead of search_files claiming a non-ready file isn't attached.
*/
function notReadyMessage(store: AttachedFilesStore, file: string): string | undefined {
const entry = store.resolve(file)
if (entry?.status === 'ready') return undefined
if (entry?.status === 'indexing')
return `File "${file}" is still being indexed. Try again shortly.`
if (entry?.status === 'locked')
return `File "${file}" is locked after a reload. Ask the user to restore access (send a message, or click "Restore access").`
if (entry?.status === 'unavailable')
return `File "${file}" is no longer available (moved, deleted, or its local copy was evicted). Ask the user to re-link it.`
if (entry?.status === 'error')
return `File "${file}" failed to load: ${entry.error ?? 'unknown error'}.`
// Re-sanitized: folder-root placeholder rows carry the raw folder key.
const names = store
.list()
.map((f) =>
f.id ? `${sanitizeAttachmentName(f.name)} (file id: ${f.id})` : sanitizeAttachmentName(f.name)
)
.join(', ')
return `No attached file matching "${file}". Attached files: ${names || '(none)'}.`
}
/**
* When attachments exist but none expose a readable target (`readyFiles()` is empty),
* explain the actual reason instead of always claiming files are still indexing. Empty
* or binary-only linked folders leave only `ready` placeholder rows (filtered out of
* `readyFiles`), while a locked/unavailable restore surfaces those statuses on the rows.
*/
function noReadyFilesMessage(store: AttachedFilesStore): string {
const statuses = new Set(store.list().map((f) => f.status))
if (statuses.has('indexing')) return 'Attached files are still being indexed. Try again shortly.'
if (statuses.has('locked'))
return 'The attached files are locked after a reload. Ask the user to restore access (send a message, or click "Restore access").'
if (statuses.has('unavailable'))
return 'The attached files are no longer available (moved, deleted, or their local copies were evicted). Ask the user to re-link them.'
if (statuses.has('error')) return 'The attached files failed to load.'
return 'No searchable text files are attached (a linked folder may be empty or contain only non-text files).'
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const searchFilesSchema = z.object({
pattern: z.string().describe('JavaScript regular expression to search for.'),
file: z
.string()
.optional()
.describe(
'Optional file to restrict the search to: its file id when one is listed, otherwise its exact filename. Omit to search across all attached files.'
),
ignore_case: z.boolean().optional().describe('Case-insensitive matching. Defaults to false.')
})
const searchFilesToolDef = createToolDef(
searchFilesSchema,
'search_files',
'Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.'
)
export const searchFilesTool: SessionTool<{}> = {
requires: NONE,
def: searchFilesToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const store = storeFrom(helpers)
if (!store || store.count === 0) {
return 'No files are attached to this conversation.'
}
const parsed = searchFilesSchema.parse(args)
// Validate a specifically requested file against the full store first, so a non-ready
// target reports its real status (indexing/locked/…) instead of "not attached".
if (parsed.file) {
const notReady = notReadyMessage(store, parsed.file)
if (notReady) return notReady
}
// Restrict to the resolved row itself, not a name filter: display names may
// collide (same-named attachments on different messages), and a name filter
// would silently search all of them under one label.
const target = parsed.file ? store.resolve(parsed.file) : undefined
const ready = target ? [target] : store.readyFiles()
if (ready.length === 0) {
return noReadyFilesMessage(store)
}
toolCallbacks.setToolStatus(toolId, {
content: `Searching attached files for /${parsed.pattern}/...`
})
// Hit lines are the model's only handle on which row matched, and display
// names may collide — label id-bearing rows with the reference that
// resolves back to exactly that row.
const rows = ready.map((f) => (f.id ? { ...f, name: `${f.name} (file id: ${f.id})` } : f))
// Run in a Worker so a pathological model-supplied regex can't freeze the tab.
const result = await searchFilesInWorker(rows, parsed.pattern, {
flags: parsed.ignore_case ? 'i' : ''
})
if (result.error) {
return `Error: ${result.error}`
}
const scope = target ? `"${target.name}"` : `${ready.length} file(s)`
if (result.hits.length === 0) {
return `No matches for /${parsed.pattern}/ in ${scope}.`
}
const body = result.hits.map((h: SearchHit) => `${h.file}:${h.line}: ${h.text}`).join('\n')
const header = `Found ${result.hits.length} match(es) in ${scope}:`
const footer = result.truncated
? '\n\n(Stopped at the result limit — refine your pattern or pass a `file` to narrow the search.)'
: ''
return `${header}\n${body}${footer}`
}
}
const readFileSchema = z.object({
file: z
.string()
.describe('File to read: its file id when one is listed, otherwise its exact filename.'),
start_line: z.number().int().optional().describe('1-based first line to read. Defaults to 1.'),
end_line: z
.number()
.int()
.optional()
.describe('1-based last line to read. The window is capped at 200 lines.')
})
const readFileToolDef = createToolDef(
readFileSchema,
'read_file',
'Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`<n>→<content>`) plus a pagination note. Files are not in context, so use this to inspect their contents.'
)
export const readFileTool: SessionTool<{}> = {
requires: NONE,
def: readFileToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const store = storeFrom(helpers)
if (!store || store.count === 0) {
return 'No files are attached to this conversation.'
}
const parsed = readFileSchema.parse(args)
const notReady = notReadyMessage(store, parsed.file)
if (notReady) return notReady
const entry = store.resolve(parsed.file)!
toolCallbacks.setToolStatus(toolId, { content: `Reading "${entry.name}"...` })
try {
const res = await readFile(entry, {
startLine: parsed.start_line,
endLine: parsed.end_line
})
return res.text ? `${res.note}\n\n${numberLines(res.text, res.startLine)}` : res.note
} catch (e) {
if (e instanceof FileReadError) {
return `Could not read "${parsed.file}": ${e.message}. The file may have been moved or deleted since it was attached.`
}
return `Error reading "${parsed.file}": ${e instanceof Error ? e.message : String(e)}`
}
}
}
export const fileTools: SessionTool<{}>[] = [searchFilesTool, readFileTool]
function rosterLine(f: AttachedFile): string {
// Message rows are addressed by their stable id (names may collide); session
// rows by name. Names are sanitized at render: this block is model-facing
// prompt text and stored names (legacy, folder children) may carry controls.
const ref = f.id
? `${sanitizeAttachmentName(f.name)} (file id: ${f.id})`
: sanitizeAttachmentName(f.name)
if (f.status === 'indexing') return `- ${ref} (indexing…)`
if (f.status === 'locked') return `- ${ref} (locked — needs the user to restore access)`
if (f.status === 'unavailable') return `- ${ref} (unavailable)`
if (f.status === 'error') return `- ${ref} (failed to load)`
return `- ${ref} — ${f.lineCount} lines, ${humanSize(f.size)}`
}
/** Build the `## Attached files` system-prompt section (metadata only, never content). */
export function buildAttachedFilesRoster(
store: AttachedFilesStore,
orphanedMessageFileIds?: Set<string>
): string {
const lines: string[] = []
for (const folder of store.folders) {
// Folder names are RAW disk keys (never sanitized in the store — they must
// match the handle, children, and persistence record), so this model-facing
// render is where control characters get stripped.
const folderName = sanitizeAttachmentName(folder.name)
// A locked/unavailable folder has no readable children — one line for the whole folder.
if (folder.status === 'locked') {
lines.push(`- ${folderName} (locked — needs the user to restore access)`)
} else if (folder.status === 'unavailable') {
lines.push(`- ${folderName} (unavailable)`)
} else {
lines.push(...folder.files.map(rosterLine))
}
}
// Message-attached files are deliberately NOT listed here: their reference
// lives inside the message that carried them (or the compaction summary),
// exactly like DOM picks — the roster only advertises session-wide links.
lines.push(...store.standalone.map(rosterLine))
// Exception: a message whose API counterpart was dropped by drop-oldest
// compaction takes its in-message reference with it, so those attachments must
// be advertised here or the model can no longer see they exist.
if (orphanedMessageFileIds?.size) {
lines.push(
...store.messageAttached
.filter((f) => f.id && orphanedMessageFileIds.has(f.id))
.map(rosterLine)
)
}
if (lines.length === 0) return ''
return [
'## Attached files',
'The user has attached the following files to this conversation. Their contents are NOT included here.',
'Use the `search_files` tool to find content with a regex, and `read_file` to read a bounded window of lines.',
'Reference a file by its file id when one is shown, otherwise by its filename.',
'',
lines.join('\n')
].join('\n')
}
/**
* Return a copy of the system message with the attached-files roster appended.
* Always derives from the provided base so the roster never accumulates across turns.
*/
export function appendAttachedFilesRoster(
base: ChatCompletionSystemMessageParam,
store: AttachedFilesStore,
orphanedMessageFileIds?: Set<string>
): ChatCompletionSystemMessageParam {
const roster = buildAttachedFilesRoster(store, orphanedMessageFileIds)
if (!roster || typeof base.content !== 'string') return base
return { ...base, content: `${base.content}\n\n${roster}` }
}