diff --git a/.changeset/code-mode-reject-needs-approval.md b/.changeset/code-mode-reject-needs-approval.md new file mode 100644 index 0000000000..d8cb78c167 --- /dev/null +++ b/.changeset/code-mode-reject-needs-approval.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai-code-mode': patch +--- + +Reject approval-gated tools in Code Mode. A tool marked `needsApproval: true` expects a human approval interrupt before it runs, but Code Mode invokes tools from inside the sandbox — the model only ever sees the outer `execute_typescript` call, so no per-tool interrupt is raised and the tool would run unattended. + +The `CodeModeTool` type already pins `needsApproval` to `false`, so typed callers were blocked at compile time. This adds the matching runtime guard for callers that bypass the types (JavaScript consumers, `as` casts, or dynamically built tools): + +- `createCodeModeTool` / `createCodeMode` now throw synchronously if any supplied tool has `needsApproval: true`, naming every offending tool. +- `toolToBinding` / `toolsToBindings` enforce the same rule as a lower-level backstop, so every binding path is covered. + +Approving only the outer `execute_typescript` call would not preserve per-tool/per-argument approval semantics, so these tools are rejected rather than silently approved. Remove approval-gated tools from the Code Mode config, or drop `needsApproval` if they are safe to call unattended. diff --git a/docs/code-mode/code-mode.md b/docs/code-mode/code-mode.md index bb5cc168ce..44427cd081 100644 --- a/docs/code-mode/code-mode.md +++ b/docs/code-mode/code-mode.md @@ -125,6 +125,10 @@ The three weather calls run in parallel inside the sandbox. The model gets one r The Node, QuickJS, QuickJS Bun, and Cloudflare drivers have no access to the host file system, network, or process. The Daytona and E2B drivers run the code in a full remote Linux sandbox, with its own file system, network, and processes. +## Tool approval + +Code Mode rejects tools that need approval. A tool marked `needsApproval: true` expects a human to approve each call before it runs. Code Mode runs tools inside the sandbox from one `execute_typescript` call, so it cannot raise that per-tool approval. `createCodeMode` and `createCodeModeTool` throw when you pass such a tool. An approval tool belongs outside the Code Mode config. `chat()` runs it directly, where the approval interrupt works. + ## Add more - **Show the run to your users:** [Showing Code Mode in the UI](./client-integration). diff --git a/docs/config.json b/docs/config.json index 6ba922828b..5ebdf1256b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1007,7 +1007,7 @@ "label": "Code Mode", "to": "code-mode/code-mode", "addedAt": "2026-04-15", - "updatedAt": "2026-10-22" + "updatedAt": "2026-10-29" }, { "label": "Showing Code Mode in the UI", diff --git a/packages/ai-code-mode/src/bindings/tool-to-binding.ts b/packages/ai-code-mode/src/bindings/tool-to-binding.ts index e7f9ff295d..1e00711d1f 100644 --- a/packages/ai-code-mode/src/bindings/tool-to-binding.ts +++ b/packages/ai-code-mode/src/bindings/tool-to-binding.ts @@ -37,6 +37,22 @@ export function toolToBinding( tool: CodeModeTool, prefix: string = '', ): ToolBinding { + // Reject approval-gated tools. A tool marked `needsApproval: true` expects a + // human approval interrupt before it runs, but Code Mode invokes bindings + // from inside the sandbox — the model only ever sees the outer + // execute_typescript call, so no per-tool interrupt is raised and the tool + // would run unattended. This is the authoritative gate: every binding path + // (toolsToBindings, direct callers, snippet helpers) funnels through here. + if (tool.needsApproval) { + throw new Error( + `Tool "${tool.name}" requires approval (needsApproval: true) and cannot ` + + `be used in Code Mode. Code Mode executes tools inside a sandbox and ` + + `cannot pause for the approval interrupt, so the tool would run without ` + + `approval. Remove it from the Code Mode config, or drop needsApproval ` + + `if the tool is safe to call unattended.`, + ) + } + // Convert schemas (Zod or Standard Schema) to JSON Schema const inputSchema = convertSchemaToJsonSchema(tool.inputSchema) || { type: 'object', diff --git a/packages/ai-code-mode/src/create-code-mode-tool.ts b/packages/ai-code-mode/src/create-code-mode-tool.ts index a991c75a88..f709a69433 100644 --- a/packages/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/ai-code-mode/src/create-code-mode-tool.ts @@ -109,6 +109,26 @@ export function createCodeModeTool( throw new Error('At least one tool must be provided to createCodeModeTool') } + // Reject approval-gated tools up front, before any binding work. A tool + // marked `needsApproval: true` expects a human approval interrupt before it + // runs. Code Mode invokes tools from inside the sandbox, where the model only + // sees the outer execute_typescript call — the per-tool interrupt is never + // raised, so the tool would run unattended. Approving only execute_typescript + // would not preserve per-tool/per-argument approval semantics. `toolToBinding` + // enforces the same rule as a lower-level backstop. + const approvalTools = tools.filter((t) => t.needsApproval) + if (approvalTools.length > 0) { + const names = approvalTools.map((t) => t.name).join(', ') + throw new Error( + `Code Mode cannot expose tools that require approval: ${names}. ` + + `A tool marked needsApproval: true expects a human approval interrupt ` + + `before it runs, but Code Mode invokes tools from inside the sandbox ` + + `where that interrupt cannot be raised. Remove these tools from the ` + + `Code Mode config, or drop needsApproval if they are safe to call ` + + `unattended.`, + ) + } + // Transform tools to bindings with external_ prefix (static bindings) const staticBindings = toolsToBindings(tools, 'external_') diff --git a/packages/ai-code-mode/tests/create-code-mode-tool.test.ts b/packages/ai-code-mode/tests/create-code-mode-tool.test.ts index 388c5e58e9..e482327eaf 100644 --- a/packages/ai-code-mode/tests/create-code-mode-tool.test.ts +++ b/packages/ai-code-mode/tests/create-code-mode-tool.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { toolDefinition } from '@tanstack/ai' import { createCodeModeTool } from '../src/create-code-mode-tool' import type { + CodeModeTool, CodeModeToolConfig, IsolateDriver, IsolateContext, @@ -34,6 +35,21 @@ function createMockTool(name: TName) { return def.server(async (input) => ({ result: input.query })) } +// A `needsApproval: true` tool is rejected by the `CodeModeTool` *type* +// (`needsApproval` is pinned to `false`). This models a runtime bypass — a JS +// consumer, an `as` cast, or a dynamically built tool — which the runtime guard +// must still reject. `extra` lets a test also flip e.g. `lazy`. +function createApprovalTool( + name: string, + extra: Record = {}, +): CodeModeTool { + return { + ...createMockTool(name), + needsApproval: true, + ...extra, + } as unknown as CodeModeTool +} + /** * A driver whose program calls the host bindings, as sandbox code calls * `external_*` functions. A program that throws ends as a failure. @@ -68,6 +84,49 @@ describe('createCodeModeTool', () => { ) }) + it('throws when a tool requires approval', () => { + const { driver } = createMockDriver() + + expect(() => + createCodeModeTool({ + driver, + tools: [createMockTool('safe'), createApprovalTool('runCommand')], + }), + ).toThrow(/Code Mode cannot expose tools that require approval: runCommand/) + }) + + it('lists every approval-gated tool in the error', () => { + const { driver } = createMockDriver() + const tools = [ + createMockTool('safe'), + createApprovalTool('runCommand'), + createApprovalTool('deleteFiles'), + ] + + expect(() => createCodeModeTool({ driver, tools })).toThrow( + /runCommand, deleteFiles/, + ) + }) + + it('rejects an approval-gated tool even when it is marked lazy', () => { + const { driver } = createMockDriver() + const gated = createApprovalTool('runCommand', { lazy: true }) + + expect(() => + createCodeModeTool({ driver, tools: [createMockTool('safe'), gated] }), + ).toThrow(/runCommand/) + }) + + it('allows tools with needsApproval explicitly false', () => { + const { driver } = createMockDriver() + const tool = { + ...createMockTool('fetchWeather'), + needsApproval: false as const, + } + + expect(() => createCodeModeTool({ driver, tools: [tool] })).not.toThrow() + }) + it('returns a tool named execute_typescript', () => { const { driver } = createMockDriver() const tool = createCodeModeTool({ diff --git a/packages/ai-code-mode/tests/create-code-mode.test.ts b/packages/ai-code-mode/tests/create-code-mode.test.ts index 1c8d096274..2bd7c4eb87 100644 --- a/packages/ai-code-mode/tests/create-code-mode.test.ts +++ b/packages/ai-code-mode/tests/create-code-mode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { toolDefinition } from '@tanstack/ai' import { createCodeMode } from '../src/create-code-mode' -import type { IsolateDriver, IsolateContext } from '../src/types' +import type { CodeModeTool, IsolateDriver, IsolateContext } from '../src/types' function createMockDriver(): IsolateDriver { const mockContext: IsolateContext = { @@ -63,6 +63,22 @@ describe('createCodeMode', () => { }), ).toThrow('At least one tool must be provided') }) + + it('throws when a tool requires approval', () => { + // Models a runtime bypass: the CodeModeTool type pins needsApproval to + // false, so a true value only reaches the guard via JS / a cast. + const gated = { + ...createMockTool('runCommand'), + needsApproval: true, + } as unknown as CodeModeTool + + expect(() => + createCodeMode({ + driver: createMockDriver(), + tools: [createMockTool('search'), gated], + }), + ).toThrow(/Code Mode cannot expose tools that require approval: runCommand/) + }) }) const driverStub = { diff --git a/packages/ai-code-mode/tests/tool-to-binding.test.ts b/packages/ai-code-mode/tests/tool-to-binding.test.ts index d623a3d41d..cdaa7dd11f 100644 --- a/packages/ai-code-mode/tests/tool-to-binding.test.ts +++ b/packages/ai-code-mode/tests/tool-to-binding.test.ts @@ -8,6 +8,7 @@ import { } from '../src/bindings/tool-to-binding' import type { CodeModeCallHook, + CodeModeTool, ToolBinding, ToolExecutionContext, } from '../src/types' @@ -27,6 +28,17 @@ function createMockServerTool( })) } +// A `needsApproval: true` tool is rejected by the `CodeModeTool` *type* +// (`needsApproval` is pinned to `false`). These helpers model a runtime bypass +// — a JS consumer, an `as` cast, or a dynamically built `AnyServerTool` — which +// is exactly what the runtime guard has to catch. +function createApprovalTool(name: string): CodeModeTool { + return { + ...createMockServerTool(name), + needsApproval: true, + } as unknown as CodeModeTool +} + describe('toolToBinding', () => { it('converts ServerTool to ToolBinding with correct fields', () => { const tool = createMockServerTool('fetchData') @@ -56,6 +68,21 @@ describe('toolToBinding', () => { expect(() => toolToBinding(def)).toThrow('server tools') }) + it('throws for a tool that requires approval', () => { + expect(() => toolToBinding(createApprovalTool('runCommand'))).toThrow( + /Tool "runCommand" requires approval .* cannot[\s\S]*be used in Code Mode/, + ) + }) + + it('allows a tool with needsApproval explicitly false', () => { + const tool = { + ...createMockServerTool('runCommand'), + needsApproval: false as const, + } + + expect(() => toolToBinding(tool)).not.toThrow() + }) + it('execute function calls through to the tool', async () => { const tool = createMockServerTool('fetchData') const binding = toolToBinding(tool) @@ -125,6 +152,15 @@ describe('toolsToBindings', () => { const bindings = toolsToBindings([]) expect(bindings).toEqual({}) }) + + it('throws if any tool requires approval', () => { + const safe = createMockServerTool('fetchWeather') + const gated = createApprovalTool('runCommand') + + expect(() => toolsToBindings([safe, gated], 'external_')).toThrow( + /Tool "runCommand" requires approval/, + ) + }) }) describe('createEventAwareBindings', () => {