fix(mode): run_code passes both layers as a transport — Code Mode composes with plan mode

Review finding, valid: under the registry's Code Mode the assembly's
only wire tool is run_code, which the plan allowlist filtered out —
leaving the model with NO tools at all, the exit review included. The
composition exists today (the acp-agent example ships a code-mode
overlay), so plan mode bricked it outright.

run_code is a transport, not a capability: every bridged sub-call is
serialized back through ToolRegistry.execute() carrying the same agent,
so tools/pre-execute judges each capability individually — exactly like
native calls. Both layers now exempt it by name: the filter keeps it
visible (tests pin plan-mode Code Mode assembly = ['run_code']) and the
gate passes the wrapper while the same run's write sub-call still
denies with the plan-mode reason.

Documented residual, same class as the prepend-after-load one: the SDK
section renders from the registry's store, so a plan-mode program may
be offered bindings whose dispatch the gate then denies — nothing runs
that a native call could not.
This commit is contained in:
kingwl
2026-07-10 20:26:59 +08:00
parent b29aeb1847
commit 976deda91b
6 changed files with 64 additions and 5 deletions

View File

@@ -12,7 +12,7 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan`.
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent.
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent. `run_code` passes both layers as a TRANSPORT: under the registry's Code Mode it is the only wire tool, and every bridged sub-call re-enters this gate with the same agent, so the allowlist governs each capability individually.
## `ctx.modes`

View File

@@ -32,6 +32,7 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -26,7 +26,7 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineTool, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
@@ -267,8 +267,14 @@ export class ModesService extends Service {
return result
}
const allowed = new Set(active.definition.tools)
// run_code is a TRANSPORT, not a capability: under the registry's Code
// Mode it is the only wire tool (filtering it would leave the model
// with nothing, not even the exit), and every bridged sub-call
// re-enters tools/pre-execute with the same agent, where the allowlist
// governs each capability individually.
result.tools = result.tools.filter(tool =>
allowed.has(tool.name) && (tool.name !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
(allowed.has(tool.name) || tool.name === RUN_CODE_NAME)
&& (tool.name !== EXIT_PLAN_MODE || active.name === PLAN_MODE))
return result
}, { prepend: true })
@@ -276,6 +282,11 @@ export class ModesService extends Service {
if (exec.agent === undefined) return next()
const active = this.activeDefinition(exec.agent.session)
if (active === undefined) return next()
// Transport pass-through: a run_code program's every tool call is
// serialized back through ToolRegistry.execute() with the same agent,
// so each sub-call is judged here individually — gating the wrapper
// would only remove the vehicle, not widen or narrow any capability.
if (exec.name === RUN_CODE_NAME) return next()
if (active.definition.tools.includes(exec.name)) return next()
const reason = active.name === PLAN_MODE
? `tool "${exec.name}" is not available in plan mode; continue planning and present your plan with ${EXIT_PLAN_MODE} when ready`

View File

@@ -7,6 +7,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
@@ -349,6 +350,28 @@ describe('the soft layer', () => {
expect(assembly.tools.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
})
it('keeps run_code visible in plan mode under the registry Code Mode (transport, not capability)', async () => {
// Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
// assembly time (the code-mode.spec fake's shape).
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(ModesService)
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
// Code Mode's only wire tool survives the filter — without it the model
// would have NO tools at all, not even a path to the exit review.
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
})
it('treats a dropped folded definition as the default mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
@@ -395,6 +418,25 @@ describe('the hard layer', () => {
expect(denied.content).toEqual([{ type: 'text', text: 'Error: tool "write" is not available in "review" mode' }])
})
it('passes run_code through the gate; bridged sub-calls are judged individually', async () => {
const ctx = await setup()
// Native mode here, so a stand-in run_code can register without clashing
// with the registry's own (Code Mode) instance; the gate exempts by name.
registerNamedTools(ctx, ['run_code', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const wrapper = await execute(ctx, 'run_code', agent)
expect(wrapper.isError).toBe(false)
// A sub-dispatch re-enters execute() with the same agent — the capability
// is what the allowlist judges, exactly like a native call.
const sub = await execute(ctx, 'write', agent)
expect(sub.isError).toBe(true)
expect(sub.content).toEqual([{
type: 'text',
text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
}])
})
it('judges by the logged mode only — a pending intent does not gate', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])