From 96ba98c99e034a407b79e428e5cd663053d3a0a5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 4 Aug 2026 14:47:46 +0800 Subject: [PATCH] fix(sandbox): require runner-specific spawn evidence --- .../partial-landlock.cordis.snapshot.yml | 4 +- .../acp-agent/partial-landlock.cordis.yml | 4 +- examples/acp-agent/tests/acp.snapshot.ts | 14 +++++ .../fixtures/partial-landlock-sandbox.ts | 17 ++++-- .../missing-sandbox-runner/input.json | 10 ++++ .../missing-sandbox-runner/session.jsonl | 47 ++++++++++++++++ .../stdout.expected.jsonl | 4 ++ packages/bash/bash-sandbox/README.i18n.yaml | 4 +- packages/bash/bash-sandbox/README.md | 4 +- packages/bash/bash-sandbox/README.zh.md | 4 +- packages/bash/bash-sandbox/src/helpers.ts | 54 +++++++++++++++++-- packages/bash/bash-sandbox/src/index.ts | 31 ++++++++--- .../bash/bash-sandbox/tests/sandbox.spec.ts | 51 ++++++++++++++---- packages/bash/tool-bash/src/background.ts | 8 +-- 14 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json create mode 100644 examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml index 685f47d814..af834b885d 100644 --- a/examples/acp-agent/partial-landlock.cordis.snapshot.yml +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless partial-Landlock composition: replay the authored model turns and -# replace the shipping provider with a deterministic older-ABI stand-in. +# Keyless runner-classification composition: replay authored model turns and +# replace the shipping provider with a deterministic process-launch stand-in. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml index 41725299a8..7a958bb6de 100644 --- a/examples/acp-agent/partial-landlock.cordis.yml +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -1,5 +1,5 @@ -# Live counterpart for the partial-Landlock snapshot overlay. It replaces only -# the sandbox provider; the authored scenario is skipped in record mode. +# Live counterpart for the runner-classification snapshot overlay. It replaces +# only the sandbox provider; authored scenarios are skipped in record mode. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 94ab32e955..f6d57560a1 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -170,6 +170,20 @@ const SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'read-only' }, posixOnly: true, }, + // A valid cwd plus a missing provider executable exercises the assembled + // foreground error and background task marker without a platform runner. + { + name: 'missing-sandbox-runner', + hasModelTurn: true, + recorded: false, + headerClass: 'sandbox', + configPath: PARTIAL_LANDLOCK_CONFIG, + env: { + DSH_PERMISSION_MODE: 'read-only', + DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER: '1', + }, + posixOnly: true, + }, { name: 'todo-write', hasModelTurn: true, recorded: true }, { name: 'skill-load', diff --git a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts index 6d5080e9de..6ac6b27a19 100644 --- a/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts +++ b/examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts @@ -1,14 +1,25 @@ +import { join } from 'node:path' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)' +const MISSING_RUNNER_ENV = 'DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER' /** - * Snapshot-only provider that reproduces an older-ABI Landlock launch. Keep - * its failure tuple aligned with `sandbox-local`'s Landlock runner rule. + * Snapshot-only provider for deterministic runner classification. Its default + * launch reproduces older-ABI Landlock; an explicit scenario flag selects a + * missing executable under the valid workspace cwd. */ export default class PartialLandlockSandboxProvider extends SandboxProvider { - confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + if (process.env[MISSING_RUNNER_ENV] === '1') { + return { + argv: [join(policy.workspaceRoot, '.dsh-missing-sandbox-runner'), ...argv], + enforcement: 'full', + denialSignatures: ['permission denied'], + runnerFailureRules: [{ fatalSignatures: ['snapshot-runner: '] }], + } + } return { argv: [ 'bash', diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json new file mode 100644 index 0000000000..f75309e4e1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/input.json @@ -0,0 +1,10 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "prompt", + "text": "Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl new file mode 100644 index 0000000000..8866cc3da8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl @@ -0,0 +1,47 @@ +{"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":1785304900000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785825343526,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785825343527,"data":{"content":[{"type":"text","text":"Run true once with bash in the foreground. After that fails, run true with bash in the background, read task bash-1 with task_output and wait=true, then reply with exactly RUNNER_FAILURES_SURFACED and stop."}],"source":{"kind":"user"},"role":"user","id":"698c50b4-8e89-490a-910a-319466b322e8"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785825343527,"data":{"title":"Run true once with bash","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":1785825343547,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"0027efa5-7cd8-4c0c-b6d0-b483e99dd35a"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785825343547,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785825343548,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785825343548,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1785304900007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1785304900008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-foreground","name":"bash","argumentsDelta":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}} +{"type":"assistant/chunk","seq":9,"time":1785304900009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785304900010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":11,"time":1785304900011,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785825343557,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9e30fc63-1414-487c-aab9-ad114818118c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785825343557,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}} +{"type":"tool/result","seq":14,"time":1785825343574,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"48c382bd-1b30-4af0-b3e4-2c7bb811ba71"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785825343574,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785825343581,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1785304900017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-background","name":"bash","argumentsDelta":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":19,"time":1785304900019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":20,"time":1785304900020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":21,"time":1785825343587,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1785825343587,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0888df6a-237b-4b8b-9a4a-bdd8b42e1b3a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785825343587,"data":{"turn":1,"step":2,"callId":"missing-runner-background","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner in background\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":24,"time":1785825343595,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"missing-runner-background"},"content":[{"type":"tool-result","toolCallId":"missing-runner-background","content":[{"type":"text","text":"started background task bash-1"}],"isError":false}],"role":"user","id":"59acf44c-7ad3-4778-a12f-997c76d657e3"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785825343595,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":26,"time":1785825343603,"data":{"content":[{"type":"text","text":"background task bash-1 (bash: true) finished [status: killed, killed before exit]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"4ca0c254-b6de-4c5a-8484-430ed6a69761"},"surfaceOp":"append"} +{"type":"step/start","seq":27,"time":1785825343603,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":28,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":29,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-output","name":"task_output","argumentsDelta":"{\"task_id\":\"bash-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":30,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":31,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":32,"time":1785825343607,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":33,"time":1785825343608,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"19d8d568-a056-45e0-a3b4-21e26e7cbc26"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"tool/call","seq":34,"time":1785825343608,"data":{"turn":1,"step":3,"callId":"missing-runner-output","name":"task_output","arguments":"{\"task_id\":\"bash-1\",\"wait\":true}"}} +{"type":"tool/result","seq":35,"time":1785825343615,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"missing-runner-output"},"content":[{"type":"tool-result","toolCallId":"missing-runner-output","content":[{"type":"text","text":"[stderr]\nspawn failed: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]\n[status: killed, killed before exit]"}],"isError":false}],"role":"user","id":"3a637de6-c9f5-4474-9bbc-ee8ec354e27b"}},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1785825343615,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":37,"time":1785825343622,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":38,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":39,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"RUNNER_FAILURES_SURFACED"}}} +{"type":"assistant/chunk","seq":40,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"type":"assistant/chunk","seq":41,"time":1785825343627,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":42,"time":1785825343628,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":43,"time":1785825343628,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59317a54-459a-4383-bcd3-eea76ab8de2a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"step/end","seq":44,"time":1785825343628,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":45,"time":1785825343628,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl new file mode 100644 index 0000000000..c7df2372dc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index df83d16576..f8d579055f 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md -README.md: c1a6d6ae19f6d5ec95087d32c7d5d18d5f28b7f5 -README.zh.md: a6bcc812e35c842c7d3880a41814a872f3dbca14 +README.md: d1f2554776d2e9b3a2a5fc1742c12ec69a5d4a97 +README.zh.md: 3aab06145c00e9df4bd06628129cf043769ffb49 diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index c1a6d6ae19..d1f2554776 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). -- **Runner failures are sandbox failures, never command failures.** A rejected spawn of the provider argv is out-of-band proof that the confined launch never started: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path. +- **Runner failures are sandbox failures, never command failures.** Before a process starts, a spawn rejection is attributed to the runner only when its structured code and an independent lookup both show that the provider's executable is missing or unusable: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. Other spawn rejections retain the local executor's command-start failure semantics. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path. - **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A provider-argv spawn rejection supplies the original spawn error as detail; a settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. +If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A provider-executable spawn failure supplies the original spawn error as detail; a rejection without runner-specific evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. #### Token effect diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index a6bcc812e3..3aab06145c 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -17,7 +17,7 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** spawn 提供方 argv 遭拒,是受限启动从未开始的带外证据:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当 spawn 拒绝的结构化错误码与一次独立查找均表明提供方可执行文件缺失或不可用时,才会将该拒绝归因于 runner:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。其他 spawn 拒绝保留本地执行器的命令启动失败语义。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。 - **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 @@ -72,7 +72,7 @@ #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。spawn 提供方 argv 遭拒时,以原始 spawn 错误作为详细信息;已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。提供方可执行文件的 spawn 失败会以原始 spawn 错误作为详细信息;没有 runner 特定证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。 #### Token 影响 diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts index fc74dab8e3..a4eee9cbff 100644 --- a/packages/bash/bash-sandbox/src/helpers.ts +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -4,9 +4,56 @@ * @module @deepseek-ai/dsh-bash-sandbox/helpers */ +import { accessSync, constants, statSync } from 'node:fs' +import { delimiter, resolve } from 'node:path' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' +/** Spawn codes that can describe an unavailable executable. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM']) + +/** Whether one resolved path is a regular executable file. */ +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * Require positive runner evidence instead of treating every spawn rejection + * as sandbox-owned. Node uses the same ENOENT/EACCES channel for unrelated + * launch failures, so the provider executable must also be absent or unusable. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the spawn cwd, used to resolve relative executable paths. + * @param searchPath - the spawn environment's PATH value. + * @returns whether the rejection has executable-specific runner evidence. + */ +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, + searchPath: string | undefined, +): boolean { + if (typeof error !== 'object' || error === null) return false + const code = (error as { code?: unknown }).code + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code) || runnerProgram === undefined) return false + + const isPath = runnerProgram.includes('/') || runnerProgram.includes('\\') + const pathEntries = isPath ? [''] : searchPath?.split(delimiter) ?? [] + if (pathEntries.length === 0) return false + return pathEntries.every((entry) => { + const candidate = isPath + ? resolve(workdir, runnerProgram) + : resolve(workdir, entry.length > 0 ? entry : '.', runnerProgram) + return !isExecutableFile(candidate) + }) +} + /** Fatal runner evidence retained for infrastructure-error detail. */ interface RunnerFailureMatch { /** The original stderr line that matched a fatal signature. */ @@ -43,11 +90,10 @@ export function classifyRunnerFailure( for (const rule of rules) { if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) - // An empty substring matches every string in JavaScript. Ignore it so a - // malformed public rule cannot turn a gated exit status into evidence by - // itself; keep any valid signatures beside it active. + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. const fatalSignatures = rule.fatalSignatures - .filter(signature => signature.length > 0) + .filter(signature => signature.trim().length > 0) .map(signature => signature.toLowerCase()) for (const line of lines) { const lowered = line.toLowerCase() diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 8de2369a26..4fb9c1b1ed 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -1,10 +1,10 @@ /** * Sandbox-consuming bash executor. It wraps the exact local bash argv through * `ctx.sandbox`, inherits local process mechanics, and reports the selected - * mode, enforcement, and denial facts. Runner failure means the command never - * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background - * processes carry `runnerFailed`. The tool owns approval and passes a complete - * per-call policy. + * mode, enforcement, and denial facts. Positive runner-launch evidence means + * the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while + * background processes carry `runnerFailed`; other spawn rejections retain + * local-executor semantics. The tool owns approval and passes a complete per-call policy. * @module @deepseek-ai/dsh-bash-sandbox */ @@ -23,7 +23,7 @@ import type { import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' -import { classifyDenial, classifyRunnerFailure, matchesSignature } from './helpers.ts' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — @@ -60,6 +60,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { enforcement: SandboxEnforcement denialSignatures: readonly string[] runnerFailureRules: readonly RunnerFailureRule[] + runnerProgram: string | undefined + searchPath: string | undefined + workdir: string }>() constructor(ctx: Context, config: Config) { @@ -97,7 +100,10 @@ export class SandboxBashExecutor extends LocalBashExecutor { } catch (error) { // An upstream abort remains cancellation even when it prevents spawn. if (spec.signal?.aborted === true) spec.signal.throwIfAborted() - throw new SandboxUnavailableError(mode, String(error)) + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir, spec.env?.PATH ?? process.env.PATH)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error } // Runner failure outranks denial because the command did not run. Carry // the matched fatal line, not an informational line that preceded it. @@ -116,7 +122,15 @@ export class SandboxBashExecutor extends LocalBashExecutor { const confined = this.confine(spec.command, { ...policy, mode }) const proc = this.startArgv(spec, confined.argv) const { enforcement, denialSignatures, runnerFailureRules } = confined - this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules }) + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + searchPath: spec.env?.PATH ?? process.env.PATH, + workdir: spec.workdir, + }) return proc } @@ -131,7 +145,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { // A rejected spawn never started the confined launch. Otherwise runner // failure outranks denial because its diagnostics may contain denial terms. const runnerFailed = spawnFailed - || classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir, facts.searchPath) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 6328d96db3..a113322ddd 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -17,7 +17,7 @@ import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' -import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) @@ -172,6 +172,22 @@ describe('fail closed', () => { controller.abort(reason) await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason) }) + + it.each(['read-only', 'danger-full-access'] as const)( + 'keeps an invalid workdir as an ordinary foreground spawn failure in %s mode', + async (mode) => { + const { bash } = await setup({ mode }) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + try { + const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + expect(failure).not.toBeInstanceOf(SandboxUnavailableError) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }, + ) }) describe('danger-full-access', () => { @@ -273,10 +289,26 @@ describe('classifyDenial', () => { }) }) +describe('isRunnerSpawnFailure', () => { + it('requires both an executable-class error and an independently unavailable provider executable', () => { + const missingRunner = join(spillDir, 'definitely-missing-runner') + const enoent = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) + const emfile = Object.assign(new Error('spawn failed'), { code: 'EMFILE' }) + + expect(isRunnerSpawnFailure(enoent, missingRunner, process.cwd(), process.env.PATH)).toBe(true) + expect(isRunnerSpawnFailure(enoent, process.execPath, process.cwd(), process.env.PATH)).toBe(false) + expect(isRunnerSpawnFailure(enoent, spillDir, process.cwd(), process.env.PATH)).toBe(true) + expect(isRunnerSpawnFailure(emfile, missingRunner, process.cwd(), process.env.PATH)).toBe(false) + expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, '')).toBe(true) + expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, undefined)).toBe(false) + expect(isRunnerSpawnFailure(enoent, undefined, spillDir, process.env.PATH)).toBe(false) + }) +}) + describe('classifyRunnerFailure', () => { - it('ignores empty fatal signatures instead of treating exit status or notice text as evidence', () => { + it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => { const notice = 'landlock-run: partial enforcement (older Landlock ABI)' - const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: [''] }] + const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }] expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined() expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined() }) @@ -286,7 +318,7 @@ describe('classifyRunnerFailure', () => { const fatal = 'landlock-run: ruleset creation failed' const rules = [{ allowedExitCodes: [125], - fatalSignatures: ['', 'landlock-run: '], + fatalSignatures: ['', ' ', 'landlock-run: '], informationalLines: [notice], }] expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal }) @@ -354,10 +386,10 @@ describe('result facts', () => { }) describe('background sandbox facts', () => { - it('stamps facts and releases accounting when background spawn fails', async () => { + it('keeps an invalid-workdir spawn rejection ordinary and releases accounting', async () => { const { bash } = await setup() - const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing') - const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir })) + const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')) + const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') })) await task.done @@ -367,13 +399,13 @@ describe('background sandbox facts', () => { mode: 'read-only', denied: false, enforcement: 'full', - runnerFailed: true, }) const accounting = (bash as unknown as { processFacts: Map }).processFacts expect(accounting.size).toBe(0) + rmSync(parent, { recursive: true, force: true }) }) - it('classifies a spawn rejection whose reason is undefined', async () => { + it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), @@ -399,7 +431,6 @@ describe('background sandbox facts', () => { mode: 'read-only', denied: false, enforcement: 'full', - runnerFailed: true, }) }) diff --git a/packages/bash/tool-bash/src/background.ts b/packages/bash/tool-bash/src/background.ts index 6ba67cdfde..0269fe976c 100644 --- a/packages/bash/tool-bash/src/background.ts +++ b/packages/bash/tool-bash/src/background.ts @@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash' */ export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { // TODO(background-infrastructure-outcome): widen BashProcess with an explicit - // infrastructure-failure outcome, then map spawn failures and - // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn - // failure with a signal-less kill and a runner failure with an ordinary - // wrapper exit; real nonzero command exits must remain `completed`. + // infrastructure-failure outcome, then map it to task `failed`. Restricted + // runner failures expose sandbox.runnerFailed, but unconfined spawn failures + // still alias a signal-less kill; real nonzero command exits must remain + // `completed`. if (proc.status === 'killed') { return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } }