Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/code-mode-reject-needs-approval.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/code-mode/code-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/ai-code-mode/src/bindings/tool-to-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
20 changes: 20 additions & 0 deletions packages/ai-code-mode/src/create-code-mode-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_')

Expand Down
59 changes: 59 additions & 0 deletions packages/ai-code-mode/tests/create-code-mode-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,6 +35,21 @@ function createMockTool<TName extends string>(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<string, unknown> = {},
): 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.
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 17 additions & 1 deletion packages/ai-code-mode/tests/create-code-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
36 changes: 36 additions & 0 deletions packages/ai-code-mode/tests/tool-to-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../src/bindings/tool-to-binding'
import type {
CodeModeCallHook,
CodeModeTool,
ToolBinding,
ToolExecutionContext,
} from '../src/types'
Expand All @@ -27,6 +28,17 @@ function createMockServerTool<TName extends string>(
}))
}

// 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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading