From 453d62a06b39fb8d7aec80adc90ae6da58c76fe2 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 29 Oct 2026 07:13:35 -0700 Subject: [PATCH] fix(ai-code-mode): reject tools that require approval 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 ever sees the outer `execute_typescript` call, so the per-tool interrupt is never 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, `as` casts, dynamically built tools): - `createCodeModeTool` / `createCodeMode` throw synchronously when 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. Addresses GHSA-wmh6-35qp-cc2x. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/code-mode-reject-needs-approval.md | 12 ++++ docs/code-mode/code-mode.md | 2 + docs/config.json | 2 +- .../src/bindings/tool-to-binding.ts | 16 +++++ .../ai-code-mode/src/create-code-mode-tool.ts | 20 +++++++ .../tests/create-code-mode-tool.test.ts | 59 +++++++++++++++++++ .../tests/create-code-mode.test.ts | 18 +++++- .../tests/tool-to-binding.test.ts | 41 ++++++++++++- 8 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 .changeset/code-mode-reject-needs-approval.md 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 db693b6279..e455db02ec 100644 --- a/docs/code-mode/code-mode.md +++ b/docs/code-mode/code-mode.md @@ -37,6 +37,8 @@ Tools you pass to Code Mode are converted to typed function stubs that appear in Generated code runs in an isolated environment. V8, QuickJS WASM, native QuickJS on Bun, and a Cloudflare Worker have no access to the host file system, network, or process. A Daytona sandbox and an E2B sandbox are full Linux environments. Each sandbox has its own file system, network, and processes. The code in that sandbox cannot reach the host. Timeouts and memory limits are configurable. +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. + ## Getting Started ### 1. Install packages diff --git a/docs/config.json b/docs/config.json index 25cd364bcd..ef03a4577f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -892,7 +892,7 @@ "label": "Code Mode", "to": "code-mode/code-mode", "addedAt": "2026-04-15", - "updatedAt": "2026-09-29" + "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 59b3b499e2..be79ef24fc 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 0d145b4efa..9db99f92eb 100644 --- a/packages/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/ai-code-mode/src/create-code-mode-tool.ts @@ -108,6 +108,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 20115fb8c8..57023f8aa2 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, IsolateDriver, IsolateContext, ExecutionResult, @@ -32,6 +33,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 +} + describe('createCodeModeTool', () => { it('throws on empty tools array', () => { const { driver } = createMockDriver() @@ -40,6 +56,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 9725dac77e..ee6a080f08 100644 --- a/packages/ai-code-mode/tests/tool-to-binding.test.ts +++ b/packages/ai-code-mode/tests/tool-to-binding.test.ts @@ -6,7 +6,22 @@ import { toolToBinding, toolsToBindings, } from '../src/bindings/tool-to-binding' -import type { ToolBinding, ToolExecutionContext } from '../src/types' +import type { + CodeModeTool, + ToolBinding, + ToolExecutionContext, +} from '../src/types' + +// 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 +} function createMockServerTool( name: TName, @@ -52,6 +67,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) @@ -121,6 +151,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', () => {