mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: filter ai session tools to the user's workspace capabilities
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,8 @@ import {
|
||||
isWebSearchEnabledForProvider
|
||||
} from '$lib/aiStore'
|
||||
import type { WorkspaceMutationTarget } from './workspaceTools'
|
||||
import { resolveSessionAccess, type SessionAccess } from './global/sessionAccess'
|
||||
import { filterSessionTools } from './global/sessionToolset'
|
||||
import {
|
||||
globalToolsFor,
|
||||
loadWorkspaceSkills,
|
||||
@@ -578,6 +580,12 @@ export class AIChatManager {
|
||||
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
|
||||
// `planModeAvailable` derives from it.
|
||||
isSessionChat = $state(false)
|
||||
// What the user may do in this session's operating workspace, resolved in the
|
||||
// send pre-flight (see `resolveSessionAccessForSend`) and applied to the toolset
|
||||
// handed to the chat loop. Undefined until the first send resolves it, and on the
|
||||
// global side-panel chat, where it stays unfiltered.
|
||||
private sessionAccess: SessionAccess | undefined = undefined
|
||||
private sessionAccessGeneration = 0
|
||||
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
|
||||
autoAcceptEditsActive = $derived(
|
||||
this.autoAcceptEditsAvailable &&
|
||||
@@ -1956,11 +1964,12 @@ export class AIChatManager {
|
||||
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
previewTools: this.isSessionChat,
|
||||
skills: this.globalSkills,
|
||||
mcpServers: this.mcpServers
|
||||
mcpServers: this.mcpServers,
|
||||
access: this.sessionAccess
|
||||
})
|
||||
const sessionCtx = this.sessionContextResolver?.()
|
||||
if (sessionCtx) {
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx)
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx, this.sessionAccess)
|
||||
}
|
||||
const baseHelpers: GlobalToolHelpers = {
|
||||
// A session targets its own fixed (possibly forked) workspace, so capture it for
|
||||
@@ -2043,6 +2052,26 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve what the user may do in the workspace this send targets, so the toolset
|
||||
// never advertises a tool whose every call the backend would refuse. Deliberately
|
||||
// re-resolved per send rather than cached for the session's life: a role change (or
|
||||
// a transient `whoami` failure, which resolves fail-open) must take effect on the
|
||||
// next message, not only on a workspace switch.
|
||||
//
|
||||
// Only sessions are filtered; the global side-panel chat keeps the full toolset.
|
||||
private resolveSessionAccessForSend = async (workspace: string) => {
|
||||
if (!this.isSessionChat || !workspace) {
|
||||
this.sessionAccess = undefined
|
||||
return
|
||||
}
|
||||
const generation = ++this.sessionAccessGeneration
|
||||
const access = await resolveSessionAccess(workspace)
|
||||
// A session can be re-pointed between sends: a slower answer for the workspace
|
||||
// we have since left must not install itself over a newer one.
|
||||
if (generation !== this.sessionAccessGeneration) return
|
||||
this.sessionAccess = workspace === (this.operatingWorkspace ?? '') ? access : undefined
|
||||
}
|
||||
|
||||
// Rebuild the GLOBAL system message in place so an updated user instruction (persisted by
|
||||
// the update_user_instructions tool) is picked up on the next chat-loop iteration, which
|
||||
// re-reads this.systemMessage via a getter.
|
||||
@@ -2053,14 +2082,15 @@ export class AIChatManager {
|
||||
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
previewTools: this.isSessionChat,
|
||||
skills: this.globalSkills,
|
||||
mcpServers: this.mcpServers
|
||||
mcpServers: this.mcpServers,
|
||||
access: this.sessionAccess
|
||||
})
|
||||
// Preserve the session-state and active pipeline-editor augmentations that
|
||||
// configureGlobalMode adds — otherwise update_user_instructions (which calls
|
||||
// this) would drop them mid-session.
|
||||
const sessionCtx = this.sessionContextResolver?.()
|
||||
if (sessionCtx) {
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx)
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx, this.sessionAccess)
|
||||
}
|
||||
const pipeline = this.pipelineAiChatHelpers
|
||||
if (pipeline) {
|
||||
@@ -2417,8 +2447,14 @@ export class AIChatManager {
|
||||
base = self.planMode.decorateSystemMessage(base)
|
||||
return base
|
||||
},
|
||||
// The one place every tool source converges — the static global set, the
|
||||
// pipeline and MCP tools `configureGlobalMode` appends, and plan mode's,
|
||||
// added here. Filtering here rather than at `globalToolsFor` is what makes
|
||||
// the capability check cover the dynamic sources too, and the array is both
|
||||
// advertised to the model and used to dispatch the call, so a withheld tool
|
||||
// is unreachable rather than merely unlisted.
|
||||
get tools() {
|
||||
return [...self.tools, ...self.planMode.tools]
|
||||
return filterSessionTools([...self.tools, ...self.planMode.tools], self.sessionAccess)
|
||||
},
|
||||
get helpers() {
|
||||
return self.helpers
|
||||
@@ -2878,8 +2914,13 @@ export class AIChatManager {
|
||||
if (this.mode === AIMode.GLOBAL) {
|
||||
await Promise.all([
|
||||
this.refreshGlobalSkills(this.operatingWorkspace ?? ''),
|
||||
this.refreshMcpServers(this.operatingWorkspace ?? '')
|
||||
this.refreshMcpServers(this.operatingWorkspace ?? ''),
|
||||
this.resolveSessionAccessForSend(this.operatingWorkspace ?? '')
|
||||
])
|
||||
// The two refreshes above rebuild as each lands, in whichever order they do,
|
||||
// so a prompt built before the access profile resolved would still advertise
|
||||
// the withheld tools. Rebuild once more now that all three have settled.
|
||||
this.configureGlobalMode()
|
||||
}
|
||||
// Stop/Escape during the beforeSend pre-flight aborted this send before any
|
||||
// request went out. Mirror the main "cancelled before usable output" recovery:
|
||||
|
||||
@@ -215,6 +215,7 @@ const VARIABLE_MASKED_NOTE =
|
||||
const SECRET_UNCOMPARABLE_NOTE =
|
||||
'Note: this is a SECRET variable — its value is never shown and cannot be compared, so it may ALSO have changed beyond what this diff shows.\n\n'
|
||||
import { apiCatalogTools } from './apiCatalogTools'
|
||||
import type { SessionAccess } from './sessionAccess'
|
||||
|
||||
const ITEM_TYPES = [
|
||||
'script',
|
||||
@@ -1206,8 +1207,18 @@ const buildGlobalSystemPrompt = (
|
||||
previewTools: boolean,
|
||||
folderCtx?: FolderPromptContext,
|
||||
skills: AiSkillListItem[] = [],
|
||||
mcpServers: McpServer[] = []
|
||||
mcpServers: McpServer[] = [],
|
||||
access?: SessionAccess
|
||||
) => {
|
||||
// Each `can*` mirrors the capability that gates the matching tools in
|
||||
// SESSION_TOOL_POLICIES, so a rule cannot outlive the tool it describes. An
|
||||
// unresolved profile keeps every block.
|
||||
const canWriteDraft = !access || access.capabilities.has('write_draft')
|
||||
const canDeploy = !access || access.capabilities.has('deploy')
|
||||
const canRunPreview = !access || access.capabilities.has('run_preview')
|
||||
// Each gated block carries its own leading newline, so dropping one leaves no blank
|
||||
// line behind and a full-access prompt is byte-for-byte the ungated text.
|
||||
const when = (cond: boolean, block: string) => (cond ? block : '')
|
||||
const folderGuidance = buildFolderGuidance(username, folderCtx)
|
||||
const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : ''
|
||||
// `previewTools` doubles as "this is a session chat" — sessions are the only
|
||||
@@ -1228,48 +1239,89 @@ const buildGlobalSystemPrompt = (
|
||||
|
||||
The current user's workspace username is "${username}".
|
||||
|
||||
Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.
|
||||
${
|
||||
canWriteDraft
|
||||
? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.'
|
||||
: "Use tools to inspect workspace items and the workspace's run history. You have no tools to create or change anything here — this user's role does not allow it — so when they ask for a change, say plainly that you cannot make it rather than describing steps as if you had."
|
||||
}${when(
|
||||
// Every line here is about choosing a path for something NEW, down to the folder
|
||||
// guidance; with nothing to create, the whole block is context tax.
|
||||
canWriteDraft,
|
||||
`
|
||||
|
||||
Path conventions:
|
||||
- A workspace path starts with one of two namespaces; its trailing <name> may itself contain "/", so a path has three or more segments:
|
||||
- \`u/${username}/<name>\` — your personal scope. Default for ad-hoc, exploratory, or scratch work.
|
||||
- \`f/<folder>/<name>\` — a shared folder scope; the <folder> must already exist (a bare \`f/<name>\` with no folder segment is INVALID and will fail).
|
||||
- If the user supplies a fully qualified \`f/<folder>/...\` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a \`u/${username}/...\` path unless a tool rejects it.
|
||||
- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/<name>\`. Never invent an \`f/<folder>/...\` path for a folder that does not exist; create one with \`create_folder\` only when the user explicitly asks for a new folder.${folderGuidanceBlock}
|
||||
- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/<name>\`. Never invent an \`f/<folder>/...\` path for a folder that does not exist; create one with \`create_folder\` only when the user explicitly asks for a new folder.${folderGuidanceBlock}`
|
||||
)}
|
||||
|
||||
Rules:
|
||||
- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.
|
||||
Rules:${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.`
|
||||
)}
|
||||
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
|
||||
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}
|
||||
- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.
|
||||
- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.
|
||||
- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.
|
||||
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when(
|
||||
canDeploy,
|
||||
`
|
||||
- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.`
|
||||
)}${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft).${when(
|
||||
canDeploy,
|
||||
' Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.'
|
||||
)}`
|
||||
)}
|
||||
- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value.
|
||||
- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.
|
||||
- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.
|
||||
- Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema.
|
||||
- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).
|
||||
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
|
||||
${pipelineBullet}
|
||||
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
|
||||
${pipelineBullet}`
|
||||
)}${when(
|
||||
canRunPreview && canWriteDraft,
|
||||
`
|
||||
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.`
|
||||
)}
|
||||
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run.
|
||||
- To see what a flow run actually did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — use get_flow_run_details with the run id (it also works while the flow is still running). Pass step to read one step's result in full (capped at 12k chars). Prefer it over get_job_logs when you need step results rather than logs.
|
||||
- Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself.
|
||||
- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its edit drawer, or the page's filters otherwise). Never just describe where to click.
|
||||
- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its edit drawer, or the page's filters otherwise). Never just describe where to click.${when(
|
||||
canDeploy,
|
||||
`
|
||||
- When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" — it opens the Compare & Deploy review page.${
|
||||
previewTools
|
||||
? ' By default it preselects the items this chat modified; pass items ("<kind>:<path>" entries) to control the selection'
|
||||
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
|
||||
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
|
||||
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
|
||||
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft.
|
||||
previewTools
|
||||
? ' By default it preselects the items this chat modified; pass items ("<kind>:<path>" entries) to control the selection'
|
||||
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
|
||||
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.`
|
||||
)}
|
||||
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools${when(
|
||||
canWriteDraft || canDeploy,
|
||||
` — use ${[canWriteDraft && 'the draft tools', canDeploy && 'delete_workspace_item']
|
||||
.filter(Boolean)
|
||||
.join(' and ')} instead`
|
||||
)}.
|
||||
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs).${when(
|
||||
canRunPreview && canWriteDraft,
|
||||
' To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft.'
|
||||
)}
|
||||
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
|
||||
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
|
||||
- Keep context targeted.${
|
||||
previewTools
|
||||
? `
|
||||
? `${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty or not-yet-created folder is fine (create_folder first if needed, then open it). Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty or not-yet-created folder is fine (create_folder first if needed, then open it). Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
|
||||
)}
|
||||
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
|
||||
- To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open.
|
||||
- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected.
|
||||
@@ -1280,7 +1332,10 @@ ${
|
||||
}
|
||||
- open_page opens its page as a tab in the side-panel preview next to the chat — the only way to show one of these pages there (open_preview only handles editable items). Changing filters on a page already open updates that same tab; only pass new_tab when the user explicitly asks for a separate tab.
|
||||
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it — never create a second artifact for the same document. Each content change is saved as a version, keeping the most recent ones: use list_artifact_versions and read_artifact's version argument to recover earlier wording the user asks to go back to, rather than rewriting it from memory. list_artifact_versions is the source of truth for what is still available — do not assume a version that is not listed.
|
||||
- The artifact whose \`role\` is \`plan\` is this session's plan document — one per session, surviving \`/clear\` — and \`approvedVersion\` is the version the user signed off. Below \`version\` means the current text is a proposal they have not agreed to, usually one they turned down; absent means nothing here was ever approved. In either case never describe the current text as agreed or build from it: ask what they want changed, or read the version they did agree to with read_artifact. An agreed plan is an ordinary artifact: revise it the same way, and do not call exit_plan_mode to amend it — that tool only exists while plan mode is active. Update it when the work parts ways with it (a step turns out unnecessary, an approach has to change, scope grows), not after every step you complete. Never quietly rewrite it to describe what you already built: say in your reply how the work now differs from the approved plan, then update the document. Updating it asks the user to approve nothing, so that sentence in your reply is their only chance to object before you keep building.`
|
||||
- The artifact whose \`role\` is \`plan\` is this session's plan document — one per session, surviving \`/clear\` — and \`approvedVersion\` is the version the user signed off. Below \`version\` means the current text is a proposal they have not agreed to, usually one they turned down; absent means nothing here was ever approved. In either case never describe the current text as agreed or build from it: ask what they want changed, or read the version they did agree to with read_artifact. An agreed plan is an ordinary artifact: revise it the same way${when(
|
||||
canWriteDraft,
|
||||
', and do not call exit_plan_mode to amend it — that tool only exists while plan mode is active'
|
||||
)}. Update it when the work parts ways with it (a step turns out unnecessary, an approach has to change, scope grows), not after every step you complete. Never quietly rewrite it to describe what you already built: say in your reply how the work now differs from the approved plan, then update the document. Updating it asks the user to approve nothing, so that sentence in your reply is their only chance to object before you keep building.`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -1293,22 +1348,37 @@ Documentation:
|
||||
|
||||
Flows:
|
||||
- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>".
|
||||
- Use read_flow_module_code and set_flow_module_code for inline script bodies.
|
||||
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.
|
||||
- Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.`
|
||||
)}
|
||||
|
||||
Raw apps:
|
||||
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.
|
||||
- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
|
||||
- Use write_app_runnable and delete_app_runnable for backend runnables.
|
||||
- Use init_app only after confirming framework, path, and summary with the user.
|
||||
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.
|
||||
- Use init_app only after confirming framework, path, and summary with the user.`
|
||||
)}${when(
|
||||
canDeploy,
|
||||
`
|
||||
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.`
|
||||
)}
|
||||
|
||||
Data Tables:
|
||||
- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL.
|
||||
- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql.
|
||||
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.
|
||||
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.
|
||||
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.${
|
||||
- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists${when(canRunPreview, ' or call exec_datatable_sql')}.
|
||||
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.${when(
|
||||
canRunPreview,
|
||||
`
|
||||
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.`
|
||||
)}${when(
|
||||
canWriteDraft,
|
||||
`
|
||||
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.`
|
||||
)}${
|
||||
skills.length > 0
|
||||
? `
|
||||
|
||||
@@ -2198,12 +2268,29 @@ export type SessionPromptContext = {
|
||||
|
||||
/** Session-state guidance appended to the global system prompt so the model
|
||||
* knows where its work lands (staged fork vs the live workspace). */
|
||||
export function getSessionContextPromptSection(ctx: SessionPromptContext): string {
|
||||
export function getSessionContextPromptSection(
|
||||
ctx: SessionPromptContext,
|
||||
access?: SessionAccess
|
||||
): string {
|
||||
// This section is concatenated onto an already capability-gated prompt, so it has to
|
||||
// honour the same profile: naming a tool the toolset withheld is what makes the model
|
||||
// invent calls. Each branch keeps its "where work lands" fact either way.
|
||||
const canDeploy = !access || access.capabilities.has('deploy')
|
||||
const canWriteDraft = !access || access.capabilities.has('write_draft')
|
||||
const canRunPreview = !access || access.capabilities.has('run_preview')
|
||||
const targets = [
|
||||
'reads',
|
||||
canWriteDraft && 'drafts',
|
||||
canRunPreview && 'test runs',
|
||||
canDeploy && 'deploys'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
const lines = [
|
||||
'',
|
||||
'',
|
||||
'Session state:',
|
||||
'- This chat is a Windmill AI session with its own operating workspace: every tool call (reads, drafts, test runs, deploys) targets that workspace.'
|
||||
`- This chat is a Windmill AI session with its own operating workspace: every tool call (${targets}) targets that workspace.`
|
||||
]
|
||||
if (ctx.pendingForkOf) {
|
||||
lines.push(
|
||||
@@ -2211,19 +2298,19 @@ export function getSessionContextPromptSection(ctx: SessionPromptContext): strin
|
||||
)
|
||||
} else if (ctx.parentWorkspaceId && ctx.isDevWorkspace) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". deploy_workspace_item publishes into the dev workspace only; the user reviews & promotes changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".`
|
||||
`- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". ${canDeploy ? 'deploy_workspace_item publishes' : 'Changes land'} into the dev workspace only; the user reviews & promotes changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".`
|
||||
)
|
||||
} else if (ctx.parentWorkspaceId) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. deploy_workspace_item publishes into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".`
|
||||
`- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. ${canDeploy ? 'deploy_workspace_item publishes' : 'Changes land'} into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".`
|
||||
)
|
||||
} else if (ctx.forkParentUnknown) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. deploy_workspace_item publishes into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.`
|
||||
`- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. ${canDeploy ? 'deploy_workspace_item publishes' : 'Changes land'} into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.`
|
||||
)
|
||||
} else if (ctx.workspaceId) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork. deploy_workspace_item publishes directly to everyone in it.`
|
||||
`- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork.${canDeploy ? ' deploy_workspace_item publishes directly to everyone in it.' : ''}`
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
@@ -7338,6 +7425,10 @@ export function prepareGlobalSystemMessage(
|
||||
user?: { username: string; is_admin?: boolean; folders?: string[]; folders_read?: string[] }
|
||||
skills?: AiSkillListItem[]
|
||||
mcpServers?: McpServer[]
|
||||
/** Capabilities of the chat's operating workspace. Undefined leaves every block
|
||||
* in place: the prompt is never what withholds a tool, only the documentation of
|
||||
* the toolset that was actually assembled — so the two must not drift apart. */
|
||||
access?: SessionAccess
|
||||
}
|
||||
): ChatCompletionSystemMessageParam {
|
||||
const user = opts?.user ?? get(userStore)
|
||||
@@ -7354,7 +7445,8 @@ export function prepareGlobalSystemMessage(
|
||||
opts?.previewTools ?? false,
|
||||
folderCtx,
|
||||
opts?.skills ?? [],
|
||||
opts?.mcpServers ?? []
|
||||
opts?.mcpServers ?? [],
|
||||
opts?.access
|
||||
)
|
||||
if (instructions?.workspace?.trim()) {
|
||||
content = `${content}\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n${instructions.workspace.trim()}`
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { whoami, deployPermission } = vi.hoisted(() => ({
|
||||
whoami: vi.fn(),
|
||||
deployPermission: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('$lib/gen', () => ({ UserService: { whoami } }))
|
||||
vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployPermission: deployPermission }))
|
||||
|
||||
import { resolveSessionAccess } from './sessionAccess'
|
||||
|
||||
type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean }
|
||||
|
||||
function user(overrides: WhoamiOverrides) {
|
||||
return {
|
||||
email: 'u@windmill.dev',
|
||||
username: 'u',
|
||||
is_admin: false,
|
||||
is_super_admin: false,
|
||||
operator: false,
|
||||
created_at: '',
|
||||
disabled: false,
|
||||
groups: [],
|
||||
folders: [],
|
||||
folders_read: [],
|
||||
folders_owners: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function capabilitiesFor(overrides: WhoamiOverrides, workspace = 'ws') {
|
||||
whoami.mockResolvedValueOnce(user(overrides))
|
||||
const access = await resolveSessionAccess(workspace)
|
||||
return access.capabilities
|
||||
}
|
||||
|
||||
describe('resolveSessionAccess', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
deployPermission.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('gives a developer every capability', async () => {
|
||||
const caps = await capabilitiesFor({})
|
||||
expect([...caps].sort()).toEqual(['deploy', 'run_preview', 'write_draft'])
|
||||
})
|
||||
|
||||
it('leaves an operator only what their token can still do', async () => {
|
||||
deployPermission.mockResolvedValue({ ok: false, reason: 'operators cannot deploy' })
|
||||
const caps = await capabilitiesFor({ operator: true })
|
||||
expect([...caps]).toEqual([])
|
||||
})
|
||||
|
||||
// The two gates have OPPOSITE precedence in the backend, so a role ladder would
|
||||
// get one of them wrong: drafts.rs returns Ok for `authed.is_admin` before the
|
||||
// operator branch, while jobs.rs checks the operator flag first with no escape.
|
||||
it('lets an admin who is also an operator write drafts but not run previews', async () => {
|
||||
deployPermission.mockResolvedValue({ ok: false, reason: 'operators cannot deploy' })
|
||||
const caps = await capabilitiesFor({ is_admin: true, operator: true })
|
||||
expect(caps.has('write_draft')).toBe(true)
|
||||
expect(caps.has('run_preview')).toBe(false)
|
||||
})
|
||||
|
||||
// `ApiAuthed.is_admin` is `usr.is_admin || super_admin`, but whoami reports the two
|
||||
// separately — so a superadmin who is an operator must resolve the same way.
|
||||
it('treats a superadmin as an admin for the draft gate', async () => {
|
||||
deployPermission.mockResolvedValue({ ok: false, reason: 'operators cannot deploy' })
|
||||
const caps = await capabilitiesFor({ is_super_admin: true, operator: true })
|
||||
expect(caps.has('write_draft')).toBe(true)
|
||||
expect(caps.has('run_preview')).toBe(false)
|
||||
})
|
||||
|
||||
// Deploy is operation-shaped: protection rulesets can block a plain developer, and
|
||||
// wm_deployers can unblock a non-admin. The resolver must not second-guess it.
|
||||
it('takes deploy from the shared permission check, not from the role', async () => {
|
||||
deployPermission.mockResolvedValue({ ok: false, reason: 'restricted to deployers' })
|
||||
const caps = await capabilitiesFor({})
|
||||
expect(caps.has('deploy')).toBe(false)
|
||||
expect(caps.has('write_draft')).toBe(true)
|
||||
})
|
||||
|
||||
// checkDeployPermission tests `me.is_admin` alone, while the backend rule it mirrors
|
||||
// receives `authed.is_admin` (workspace admin OR superadmin). The resolver folds the
|
||||
// two before delegating, so a superadmin keeps deploy under RestrictDeployToDeployers.
|
||||
it('presents a superadmin as admin to the deploy check', async () => {
|
||||
whoami.mockResolvedValueOnce(user({ is_super_admin: true }))
|
||||
await resolveSessionAccess('ws')
|
||||
expect(deployPermission).toHaveBeenCalledWith('ws', expect.objectContaining({ is_admin: true }))
|
||||
})
|
||||
|
||||
// Fail open, matching checkDeployPermission: a transient whoami failure must not
|
||||
// strip a session's toolset — the server is still the enforcement point.
|
||||
it('grants everything when the role cannot be resolved', async () => {
|
||||
whoami.mockRejectedValueOnce(new Error('network'))
|
||||
const access = await resolveSessionAccess('ws')
|
||||
expect(access.capabilities.has('write_draft')).toBe(true)
|
||||
expect(access.capabilities.has('deploy')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { UserService, type User } from '$lib/gen'
|
||||
import { checkDeployPermission } from '$lib/utils_workspace_deploy'
|
||||
|
||||
/**
|
||||
* What a user may do in ONE workspace, as the AI session toolset needs to know it.
|
||||
*
|
||||
* These are permission facts, never relevance judgements — a capability is absent
|
||||
* only when the backend would refuse the call. The client is not the enforcement
|
||||
* point (the token is); this exists so the model is never handed a tool whose every
|
||||
* invocation would 401.
|
||||
*
|
||||
* The rules below are NOT a role ladder. The backend's precedence between "admin"
|
||||
* and "operator" differs per capability, and each rule cites the site it mirrors —
|
||||
* copying the ladder instead would get `write_draft` wrong for a superadmin whose
|
||||
* workspace role is operator.
|
||||
*/
|
||||
export type SessionCapability = 'write_draft' | 'run_preview' | 'deploy'
|
||||
|
||||
export type SessionAccess = {
|
||||
/** The workspace these capabilities were resolved against — a session targets its
|
||||
* own (possibly forked) workspace, which is not necessarily the navigated one. */
|
||||
workspace: string
|
||||
capabilities: ReadonlySet<SessionCapability>
|
||||
}
|
||||
|
||||
const ALL_CAPABILITIES: SessionCapability[] = ['write_draft', 'run_preview', 'deploy']
|
||||
|
||||
/** Benefit of the doubt: an unresolvable role must not blank the toolset. Mirrors the
|
||||
* fail-open contract of `checkDeployPermission`, which is the same kind of advisory
|
||||
* client-side mirror and defers to the server for the actual refusal. */
|
||||
export function fullSessionAccess(workspace: string): SessionAccess {
|
||||
return { workspace, capabilities: new Set(ALL_CAPABILITIES) }
|
||||
}
|
||||
|
||||
export function hasCapabilities(
|
||||
access: SessionAccess | undefined,
|
||||
requires: readonly SessionCapability[]
|
||||
): boolean {
|
||||
if (!access) return true
|
||||
return requires.every((c) => access.capabilities.has(c))
|
||||
}
|
||||
|
||||
/**
|
||||
* `ApiAuthed.is_admin` is `usr.is_admin || super_admin` (windmill-api-auth/src/auth.rs),
|
||||
* while `whoami` reports the two separately — so every rule below that mirrors an
|
||||
* `authed.is_admin` check must OR them back together.
|
||||
*/
|
||||
function isAuthedAdmin(me: User): boolean {
|
||||
return !!me.is_admin || !!me.is_super_admin
|
||||
}
|
||||
|
||||
export async function resolveSessionAccess(workspace: string): Promise<SessionAccess> {
|
||||
let me: User
|
||||
try {
|
||||
me = await UserService.whoami({ workspace })
|
||||
} catch {
|
||||
return fullSessionAccess(workspace)
|
||||
}
|
||||
|
||||
const capabilities = new Set<SessionCapability>()
|
||||
|
||||
// windmill-api/src/drafts.rs `require_can_write_path`: `authed.is_admin` returns Ok
|
||||
// BEFORE the operator branch, so an admin who is also an operator may still save
|
||||
// drafts. Every chat write tool funnels through the draft lifecycle, which is why
|
||||
// this single capability covers scripts, flows, apps, resources, variables,
|
||||
// schedules and triggers alike.
|
||||
if (isAuthedAdmin(me) || !me.operator) {
|
||||
capabilities.add('write_draft')
|
||||
}
|
||||
|
||||
// windmill-api/src/jobs.rs `run_preview_script` / `run_dynamic_select`: the operator
|
||||
// check comes first and has no admin escape — the opposite precedence to drafts.
|
||||
if (!me.operator) {
|
||||
capabilities.add('run_preview')
|
||||
}
|
||||
|
||||
// Deploy authorization is operation-shaped, not role-shaped: protection rulesets,
|
||||
// `wm_deployers` membership and per-ruleset bypass lists all feed into it. Reuse the
|
||||
// existing mirror rather than re-deriving it, so the chat and the visible deploy
|
||||
// button can never disagree.
|
||||
//
|
||||
// `is_admin` is folded first because that helper tests `me.is_admin` alone, while the
|
||||
// rule it mirrors receives `authed.is_admin` — superadmin included. Without this a
|
||||
// superadmin who is a plain member of a workspace under `RestrictDeployToDeployers`
|
||||
// would lose the deploy tools the backend grants them.
|
||||
if ((await checkDeployPermission(workspace, { ...me, is_admin: isAuthedAdmin(me) })).ok) {
|
||||
capabilities.add('deploy')
|
||||
}
|
||||
|
||||
return { workspace, capabilities }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The toolset pulls in the script/flow editor tools, hence monaco. Same stand-ins
|
||||
// as global/core.test.ts — this suite only ever reads `def.function.name`.
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {},
|
||||
languages: {},
|
||||
KeyCode: {},
|
||||
Uri: { parse: (value: string) => ({ toString: () => value }) },
|
||||
MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }
|
||||
}))
|
||||
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
|
||||
getTypeScriptWorker: async () => async () => ({}),
|
||||
typescriptVersion: 'test'
|
||||
}))
|
||||
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
import {
|
||||
globalTools,
|
||||
getSessionContextPromptSection,
|
||||
prepareGlobalSystemMessage,
|
||||
type SessionPromptContext
|
||||
} from './core'
|
||||
import { appendPlanModeInstructions } from '../planMode'
|
||||
import { pipelineTools } from '../pipeline/core'
|
||||
import { createMcpTools } from './mcpTools'
|
||||
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from '../planMode'
|
||||
import { SESSION_TOOL_POLICIES, filterSessionTools, sessionToolAllowed } from './sessionToolset'
|
||||
import { fullSessionAccess, type SessionAccess, type SessionCapability } from './sessionAccess'
|
||||
|
||||
/** Every tool name that can reach a session's toolset. `globalTools` is only part of
|
||||
* it — pipeline and MCP tools are appended by `configureGlobalMode`, and plan mode's
|
||||
* at request time — which is exactly why the policy table is keyed by name rather
|
||||
* than declared on `globalTools`. */
|
||||
function assembledSessionToolNames(): string[] {
|
||||
const mcp = createMcpTools([{ path: 'f/test/server' } as any])
|
||||
return [
|
||||
...globalTools.map((t) => t.def.function.name),
|
||||
...pipelineTools.map((t) => t.def.function.name),
|
||||
...mcp.map((t) => t.def.function.name),
|
||||
ENTER_PLAN_MODE_TOOL,
|
||||
EXIT_PLAN_MODE_TOOL
|
||||
]
|
||||
}
|
||||
|
||||
function accessWith(capabilities: SessionCapability[]): SessionAccess {
|
||||
return { workspace: 'test', capabilities: new Set(capabilities) }
|
||||
}
|
||||
|
||||
/** One per branch of getSessionContextPromptSection — each words the deploy target
|
||||
* differently, so a gate fixed in one branch can still leak in another. */
|
||||
const SESSION_CONTEXTS: SessionPromptContext[] = [
|
||||
{ pendingForkOf: 'parent' },
|
||||
{ workspaceId: 'dev', parentWorkspaceId: 'parent', isDevWorkspace: true },
|
||||
{ workspaceId: 'fork', parentWorkspaceId: 'parent' },
|
||||
{ workspaceId: 'fork', forkParentUnknown: true },
|
||||
{ workspaceId: 'live' },
|
||||
{}
|
||||
]
|
||||
|
||||
describe('session tool policies', () => {
|
||||
// The fail-closed guarantee: `sessionToolAllowed` withholds an unregistered tool,
|
||||
// so a tool shipped without a policy would silently vanish from restricted
|
||||
// sessions. This test is what turns that into a build failure instead.
|
||||
it('covers every tool that can reach a session toolset', () => {
|
||||
const missing = assembledSessionToolNames().filter((n) => !SESSION_TOOL_POLICIES[n])
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('does not carry policies for tools that no longer exist', () => {
|
||||
const assembled = new Set(assembledSessionToolNames())
|
||||
const stale = Object.keys(SESSION_TOOL_POLICIES).filter((n) => !assembled.has(n))
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
|
||||
// Full access must be a no-op, or every existing session (and the ai_evals
|
||||
// baseline measured against it) changes behaviour.
|
||||
it('withholds nothing from a session with every capability', () => {
|
||||
const names = assembledSessionToolNames()
|
||||
const allowed = names.filter((n) => sessionToolAllowed(n, fullSessionAccess('test')))
|
||||
expect(allowed).toEqual(names)
|
||||
})
|
||||
|
||||
it('passes the toolset through untouched when access is unresolved', () => {
|
||||
const tools = globalTools.map((t) => ({ def: t.def }))
|
||||
expect(filterSessionTools(tools, undefined)).toHaveLength(tools.length)
|
||||
})
|
||||
|
||||
it('withholds draft writes, deploys and previews without the capability', () => {
|
||||
const readOnly = accessWith([])
|
||||
expect(sessionToolAllowed('write_script', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('write_variable', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('deploy_workspace_item', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('test_run_script', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('exec_datatable_sql', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('list_workspace_items', readOnly)).toBe(true)
|
||||
expect(sessionToolAllowed('list_runs', readOnly)).toBe(true)
|
||||
expect(sessionToolAllowed('cancel_job', readOnly)).toBe(true)
|
||||
})
|
||||
|
||||
// Relevance is the second axis: these need no capability, so `requires` alone
|
||||
// would keep advertising them to a session that can never author anything.
|
||||
it('drops authoring aids when drafts cannot be written', () => {
|
||||
const readOnly = accessWith(['deploy'])
|
||||
expect(sessionToolAllowed('get_instructions', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('search_npm_packages', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('create_folder', readOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('search_docs', readOnly)).toBe(true)
|
||||
})
|
||||
|
||||
// Draft writes survive without `deploy`, and vice versa: the two are separate
|
||||
// backend gates (drafts.rs vs. the deploy protection rules), not one ladder.
|
||||
// The prompt is documentation OF the toolset, so it must never name a tool the same
|
||||
// profile withheld — an instruction to call a tool the model was not given is what
|
||||
// produces invented calls and promises the chat cannot keep.
|
||||
//
|
||||
// Asserted over the ASSEMBLED message, not `prepareGlobalSystemMessage` alone: what
|
||||
// actually ships is that plus the session-state section and plan mode's decoration,
|
||||
// each appended by a different caller, and gating only the first looks correct while
|
||||
// the other two still name withheld tools. Both axes are swept — every reachable
|
||||
// profile, and every tool from the policy table — so neither a new tool nor a new
|
||||
// capability combination slips past.
|
||||
it.each([
|
||||
['read-only', []],
|
||||
['drafts, no deploy', ['write_draft', 'run_preview']],
|
||||
['drafts, no preview', ['write_draft', 'deploy']],
|
||||
['deploy, no drafts', ['deploy']]
|
||||
] as [string, SessionCapability[]][])(
|
||||
'never names a withheld tool in the assembled prompt (%s)',
|
||||
(_label, capabilities) => {
|
||||
const access = accessWith(capabilities)
|
||||
const withheld = assembledSessionToolNames().filter((n) => !sessionToolAllowed(n, access))
|
||||
expect(withheld.length).toBeGreaterThan(0)
|
||||
for (const previewTools of [false, true]) {
|
||||
for (const ctx of SESSION_CONTEXTS) {
|
||||
let msg = prepareGlobalSystemMessage(undefined, {
|
||||
previewTools,
|
||||
user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] },
|
||||
access
|
||||
})
|
||||
msg = {
|
||||
...msg,
|
||||
content: (msg.content as string) + getSessionContextPromptSection(ctx, access)
|
||||
}
|
||||
// The escalation variant names exit_plan_mode a second time.
|
||||
for (const blocks of [0, 9]) {
|
||||
const full = appendPlanModeInstructions(msg, blocks).content as string
|
||||
expect(withheld.filter((n) => full.includes(n))).toEqual([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Full access must reproduce the pre-capabilities prompt exactly, or every
|
||||
// existing session's cached prefix and the ai_evals baseline move underneath us.
|
||||
it('builds an unchanged prompt when every capability is present', () => {
|
||||
const user = { username: 'alex', folders: ['shared'], folders_read: ['shared'] }
|
||||
for (const previewTools of [false, true]) {
|
||||
const ungated = prepareGlobalSystemMessage(undefined, { previewTools, user }).content
|
||||
const full = prepareGlobalSystemMessage(undefined, {
|
||||
previewTools,
|
||||
user,
|
||||
access: fullSessionAccess('test')
|
||||
}).content
|
||||
expect(full).toBe(ungated)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats write_draft and deploy as independent', () => {
|
||||
const draftsOnly = accessWith(['write_draft'])
|
||||
expect(sessionToolAllowed('write_script', draftsOnly)).toBe(true)
|
||||
expect(sessionToolAllowed('deploy_workspace_item', draftsOnly)).toBe(false)
|
||||
|
||||
const deployOnly = accessWith(['deploy'])
|
||||
expect(sessionToolAllowed('write_script', deployOnly)).toBe(false)
|
||||
expect(sessionToolAllowed('deploy_workspace_item', deployOnly)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { hasCapabilities, type SessionAccess, type SessionCapability } from './sessionAccess'
|
||||
|
||||
/**
|
||||
* Why a tool may be withheld from an AI session. The two axes are deliberately
|
||||
* separate: `requires` is auditable authorization metadata (the backend would
|
||||
* refuse the call), `relevance` is context economy (the call would succeed but
|
||||
* the tool exists only to serve authoring). Collapsing them into one field makes
|
||||
* the permission vocabulary lie — `search_npm_packages` is permitted for every
|
||||
* user, it is just dead weight in a session that cannot write a draft.
|
||||
*/
|
||||
export type SessionToolPolicy = {
|
||||
requires: readonly SessionCapability[]
|
||||
/** Dropped when the session cannot write drafts, regardless of `requires`. */
|
||||
relevance?: 'authoring'
|
||||
}
|
||||
|
||||
const NONE: SessionToolPolicy = { requires: [] }
|
||||
const AUTHORING_AID: SessionToolPolicy = { requires: [], relevance: 'authoring' }
|
||||
const WRITE_DRAFT: SessionToolPolicy = { requires: ['write_draft'] }
|
||||
const DEPLOY: SessionToolPolicy = { requires: ['deploy'] }
|
||||
const RUN_PREVIEW: SessionToolPolicy = { requires: ['run_preview'] }
|
||||
|
||||
/**
|
||||
* Policy for every tool that can reach an AI session's toolset — the STATIC global
|
||||
* set plus the sources appended after it (pipeline, MCP, plan mode). Keyed by tool
|
||||
* name rather than declared on each tool object because those later sources are
|
||||
* built by factories in other modules: a field on `globalTools` alone would look
|
||||
* exhaustive while silently missing them.
|
||||
*
|
||||
* Completeness is enforced by a test that assembles the full session toolset and
|
||||
* asserts every name resolves here — add the entry with the tool, not after.
|
||||
*/
|
||||
export const SESSION_TOOL_POLICIES: Record<string, SessionToolPolicy> = {
|
||||
// ── Reads, docs and conversation ────────────────────────────────────────
|
||||
read_skill: NONE,
|
||||
open_page: NONE,
|
||||
askUserQuestion: NONE,
|
||||
update_user_instructions: NONE,
|
||||
search_docs: NONE,
|
||||
read_docs_page: NONE,
|
||||
list_workspace_items: NONE,
|
||||
read_workspace_item: NONE,
|
||||
read_flow_module_code: NONE,
|
||||
read_app_file: NONE,
|
||||
search_app: NONE,
|
||||
diff: NONE,
|
||||
read_file: NONE,
|
||||
search_files: NONE,
|
||||
|
||||
// ── Run history ─────────────────────────────────────────────────────────
|
||||
list_runs: NONE,
|
||||
get_flow_run_details: NONE,
|
||||
get_job_logs: NONE,
|
||||
cancel_job: NONE,
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────────────
|
||||
list_datatables: NONE,
|
||||
get_datatable_table_schema: NONE,
|
||||
list_ducklakes: NONE,
|
||||
exec_datatable_sql: RUN_PREVIEW,
|
||||
|
||||
// ── API catalog and MCP ─────────────────────────────────────────────────
|
||||
// The wrapper tools themselves need nothing; the endpoint a call names is
|
||||
// policed per-endpoint at call time (see apiCatalogTools).
|
||||
search_api_endpoints: NONE,
|
||||
call_api_get: NONE,
|
||||
call_api_endpoint: NONE,
|
||||
search_mcp_tools: NONE,
|
||||
call_mcp_read_tool: NONE,
|
||||
call_mcp_write_tool: NONE,
|
||||
|
||||
// ── Session preview panel and artifacts ─────────────────────────────────
|
||||
open_preview: NONE,
|
||||
get_preview_status: NONE,
|
||||
close_page: NONE,
|
||||
get_app_runtime_logs: NONE,
|
||||
list_app_runs: NONE,
|
||||
search_dom: NONE,
|
||||
read_dom: NONE,
|
||||
take_screenshot: NONE,
|
||||
create_artifact: NONE,
|
||||
update_artifact: NONE,
|
||||
list_artifacts: NONE,
|
||||
read_artifact: NONE,
|
||||
list_artifact_versions: NONE,
|
||||
|
||||
// ── Authoring aids: permitted for everyone, useless without write_draft ──
|
||||
get_instructions: AUTHORING_AID,
|
||||
search_hub_scripts: AUTHORING_AID,
|
||||
search_npm_packages: AUTHORING_AID,
|
||||
search_resource_types: AUTHORING_AID,
|
||||
get_trigger_schema: AUTHORING_AID,
|
||||
get_schedule_schema: AUTHORING_AID,
|
||||
get_db_schema: AUTHORING_AID,
|
||||
// An operator's token may create a folder, but a folder exists to hold
|
||||
// authored items, so it goes with them rather than with the reads.
|
||||
create_folder: AUTHORING_AID,
|
||||
// Ungated on purpose, for two reasons. Plan mode's deliverable is a plan artifact,
|
||||
// which is worth producing for someone else to execute even when this user can
|
||||
// change nothing themselves. And it is a posture the USER selects, so withholding
|
||||
// `exit_plan_mode` strands the model in it: the posture's instructions order it to
|
||||
// call that tool to hand the plan over, and nothing else ends the round.
|
||||
enter_plan_mode: NONE,
|
||||
exit_plan_mode: NONE,
|
||||
|
||||
// ── Draft writes ────────────────────────────────────────────────────────
|
||||
// Every one of these funnels through the per-user draft lifecycle, which is
|
||||
// the single place the backend refuses (drafts.rs `require_can_write_path`) —
|
||||
// including the resource/variable/schedule/trigger tools, whose deployed-object
|
||||
// endpoints an operator's token would otherwise allow.
|
||||
write_script: WRITE_DRAFT,
|
||||
write_flow: WRITE_DRAFT,
|
||||
edit_script: WRITE_DRAFT,
|
||||
patch_flow_json: WRITE_DRAFT,
|
||||
set_flow_module_code: WRITE_DRAFT,
|
||||
write_schedule: WRITE_DRAFT,
|
||||
write_trigger: WRITE_DRAFT,
|
||||
write_resource: WRITE_DRAFT,
|
||||
write_variable: WRITE_DRAFT,
|
||||
init_app: WRITE_DRAFT,
|
||||
write_app_file: WRITE_DRAFT,
|
||||
patch_app_file: WRITE_DRAFT,
|
||||
delete_app_file: WRITE_DRAFT,
|
||||
write_app_runnable: WRITE_DRAFT,
|
||||
delete_app_runnable: WRITE_DRAFT,
|
||||
// Both act on a draft, which can only exist for someone who could write one.
|
||||
discard_local_draft: WRITE_DRAFT,
|
||||
rebase_draft: WRITE_DRAFT,
|
||||
|
||||
// ── Deployed-object mutations ───────────────────────────────────────────
|
||||
deploy_workspace_item: DEPLOY,
|
||||
delete_workspace_item: DEPLOY,
|
||||
|
||||
// ── Preview execution ───────────────────────────────────────────────────
|
||||
test_run_script: RUN_PREVIEW,
|
||||
test_run_flow: RUN_PREVIEW,
|
||||
test_run_step: RUN_PREVIEW,
|
||||
|
||||
// ── Pipeline editor ─────────────────────────────────────────────────────
|
||||
get_pipeline_graph: NONE,
|
||||
read_pipeline_node: NONE,
|
||||
build_pipeline_node: WRITE_DRAFT,
|
||||
edit_pipeline_node: WRITE_DRAFT,
|
||||
remove_pipeline_node: WRITE_DRAFT,
|
||||
test_pipeline_node: RUN_PREVIEW
|
||||
}
|
||||
|
||||
export function sessionToolAllowed(name: string, access: SessionAccess): boolean {
|
||||
const policy = SESSION_TOOL_POLICIES[name]
|
||||
// An unregistered tool is withheld rather than advertised: the completeness
|
||||
// test is what keeps this branch unreachable, so reaching it means a tool
|
||||
// shipped without anyone deciding what it needs.
|
||||
if (!policy) return false
|
||||
if (policy.relevance === 'authoring' && !access.capabilities.has('write_draft')) {
|
||||
return false
|
||||
}
|
||||
return hasCapabilities(access, policy.requires)
|
||||
}
|
||||
|
||||
/** Filter an assembled toolset. `access` undefined means "not resolved yet, or not
|
||||
* a session" — the toolset passes through untouched. */
|
||||
export function filterSessionTools<T extends { def: { function: { name: string } } }>(
|
||||
tools: T[],
|
||||
access: SessionAccess | undefined
|
||||
): T[] {
|
||||
if (!access) return tools
|
||||
return tools.filter((t) => sessionToolAllowed(t.def.function.name, access))
|
||||
}
|
||||
Reference in New Issue
Block a user