From 0e49615a3d0de643d068514c672dbcad40e7ffa2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 9 Jul 2026 16:37:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(tool-bash):=20sandbox=20escalation=20?= =?UTF-8?q?=E2=80=94=20one=20approved=20wider=20retry=20after=20a=20denial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool gate advertises sandbox_permissions (an enum of exactly the modes STRICTLY WIDER than the mounted executor default — the schema makes a non-widening request inexpressible) plus a required justification, exactly when ctx.bash.sandboxMode reports a confining mode at registration: composition truth, never a dead lever. An escalating call resolves ctx.approval BEFORE anything executes with the audit-self-contained reason "escalate sandbox to : "; allowed-once stamps the granted mode onto that one bash request (the seam-level per-call override), while rejected / cancelled / unavailable and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The description teaches the flow only when the fields exist: retry the SAME command once after a real denial, never preemptively; a rejected escalation is final. No new session events: the attempt is an ordinary tool/call, the decision is the approval audit pair, the outcome an ordinary tool/result whose facts name the mode it ran under. --- docs/capability-seams.md | 3 +- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/package.json | 3 + packages/bash/tool-bash/src/index.ts | 237 ++++++++++++++--- packages/bash/tool-bash/tests/tools.spec.ts | 274 +++++++++++++++++++- pnpm-lock.yaml | 6 + scripts/gen-doc-graphs.ts | 2 +- 7 files changed, 493 insertions(+), 34 deletions(-) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 703226a968..7b3b7aca20 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -123,6 +123,7 @@ flowchart LR svc_agents --> pkg_invariants svc_agents --> pkg_stdio_agent svc_agents --> pkg_subagent_inprocess + svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex @@ -174,7 +175,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | -| `ctx.approval` | `seam` | [`approval`](../packages/approval/approval) | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | +| `ctx.approval` | `seam` | [`approval`](../packages/approval/approval) | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index b5f6680717..a45962e29c 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -52,5 +52,5 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md). -The escalation gate — one approved wider retry of a denied command through `ctx.approval` — is the sandbox RFC's staged follow-up ([§ Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)); this layer today renders the denial facts and forbids retrying around them. +On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../approval/approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the executor's default), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command. diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index b08ccd7b74..bd8f8c1aea 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", @@ -33,12 +34,14 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 9544858c9e..da6c5c3325 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -33,12 +33,16 @@ * Commands run with the executor's full authority unless a sandboxing * executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call * allow/deny/ask policy is the `tools/pre-execute` waterfall — see - * docs/architecture.md § Extension And Composition. A sandbox denial is a - * RESULT FACT this layer renders as its own marker (the command RAN and the - * kernel refused a file effect), and a sandbox RUNNER failure renders as a - * sandbox problem, never a command failure. The escalation surface and the - * per-session mode switching are staged follow-ups of the sandbox RFC - * (docs/rfc/proposed/feature/2026-07-06-sandbox.md). + * docs/architecture.md § Extension And Composition. Under a sandboxing + * executor this plugin also advertises the ESCALATION surface + * (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation, + * docs/rfc/proposed/feature/2026-07-06-sandbox.md): a command the + * sandbox denied may be retried once under a strictly wider mode, resolved + * through `ctx.approval` BEFORE anything executes and failing closed on every + * unanswerable path. The fields exist only when the mounted executor reports + * a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised + * that the composition cannot honor. Per-session mode switching is the + * sandbox RFC's staged follow-up. * * @module @deepseek-ai/dsh-tool-bash */ @@ -46,9 +50,15 @@ import type { Context } from 'cordis' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' +// Side-effect type import: declaration-merges `ctx.approval`, consumed +// opportunistically by the escalation gate (`ctx.get('approval')` — the seam +// stays optional at runtime, same pattern as dsh-tools' ask routing). +import type {} from '@deepseek-ai/dsh-approval' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' @@ -60,16 +70,12 @@ export const inject = ['tools', 'bash', 'systemPrompt'] * validates parsed args against the SchemaSpec before `execute` runs (the * arg-validation RFC), so type/required/enum checks are already done and `args` * is the validated `InferArgs` shape here. What remains are value constraints - * the DSL has no vocabulary for: non-empty strings and a positive, finite - * timeout. + * the DSL has no vocabulary for: non-empty strings, a positive finite timeout, + * and the escalation pairing (`sandbox_permissions` and `justification` travel + * together — an approval prompt without a reason, or a reason driving nothing, + * is a malformed ask). */ -function validateBashArgs(args: { - command: string - description: string - timeoutMs?: number - workdir?: string - run_in_background?: boolean -}): void { +function validateBashArgs(args: BashToolArgs): void { if (args.command.trim().length === 0) { throw new Error('invalid command: expected a non-empty string') } @@ -79,6 +85,15 @@ function validateBashArgs(args: { if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) } + if (args.sandbox_permissions !== undefined && args.justification === undefined) { + throw new Error('invalid escalation: sandbox_permissions requires a justification') + } + if (args.justification !== undefined && args.sandbox_permissions === undefined) { + throw new Error('invalid escalation: justification is only valid together with sandbox_permissions') + } + if (args.justification !== undefined && args.justification.trim().length === 0) { + throw new Error('invalid justification: expected a non-empty sentence') + } } /** @@ -93,6 +108,75 @@ function validateTaskId(value: string): BashTaskId { return BashTaskId(value) } +/** + * The bash tool's validated argument shape — the base parameters plus the two + * escalation fields, which are ADVERTISED only when the mounted executor + * reports a confining default mode (absent from the schema otherwise, so the + * SchemaSpec validator rejects them before `execute` ever sees one). + */ +interface BashToolArgs { + command: string + description: string + timeoutMs?: number + workdir?: string + run_in_background?: boolean + sandbox_permissions?: string + justification?: string +} + +/** + * The strictly-wider table: what a call whose effective mode is the key may + * escalate TO. Checked at EXECUTION, never baked into the schema — the + * schema's enum is {@link ESCALATION_TARGETS}, because schemas are + * registry-global while the effective mode is per-call truth. + */ +const WIDER_MODES: Record = { + 'read-only': ['workspace-write', 'danger-full-access'], + 'workspace-write': ['danger-full-access'], +} + +/** + * The closed escalation-target vocabulary — every mode a call could ever + * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised + * whenever the mounted executor confines: cutting the enum down to the modes + * wider than the executor's DEFAULT would strand a session whose effective + * mode sits below it (a `danger-full-access` default would advertise nothing + * while a narrower-switched session stays confined with no lever). + */ +const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access'] + +/** + * The bash tool's static description. The base text is byte-stable regardless + * of composition (it is part of the pinned snapshot header); the escalation + * teaching rides only when the mounted executor actually honors the fields — + * it names the ONE sanctioned exception to the base text's "do not retry + * another way" rule. Its deference clause ("If the session states approval + * prompts are disabled…") points at the approval plugin's never-policy prompt + * sentence by meaning, not by parsed wording — a rendezvous kept working by + * that sentence continuing to open with the approvals-disabled claim. + */ +function bashDescription(escalationModes: readonly SandboxMode[]): string { + const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. ' + + 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — ' + + 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. ' + + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). ' + + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + + 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; ' + + 'poll it with `bash_output` and stop it with `bash_kill`.' + if (escalationModes.length === 0) return base + return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the ' + + 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it ' + + 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry ' + + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) ' + + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the ' + + 'approval prompt raised by that retry IS how the user consents. If the session states approval ' + + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. ' + + 'Never escalate speculatively: ground the request in a real denial — normally the one THIS command ' + + 'just hit; escalating up front is fine only when this session already denied the same access. ' + + 'A rejected escalation is final for THAT command — stop and explain, never work around ' + + 'it — but it does not forbid attempting or escalating other commands later.' +} + /** Append the truncation notice (with the full-output spill path) to a stream's text. */ function streamText(output: CollectedOutput): string { if (!output.truncated) return output.text @@ -105,9 +189,15 @@ function streamText(output: CollectedOutput): string { * errored — the model decides how to react; only infrastructure failures * (spawn errors, aborts) surface as isError results. * @param result - the completed foreground run from the executor. + * @param escalationModes - the escalation targets this composition advertises; + * non-empty adds the same-turn escalation hint after a denial marker + * (default `[]`: no hint). * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. */ -export function renderResult(result: BashRunResult): string { +export function renderResult( + result: BashRunResult, + escalationModes: readonly SandboxMode[] = [], +): string { const out = streamText(result.stdout) const err = streamText(result.stderr) @@ -125,6 +215,13 @@ export function renderResult(result: BashRunResult): string { // reported fact like timeout: the model decides how to react. if (result.sandbox?.denied) { markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) + // The same-turn nudge lives at the decision point: only when this + // composition advertises the fields (a lever is never hinted that the + // schema does not offer), and inside the sandbox marker family so the + // exit-code marker stays the last line. + if (escalationModes.length > 0) { + markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + } } // Timeout is reported independently of how the process actually ended: a // command can trap SIGTERM and exit 0 after our timer fired (e.g. @@ -366,15 +463,73 @@ export function apply(ctx: Context): void { } }) + // The escalation surface exists exactly when the mounted executor confines + // under a default that has a strictly wider mode to escalate to — a lever + // is never advertised that the composition cannot honor. Registration time + // is the right read: the executor's default is config-fixed for its + // lifetime, and an executor swap restarts this fiber (static inject) and + // re-registers the schema. + const defaultMode = ctx.bash.sandboxMode + const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS + + /** + * Resolve a sandbox-escalation request through `ctx.approval` BEFORE + * anything executes. Returns the granted mode to stamp onto the bash + * request; throws the distinct fail-closed text for every other path (no + * service composed, an agent-less execution, a rejection, a cancellation, + * an unanswerable ask) — the registry turns the throw into this call's + * isError result, and nothing has run. The seam is consumed + * opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a + * deployment without it degrades per call, never at registration. + */ + const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise => { + // Schema validation only checks ADVERTISED keys, so an unadvertised + // `sandbox_permissions` (no sandboxing executor, or a `danger-full-access` + // default with nothing wider) still reaches execute — reject it here so a + // human is never prompted to "escalate" a sandbox that is not there. When + // the fields ARE advertised, the registry's SchemaSpec enum has already + // pinned `mode` to this ladder for every caller. + if (escalationModes.length === 0) { + throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + } + // Strict widening is an EXECUTION check against the call's effective + // mode, deliberately not a schema constraint (the enum is the closed + // target vocabulary; the effective mode is per-call truth). A + // non-widening request fails closed here and never prompts a human. + const effectiveMode = defaultMode as SandboxMode + if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) { + throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`) + } + const approval = ctx.get('approval') + if (approval === undefined) { + throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`) + } + if (exec.agent === undefined) { + throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`) + } + const outcome = await approval.request({ + agent: exec.agent, + toolName: 'bash', + callId: exec.callId, + // Self-contained for the audit trail: approval/asked stores this + // reason, and the target mode is part of the grant's identity. + reason: `escalate sandbox to ${mode}: ${justification}`, + ...exec.signal ? { signal: exec.signal } : {}, + }) + switch (outcome) { + // The SchemaSpec enum already pinned `mode` to this executor's wider + // ladder; the cast records that validated fact. + case 'allowed-once': return mode as SandboxMode + case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`) + case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) + case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`) + default: return assertNever(outcome, 'ApprovalOutcome') + } + } + ctx.tools.register(defineTool({ name: 'bash', - description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. ' - + 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — ' - + 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. ' - + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). ' - + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' - + 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; ' - + 'poll it with `bash_output` and stop it with `bash_kill`.', + description: bashDescription(escalationModes), parameters: { command: { type: 'string', required: true, description: 'The bash command to execute.' }, description: { @@ -387,12 +542,31 @@ export function apply(ctx: Context): void { timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' }, workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' }, + ...escalationModes.length > 0 ? { + sandbox_permissions: { + type: 'string' as const, + enum: [...escalationModes], + description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry ' + + 'of a command the sandbox just denied; requires justification and user approval.', + }, + justification: { + type: 'string' as const, + description: 'Required with sandbox_permissions: one sentence for the user explaining ' + + 'why this exact command needs the wider access.', + }, + } : {}, }, - async execute(args, exec) { + async execute(args: BashToolArgs, exec) { validateBashArgs(args) // `description` is display/logging metadata only (surfaced to UIs via // the tool/call session event); it is intentionally NOT forwarded to // ctx.bash and has no effect on execution. + // An escalating call resolves approval BEFORE anything executes; every + // non-grant outcome throws its distinct error text and runs nothing. + // (validateBashArgs pinned the pairing, so the double narrow is exact.) + const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined + ? await approveEscalation(args.sandbox_permissions, args.justification, exec) + : undefined // Default the workdir to the calling agent's session cwd so each ACP // session runs in its own workspace (see resolveWorkdir); an explicit // model workdir still wins. @@ -402,6 +576,7 @@ export function apply(ctx: Context): void { ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, ...exec.signal ? { signal: exec.signal } : {}, + ...sandboxMode !== undefined ? { sandboxMode } : {}, } if (args.run_in_background === true) { // Stamp the owner token (the agent's session id) onto the spec so the @@ -413,7 +588,7 @@ export function apply(ctx: Context): void { } const result = await ctx.bash.run(ctx.bash.resolve(request)) if (result.aborted) throw new Error('command aborted') - return [{ type: 'text', text: renderResult(result) }] + return [{ type: 'text', text: renderResult(result, escalationModes) }] }, presentCall: presentBashCall, presentResult: presentBashResult, @@ -446,10 +621,14 @@ export function apply(ctx: Context): void { // error; a settled task's read carries the marker instead. text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]` } else if (read.task.sandbox?.denied) { - // Mirrors the foreground result marker. Background denials are only - // classifiable once the task settles (the classifier needs the whole - // stderr), so the marker rides every read that sees the settled task. + // Mirrors the foreground result marker (and its same-turn escalation + // hint). Background denials are only classifiable once the task + // settles (the classifier needs the whole stderr), so the marker + // rides every read that sees the settled task. text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]` + if (escalationModes.length > 0) { + text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]' + } } return Promise.resolve([{ type: 'text', text }]) }, diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 2af987e941..9ae80f28fb 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -15,6 +15,8 @@ import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import ApprovalService from '@deepseek-ai/dsh-approval' +import type { ApprovalOutcome } from '@deepseek-ai/dsh-approval' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { renderResult } from '@deepseek-ai/dsh-tool-bash' @@ -999,6 +1001,17 @@ describe('sandbox rendering', () => { expect(text).toMatch(/\[sandbox: file access denied under read-only mode\]\n\[exit code: 1\]$/) }) + it('appends the same-turn escalation hint to a denial exactly when the fields are advertised', () => { + const hinted = renderResult(sandboxResult(true, 1), ['workspace-write', 'danger-full-access']) + expect(hinted).toMatch( + /denied under read-only mode\]\n\[sandbox: escalation available — retry this exact command once with sandbox_permissions [^\n]+\]\n\[exit code: 1\]$/, // eslint-disable-line @stylistic/max-len -- the hint sentence is pinned verbatim + ) + // Default (no advertisement): no hint — a lever the schema does not offer is never suggested. + expect(renderResult(sandboxResult(true, 1))).not.toContain('escalation available') + // A non-denied result never hints, advertised or not. + expect(renderResult(sandboxResult(false, 2), ['danger-full-access'])).not.toContain('escalation available') + }) + it('renders no sandbox marker for a plain failure under a sandboxed mode', () => { expect(renderResult(sandboxResult(false, 2))).not.toContain('[sandbox:') }) @@ -1017,7 +1030,58 @@ describe('sandbox rendering', () => { const id = text(started).match(/started background task (bash-\d+)/)![1] await bash.list().find(task => task.id === id)!.done const read = await call(ctx, 'bash_output', { task_id: id }) - expect(text(read)).toMatch(/\[status: completed, exit code: 1\]\n\[sandbox: file access denied under read-only mode\]$/) + expect(text(read)).toMatch( + /\[status: completed, exit code: 1\]\n\[sandbox: file access denied under read-only mode\]\n\[sandbox: escalation available[^\n]+\]$/, + ) + }) + + it('a settled background denial renders no escalation hint without a confining executor (defensive arm)', async () => { + // Structurally near-unreachable through the real stack — every confining + // default advertises the static target set — but the read path guards + // it anyway: an executor that reports no sandboxMode (fields never + // advertised) whose task nonetheless carries denial facts must render + // the marker without suggesting a lever the schema does not offer. + class FactsOnlyExecutor extends BashExecutor { + private readonly task: BashTask = { + id: BashTaskId('bash-facts'), + command: 'fake', + status: 'completed', + exitCode: 1, + signal: null, + done: Promise.resolve(), + sandbox: { mode: 'read-only', denied: true }, + } + + resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 0, + ...request.signal ? { signal: request.signal } : {}, + owner: request.owner, + sandboxMode: request.sandboxMode, + } + } + + run(): Promise { return Promise.reject(new Error('not used')) } + start(): BashTask { return this.task } + get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined } + list(): BashTask[] { return [this.task] } + kill(): boolean { return false } + ownerOf(): OwnerToken | undefined { return undefined } + readOutput(): BashTaskRead { + return { task: this.task, delta: '', lossy: false } + } + } + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(FactsOnlyExecutor) + await ctx.plugin(ToolBash) + const read = await call(ctx, 'bash_output', { task_id: 'bash-facts' }) + expect(text(read)).toMatch(/\[sandbox: file access denied under read-only mode\]$/) + expect(text(read)).not.toContain('escalation available') }) it('bash_output reports a settled background RUNNER failure as a sandbox problem, outranking the denial marker', async () => { @@ -1063,7 +1127,213 @@ describe('sandbox rendering', () => { chmodSync(lockedDir, 0o555) const result = await call(ctx, 'bash', { command: `echo x > ${lockedDir}/f`, description: 'Write into a locked directory' }) expect(result.isError).toBe(false) - expect(text(result)).toMatch(/\[sandbox: file access denied under read-only mode\]\n\[exit code: \d+\]$/) + expect(text(result)).toMatch( + /denied under read-only mode\]\n\[sandbox: escalation available[^\n]+\]\n\[exit code: \d+\]$/, + ) }) }) +describe('sandbox escalation (sandbox_permissions / justification)', () => { + /** Compose the real sandbox stack (passthrough runner) at a given default mode. */ + async function setupSandboxed(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', opts: { approval?: boolean } = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalSandboxProvider, { runnerCommand: PASSTHROUGH_RUNNER }) + await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...mode !== undefined ? { mode } : {} }) + const bash = ctx.bash as SandboxBashExecutor + bash.internals = { spillDir } + if (opts.approval === true) await ctx.plugin(ApprovalService) + await ctx.plugin(ToolBash) + return { ctx, bash } + } + + /** The registered bash tool's wire schema (what the model actually sees). */ + function bashSchema(ctx: Context) { + const schema = ctx.tools.schemas().find(s => s.name === 'bash') + if (!schema) throw new Error('bash tool not registered') + return schema as unknown as { description: string; parameters: { properties: Record } } + } + + /** + * A fake agent whose session records appends — the approval audit surface. + * Seeded mid-turn: an escalating call always runs inside one, and request() + * enforces the enclosure. + */ + function escalationAgent(events: Array<{ type: string; data: Record }>): Agent { + return { + id: 'agent-esc', + session: { + header: { version: 0, id: 'sess-esc', createdAt: 0 }, + events: [{ type: 'turn/start' }], + append: (type: string, data: Record) => { events.push({ type, data }) }, + }, + } as unknown as Agent + } + + let escCall = 0 + function callAs(ctx: Context, agent: Agent | undefined, args: unknown) { + return ctx.tools.execute({ callId: CallId(`call-esc-${++escCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} }) + } + + const ESCALATE = { command: 'true', description: 'test escalation', sandbox_permissions: 'workspace-write', justification: 'the test needs it' } + + it('advertises no escalation surface under a non-sandboxing executor', async () => { + const ctx = await setup() + expect(ctx.bash.sandboxMode).toBeUndefined() + const schema = bashSchema(ctx) + expect(schema.parameters.properties['sandbox_permissions']).toBeUndefined() + expect(schema.parameters.properties['justification']).toBeUndefined() + expect(schema.description).not.toContain('sanctioned exception') + }) + + it('advertises the full closed target vocabulary under any confining default', async () => { + // The enum is deliberately NOT default-relative: a session's effective + // mode is per-session and switchable, so every confining composition + // advertises every possible target — strict widening is checked at + // execution against the call's effective mode instead. + for (const mode of [undefined, 'workspace-write', 'danger-full-access'] as const) { + const { ctx } = await setupSandboxed(mode) + const schema = bashSchema(ctx) + expect(schema.parameters.properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) + expect(schema.parameters.properties['justification']).toBeDefined() + expect(schema.description).toContain('sanctioned exception') + } + }) + + it('a non-widening request fails at execution with its own text and prompts no one', async () => { + const { ctx } = await setupSandboxed('danger-full-access', { approval: true }) + const consulted = vi.fn() + ctx.on('approval/request', (_req, next) => { consulted(); return next() }) + const result = await callAs(ctx, escalationAgent([]), { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'already wider' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('not strictly wider than this call\'s current "danger-full-access" mode') + expect(consulted).not.toHaveBeenCalled() + }) + + it('rejects sandbox_permissions without a justification, and vice versa, and a blank justification', async () => { + const { ctx } = await setupSandboxed() + const missing = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write' }) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('sandbox_permissions requires a justification') + const orphan = await callAs(ctx, undefined, { command: 'true', description: 'd', justification: 'why not' }) + expect(orphan.isError).toBe(true) + expect(text(orphan)).toContain('only valid together with sandbox_permissions') + const blank = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' }) + expect(blank.isError).toBe(true) + expect(text(blank)).toContain('expected a non-empty sentence') + }) + + it('the schema enum rejects a mode outside the target vocabulary before execute (registry-level, any caller)', async () => { + const { ctx } = await setupSandboxed() + const result = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'read-only', justification: 'narrow' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('must be one of') + }) + + it('rejects an unadvertised sandbox_permissions injection under a non-sandboxing executor', async () => { + const ctx = await setup() + const result = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'sneaky' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('not available in this composition') + }) + + it('fails closed with its own text when no approval service is composed', async () => { + const { ctx } = await setupSandboxed() + const result = await callAs(ctx, escalationAgent([]), ESCALATE) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no approval service is composed') + }) + + it('fails closed with its own text for an agent-less escalating call', async () => { + const { ctx } = await setupSandboxed('read-only', { approval: true }) + const result = await callAs(ctx, undefined, ESCALATE) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no agent to route it through') + }) + + it('fails closed with its own text when the service has no answerer', async () => { + const { ctx } = await setupSandboxed('read-only', { approval: true }) + const result = await callAs(ctx, escalationAgent([]), ESCALATE) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no approval channel is available') + }) + + it('a grant runs THAT call under the wider mode — the denial marker names it — and lands the audit pair', async () => { + const { ctx } = await setupSandboxed('read-only', { approval: true }) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const events: Array<{ type: string; data: Record }> = [] + // A real unix denial under the passthrough runner: the marker's mode can + // only say workspace-write if the override actually rode the spec. + const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-esc-denied-')), 'locked') + mkdirSync(lockedDir) + chmodSync(lockedDir, 0o555) + const result = await callAs(ctx, escalationAgent(events), { + command: `echo x > ${lockedDir}/f`, + description: 'write into a locked directory', + sandbox_permissions: 'workspace-write', + justification: 'must write outside the workspace', + }) + expect(result.isError).toBe(false) + expect(text(result)).toMatch(/\[sandbox: file access denied under workspace-write mode\]/) + expect(events.map(e => e.type)).toEqual(['approval/asked', 'approval/decided']) + expect(events[0]?.data['toolName']).toBe('bash') + expect(events[0]?.data['reason']).toBe('escalate sandbox to workspace-write: must write outside the workspace') + expect(events[1]?.data['outcome']).toBe('allowed-once') + }) + + it('a granted background start settles with the wider mode\'s facts', async () => { + const { ctx, bash } = await setupSandboxed('read-only', { approval: true }) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const started = await callAs(ctx, escalationAgent([]), { ...ESCALATE, run_in_background: true }) + expect(started.isError).toBe(false) + const id = text(started).match(/started background task (bash-\d+)/)?.[1] + const task = bash.list().find(t => t.id === id) + if (!task) throw new Error('escalated task not tracked') + await task.done + expect(task.sandbox).toMatchObject({ mode: 'workspace-write', denied: false }) + }) + + it('a rejection denies with the user-said-no text and runs nothing', async () => { + const { ctx } = await setupSandboxed('read-only', { approval: true }) + ctx.on('approval/request', () => Promise.resolve('rejected')) + // A live (non-aborted) signal rides the execution: the gate threads it + // into the approval request so a turn cancellation can withdraw the ask. + const result = await ctx.tools.execute({ + callId: CallId(`call-esc-${++escCall}`), + name: 'bash', + arguments: ESCALATE, + agent: escalationAgent([]), + signal: new AbortController().signal, + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('the user rejected escalating this command to "workspace-write"') + }) + + it('a cancellation denies with the cancelled text', async () => { + const { ctx } = await setupSandboxed('read-only', { approval: true }) + ctx.on('approval/request', () => Promise.resolve('cancelled')) + const result = await callAs(ctx, escalationAgent([]), ESCALATE) + expect(result.isError).toBe(true) + expect(text(result)).toContain('approval for escalating to "workspace-write" was cancelled') + }) + + it('a rogue approval stand-in returning a non-vocabulary outcome hits the exhaustiveness backstop', async () => { + const { ctx } = await setupSandboxed() + ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as InstanceType) + const result = await callAs(ctx, escalationAgent([]), ESCALATE) + expect(result.isError).toBe(true) + expect(text(result)).toContain('unreachable') + }) + + it('a plain call under a sandboxing executor never consults approval', async () => { + const { ctx } = await setupSandboxed('read-only', { approval: true }) + const asked = vi.fn() + ctx.on('approval/request', (_req, next) => { asked(); return next() }) + const result = await callAs(ctx, escalationAgent([]), { command: 'echo plain', description: 'plain run' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('plain') + expect(asked).not.toHaveBeenCalled() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1afe78f76e..624ae3407d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-approval': + specifier: workspace:^ + version: link:../../approval/approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -175,6 +178,9 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ae52bdb307..4396df9fc0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -175,7 +175,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Approval seam', mode: 'seam', implementations: ['acp'], - consumers: ['tools'], + consumers: ['tools', 'tool-bash'], note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.', }, {