diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 8dac9fbfd6..703226a968 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -44,6 +44,7 @@ flowchart LR
pkg_bash["bash"]
svc_bash["ctx.bash
Bash executor seam"]
pkg_bash_local["bash-local"]
+ pkg_bash_sandbox["bash-sandbox"]
pkg_hooks_claude["hooks-claude"]
pkg_hooks_codex["hooks-codex"]
pkg_sandbox["sandbox"]
@@ -83,6 +84,7 @@ flowchart LR
pkg_approval --> svc_approval
pkg_bash --> svc_bash
pkg_bash_local --> svc_bash
+ pkg_bash_sandbox --> svc_bash
pkg_code_runtime --> svc_codeRuntime
pkg_code_runtime_worker --> svc_codeRuntime
pkg_compact --> svc_compact
@@ -130,6 +132,7 @@ flowchart LR
svc_fs --> pkg_tool_fs
svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic
+ svc_sandbox --> pkg_bash_sandbox
svc_sessionPersistence --> pkg_acp
svc_sessionPersistence --> pkg_agent_loop
svc_sessions --> pkg_agent
@@ -169,8 +172,8 @@ flowchart LR
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `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) | [`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 can replace bash-local. |
-| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | - | - | 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.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.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. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index b092b20537..baae8c65e0 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -150,6 +150,33 @@ export interface Config {
Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local/src/index.ts)
+## `@deepseek-ai/dsh-bash-sandbox`
+
+Requires: `sandbox`
+
+```ts config-catalog
+/**
+ * Plugin config: the local executor's knobs plus the sandbox policy. All
+ * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
+ * fail-safe default; an example that wants a workspace-writable agent opts in
+ * explicitly). The runner choice is NOT configured here: which platform
+ * backend confines the command is the `ctx.sandbox` provider's config.
+ */
+export interface Config extends LocalConfig {
+ /** File-sandbox mode commands run under (default: `read-only`). */
+ mode?: SandboxMode
+ /**
+ * Root directory `workspace-write` mode may write under (default: the
+ * executor's default working directory — `cwd`, else `process.cwd()`).
+ */
+ workspaceRoot?: string
+}
+```
+
+Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](../packages/sandbox/sandbox/src/index.ts)
+
+Source: [`packages/bash/bash-sandbox/src/index.ts:59`](../packages/bash/bash-sandbox/src/index.ts)
+
## `@deepseek-ai/dsh-code-runtime-worker`
```ts config-catalog
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index e0fa68bab9..2206adfde6 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -75,7 +75,7 @@ onTaskDone(listener: BashTaskListener): () => void
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md)
-Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:61`](../../packages/bash/bash/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md
index 273ba5ebe8..07b0307e2d 100644
--- a/docs/core-data-structures/bash.md
+++ b/docs/core-data-structures/bash.md
@@ -44,6 +44,20 @@ interface BashExecRequest {
* ownerless background start (a non-agent caller).
*/
owner?: OwnerToken | undefined
+ /**
+ * Explicit per-call sandbox-policy input, overriding the executor's
+ * configured default mode for THIS call. Never a silent default: a
+ * consumer sets it only from an explicit policy source — an
+ * `'allowed-once'` grant a human just issued through `ctx.approval` (the
+ * escalation flow in the sandbox RFC § Escalation, which outranks), or the
+ * session's standing override folded from its own `bash/sandbox-mode`
+ * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
+ * choice). A sandboxing executor confines THIS call under the given mode;
+ * a non-sandboxing executor carries the field and confines nothing (the
+ * tool layer stamps neither escalation nor overrides without a sandboxing
+ * executor — see {@link BashExecutor.sandboxMode}).
+ */
+ sandboxMode?: SandboxMode | undefined
}
```
@@ -79,6 +93,16 @@ interface BashExecSpec {
* task. `start()` stores it; `run()` (foreground) ignores it.
*/
owner: OwnerToken | undefined
+ /**
+ * The sandbox mode this call executes under, REQUIRED-but-nullable for the
+ * same visibility reason as `owner`. A sandboxing executor's `resolve()`
+ * stamps the effective mode (the request's explicit override, else its
+ * configured default) so `run()`/`start()` read the spec, never the config;
+ * a non-sandboxing executor carries the request value through verbatim and
+ * ignores it (`undefined` under such an executor means what its README says:
+ * unconfined execution).
+ */
+ sandboxMode: SandboxMode | undefined
}
```
@@ -106,6 +130,12 @@ interface BashRunResult {
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
+ /**
+ * Sandbox facts, present iff a sandboxing executor ran the command — an
+ * unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
+ * {@link BashSandboxInfo} for the `denied` classification semantics.
+ */
+ sandbox?: BashSandboxInfo
}
```
@@ -122,9 +152,56 @@ interface CollectedOutput {
}
```
+## File sandbox: `SandboxMode` / `BashSandboxInfo`
+
+A sandbox-consuming executor (`dsh-bash-sandbox`) confines commands under its executor-configured mode — fixed at config time for the executor's lifetime; a runtime/per-session mode surface is the sandbox RFC's config phase, not current behavior; the mode/enforcement vocabulary is owned by the `@deepseek-ai/dsh-sandbox` seam (whose provider wraps the executor's argv), and the mode governs FILE effects only — network and process visibility are deliberately not restricted, because a backend that cannot honestly enforce them must not pretend to:
+
+```ts type-equiv
+type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
+```
+
+A sandboxed run always reports the facts it executed under on `BashRunResult.sandbox`: `denied` is the executor's conservative classification of a failure as sandbox-caused (a failed exit whose stderr carries a filesystem-permission signature — never a clean exit or a signal kill), read from the collected stderr tail; `enforcement` reports how completely the selected backend governs the mode's file effects (`SandboxEnforcement = 'full' | 'partial'` — `partial` when an older Landlock ABI governs only a subset of the requested accesses; absent under `danger-full-access`, where nothing is confined); `runnerFailed` marks the opposite of a denial — the sandbox RUNNER itself failed and the command never ran (stamped only on settled background tasks; a foreground run surfaces the same condition as the thrown `SANDBOX_UNAVAILABLE` error):
+
+```ts type-equiv
+interface BashSandboxInfo {
+ /** The mode the command actually ran under. */
+ mode: SandboxMode
+ /**
+ * True when the executor classifies this run's failure as the sandbox
+ * denying a file operation. The classification is CONSERVATIVE (a failed
+ * exit whose stderr carries a filesystem-permission signature) and reads
+ * the COLLECTED stderr — the bounded in-memory tail per
+ * {@link CollectedOutput} semantics, so a signature that survives only in a
+ * spill file is missed toward `denied: false`. A plain command failure
+ * keeps `denied: false` even under a sandboxed mode.
+ */
+ denied: boolean
+ /**
+ * How completely the runner enforced `mode`'s file effects — see
+ * {@link SandboxEnforcement}. Absent exactly when `mode` is
+ * `danger-full-access`: nothing is confined, so there is no enforcement to
+ * report.
+ */
+ enforcement?: SandboxEnforcement
+ /**
+ * True when the executor classifies this failure as the SANDBOX RUNNER
+ * itself failing (missing binary, refused profile, fail-closed refusal
+ * before exec) — the command NEVER RAN; this is a sandbox failure, not a
+ * task failure, and it outranks `denied` (a runner's own error text can
+ * contain denial words). Only ever stamped on settled BACKGROUND tasks: a
+ * foreground run surfaces the same condition as the thrown
+ * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error
+ * channel; a settled task's facts are its only channel).
+ */
+ runnerFailed?: boolean
+}
+```
+
+One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the sandbox seam) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend: sandboxed modes fail CLOSED instead of silently running unconfined. The model's view of the sandbox is result facts only: the static bash tool description explains the denial marker, and each run's `result.sandbox` carries the mode it executed under (no live-mode getter on the seam and no current-mode prompt statement — both arrive with the runtime-context phase of the RFC below). Denials are deny-only result facts today; the approval/escalated-retry flow on top of them is the [sandbox RFC](../rfc/proposed/feature/2026-07-06-sandbox.md).
+
## Background tasks: `BashTask`
-A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects.
+A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects. A sandboxing executor stamps `sandbox` once the task settles — classification runs against the settled task's collected stderr — so the field is absent while running and under an unsandboxed executor.
```ts type-equiv
interface BashTask {
@@ -137,6 +214,16 @@ interface BashTask {
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects). */
readonly done: Promise
+ /**
+ * Sandbox facts for this task's execution, stamped by a sandboxing executor
+ * once the task settles and BEFORE completion listeners are notified — an
+ * `onTaskDone` consumer and a `done` awaiter both see it. Denial
+ * classification runs against the settled task's collected stderr, so the
+ * field cannot exist earlier: absent while the task is running and under an
+ * executor that does not sandbox. See {@link BashSandboxInfo} for the
+ * `denied` semantics.
+ */
+ sandbox?: BashSandboxInfo
}
```
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 2c2e94a254..4553e4f421 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -27,6 +27,7 @@ flowchart TD
subgraph group_bash["packages/bash"]
pkg_bash["bash"]
pkg_bash_local["bash-local"]
+ pkg_bash_sandbox["bash-sandbox"]
pkg_tool_bash["tool-bash"]
end
subgraph group_fs["packages/fs"]
@@ -108,15 +109,12 @@ flowchart TD
pkg_workflow_workerthread["workflow-workerthread"]
end
pkg_llm --> pkg_brand
- pkg_bash --> pkg_brand
pkg_code_runtime_worker --> pkg_code_runtime
pkg_llm_deepseek --> pkg_llm
pkg_llm_pi_ai --> pkg_llm
pkg_session --> pkg_brand
pkg_session --> pkg_llm
pkg_system_prompt --> pkg_llm
- pkg_bash_local --> pkg_bash
- pkg_bash_local --> pkg_timeout
pkg_fs --> pkg_brand
pkg_fs --> pkg_llm
pkg_web --> pkg_llm
@@ -125,6 +123,9 @@ flowchart TD
pkg_agent --> pkg_llm
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
+ pkg_bash --> pkg_brand
+ pkg_bash --> pkg_sandbox
+ pkg_bash --> pkg_session
pkg_fs_local --> pkg_fs
pkg_fs_policy --> pkg_fs
pkg_compact --> pkg_llm
@@ -134,17 +135,19 @@ flowchart TD
pkg_web_search_deepseek --> pkg_web
pkg_web_search_exa --> pkg_web
pkg_web_search_perplexity --> pkg_web
- pkg_hook_protocol --> pkg_bash
- pkg_hook_protocol --> pkg_session
pkg_session_persistence --> pkg_session
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
+ pkg_bash_local --> pkg_bash
+ pkg_bash_local --> pkg_timeout
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
+ pkg_hook_protocol --> pkg_bash
+ pkg_hook_protocol --> pkg_session
pkg_session_persistence_jsonl --> pkg_session
pkg_session_persistence_jsonl --> pkg_session_persistence
pkg_session_persistence_sqlite --> pkg_session
@@ -167,6 +170,9 @@ flowchart TD
pkg_tools --> pkg_llm
pkg_tools --> pkg_session
pkg_tools --> pkg_system_prompt
+ pkg_bash_sandbox --> pkg_bash
+ pkg_bash_sandbox --> pkg_bash_local
+ pkg_bash_sandbox --> pkg_sandbox
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_session
@@ -176,6 +182,7 @@ flowchart TD
pkg_tool_bash --> pkg_agent
pkg_tool_bash --> pkg_bash
pkg_tool_bash --> pkg_llm
+ pkg_tool_bash --> pkg_sandbox
pkg_tool_bash --> pkg_system_prompt
pkg_tool_bash --> pkg_tools
pkg_tool_fs --> pkg_fs
@@ -286,17 +293,16 @@ flowchart TD
| [`app-boot`](../packages/ui/app-boot) | `ui` | — |
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) |
-| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm) |
-| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
+| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -304,11 +310,12 @@ flowchart TD
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) |
-| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
+| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -316,8 +323,9 @@ flowchart TD
| [`approval`](../packages/approval/approval) | `approval` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
+| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 6d97017f1d..ba0bd64115 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -124,7 +124,7 @@ Registered by the tool registry itself under `mode: code` / `mode: both` (see th
### `bash`
-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]`. 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`.
+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`.
```json
{
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
index df7187bbbb..72d5e47ab9 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}}
-{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-52lrTl.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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]`. 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"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]`. 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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"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."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}}
+{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-52lrTl.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"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."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
index 72533bcdeb..0793952e95 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783611771396,"data":{"turn":1,"step":1}}
-{"type":"request/header","seq":3,"time":1783611771396,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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]`. 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}}
+{"type":"request/header","seq":3,"time":1783611771396,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately. No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** Ask the executor to kill a running background bash task by task id. */\n bash_kill(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */\n bash_output(args: {\n /** Task id returned by the bash tool. */\n task_id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n }): Promise;\n /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n }): Promise;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}}]},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":5,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":6,"time":1783611771978,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl
index 8475c97896..151aed9a4d 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl
@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}}
-{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"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]`. 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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"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."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}}
+{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding assistant powered by the deepseek-v4-flash model. Your working\ndirectory is /var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.","tools":[{"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"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."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
diff --git a/knip.json b/knip.json
index ba6dfb6fb2..c0c202a221 100644
--- a/knip.json
+++ b/knip.json
@@ -15,6 +15,10 @@
],
"project": ["scripts/**/*.ts", "examples/**/*.ts"]
},
+ "packages/bash/bash-sandbox": {
+ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
+ "project": ["src/**/*.ts", "tests/**/*.ts"]
+ },
"packages/sandbox/sandbox-local": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
diff --git a/packages/bash/README.md b/packages/bash/README.md
index 9a9dba88d5..4072998f3e 100644
--- a/packages/bash/README.md
+++ b/packages/bash/README.md
@@ -1,11 +1,12 @@
# bash/ — bash capability family
-The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, a concrete local implementation, and the model-facing tool that consumes it. All **product** packages.
+The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
-| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
+| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
+| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
-The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible.
+The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/)).
diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md
index dec29ce93b..069adb3576 100644
--- a/packages/bash/bash-local/README.md
+++ b/packages/bash/bash-local/README.md
@@ -27,4 +27,4 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
## Sandboxing
-`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § Extending The Harness. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
+Execution policy does NOT belong in this package: this executor always runs commands unconfined. Confinement is [`dsh-bash-sandbox`](../bash-sandbox/README.md), which extends this executor verbatim and confines commands under the `ctx.sandbox` seam's bwrap/Landlock/Seatbelt backends ([sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)); per-call allow/deny/ask policy belongs on the `tools/pre-execute` gate.
diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts
index 3e09d7e35b..6903c06e32 100644
--- a/packages/bash/bash-local/src/index.ts
+++ b/packages/bash/bash-local/src/index.ts
@@ -133,6 +133,10 @@ export class LocalBashExecutor extends BashExecutor {
// Carry the owner through verbatim (required-but-nullable on the spec):
// the executor never interprets it — the consumer's access policy does.
owner: request.owner,
+ // Carry a sandbox-mode override through verbatim: this executor never
+ // confines, so the field is inert here (the seam contract) — a
+ // sandboxing subclass overrides resolve() to stamp its default instead.
+ sandboxMode: request.sandboxMode,
}
}
@@ -212,6 +216,18 @@ export class LocalBashExecutor extends BashExecutor {
return this.tasks.get(id)
}
+ /**
+ * Full collected stderr of a tracked task from stream start (bounded by the
+ * in-memory cap; bytes only in the spill file are not re-read). A protected
+ * seam for subclasses that classify a settled task's outcome — reading here
+ * does NOT advance the consumer's {@link readOutput} cursor. An unknown id
+ * (a task already dropped by disposal) reads as empty.
+ */
+ protected collectedStderr(id: BashTaskId): string {
+ const task = this.tasks.get(id)
+ return task === undefined ? '' : task.running.stderr.readFrom(0).text
+ }
+
ownerOf(id: BashTaskId): OwnerToken | undefined {
// Unknown id and known-but-ownerless both read as undefined — the consumer
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md
new file mode 100644
index 0000000000..9ceb793e27
--- /dev/null
+++ b/packages/bash/bash-sandbox/README.md
@@ -0,0 +1,33 @@
+# @deepseek-ai/dsh-bash-sandbox
+
+Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — the model-facing tool layer (`dsh-tool-bash`) is untouched; that swap is exactly what the seams exist for.
+
+Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
+
+| Mode | File effects |
+|---|---|
+| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
+| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
+| `danger-full-access` | No confinement; the provider is never consulted. Execution is `dsh-bash-local`'s verbatim — foreground results still carry `sandbox: { mode, denied: false }` (no `enforcement`: nothing was confined), background tasks carry no sandbox facts |
+
+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 task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker.
+- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
+- **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 tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
+
+Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
+
+```yaml
+- id: sandbox
+ name: '@deepseek-ai/dsh-sandbox-local'
+- id: bash
+ name: '@deepseek-ai/dsh-bash-sandbox'
+ config:
+ mode: read-only
+ workspaceRoot: !!js process.cwd()
+```
+
+The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo.
diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json
new file mode 100644
index 0000000000..0077511946
--- /dev/null
+++ b/packages/bash/bash-sandbox/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@deepseek-ai/dsh-bash-sandbox",
+ "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-bash": "^0.0.1",
+ "@deepseek-ai/dsh-bash-local": "^0.0.1",
+ "@deepseek-ai/dsh-sandbox": "^0.0.1",
+ "cordis": "^4.0.0-rc.6"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-bash": "workspace:^",
+ "@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-sandbox": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-local": "workspace:^",
+ "node-addon-landlock-run": "0.0.0-test.0",
+ "cordis": "^4.0.0-rc.6"
+ }
+}
diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts
new file mode 100644
index 0000000000..d4f7d867ad
--- /dev/null
+++ b/packages/bash/bash-sandbox/src/index.ts
@@ -0,0 +1,305 @@
+/**
+ * `SandboxBashExecutor`: the sandbox-consuming implementation of the
+ * `@deepseek-ai/dsh-bash` executor seam. Every spawned command is wrapped by
+ * the `ctx.sandbox` provider (`@deepseek-ai/dsh-sandbox`) according to the
+ * configured {@link SandboxMode}: the executor hands the provider the exact
+ * `['bash', '-c', command]` argv it is about to spawn and spawns the wrapped
+ * argv instead. WHICH platform runner confines it — and whether one is
+ * usable at all (the provider fails CLOSED with a structured
+ * `SANDBOX_UNAVAILABLE` error rather than passing the argv through) — is the
+ * provider's concern (`@deepseek-ai/dsh-sandbox-local` first).
+ *
+ * Extends `LocalBashExecutor` so all process mechanics — spawn, process-group
+ * kills, timeout escalation, output collection and spill files, background
+ * tasks, the credential scrub — are the local implementation's, verbatim.
+ * This package adds only the seam consumption and the result facts, which is
+ * exactly the split the capability seam was designed for (a sandboxing
+ * executor replaces `dsh-bash-local` without touching `dsh-tool-bash`, and
+ * swapping the confinement backend never touches this package).
+ *
+ * A failed run whose stderr carries the selected backend's own denial
+ * dialect (the signatures the provider stamps on every wrap) is classified
+ * as a sandbox denial on `BashRunResult.sandbox`, and every confined result
+ * also carries how completely the selected runner enforces the mode
+ * (`sandbox.enforcement`, from the provider's wrap). A failure carrying the
+ * backend's RUNNER-FAILURE signature instead means the sandbox itself broke
+ * and the command never ran: the foreground path re-throws it as the
+ * structured fail-closed `SANDBOX_UNAVAILABLE` error (late twin of the
+ * provider's confine-time throw), a settled background task stamps
+ * `sandbox.runnerFailed` — either way a broken sandbox can never read as a
+ * failing command, and the command never slips through unconfined.
+ *
+ * Deny-only at the seam, escalation at the tool: a denial is a reported FACT
+ * here, and the one-shot user-approved escalated retry of a denied action
+ * (docs/rfc/proposed/feature/2026-07-06-sandbox.md) is driven by
+ * `dsh-tool-bash` through `ctx.approval` — this executor's contribution is the
+ * per-call `sandboxMode` override it honors in {@link resolve}: an escalated
+ * call runs (and classifies, and reports) under ITS granted mode while every
+ * neighboring call keeps the configured default.
+ *
+ * @module @deepseek-ai/dsh-bash-sandbox
+ */
+
+import { resolve } from 'node:path'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
+import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
+import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
+import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
+
+/**
+ * Plugin config: the local executor's knobs plus the sandbox policy. All
+ * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
+ * fail-safe default; an example that wants a workspace-writable agent opts in
+ * explicitly). The runner choice is NOT configured here: which platform
+ * backend confines the command is the `ctx.sandbox` provider's config.
+ */
+export interface Config extends LocalConfig {
+ /** File-sandbox mode commands run under (default: `read-only`). */
+ mode?: SandboxMode
+ /**
+ * Root directory `workspace-write` mode may write under (default: the
+ * executor's default working directory — `cwd`, else `process.cwd()`).
+ */
+ workspaceRoot?: string
+}
+
+/**
+ * Quote one string as a single-quoted POSIX shell word (embedded single
+ * quotes become `'\''`), so a wrapped argv element survives the outer
+ * `bash -c` re-parse byte-for-byte.
+ * @param text - the raw argv element to quote.
+ * @returns the single-quoted shell word.
+ */
+export function shellQuote(text: string): string {
+ return `'${text.replaceAll("'", String.raw`'\''`)}'`
+}
+
+/**
+ * Conservative sandbox-denial classifier: a run counts as denied only when it
+ * FAILED (nonzero exit — a signal kill is not a denial) and its stderr
+ * carries one of the SELECTED BACKEND's own denial signatures — the dialect
+ * the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
+ * `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
+ * under Landlock's EACCES, `Operation not permitted` under Seatbelt's
+ * EPERM). Matching the backend's dialect rather than a cross-backend union
+ * keeps the classifier from claiming denials the active backend never
+ * produces (bare EPERM text under a Linux runner names non-file boundaries —
+ * mount, kill, ptrace — that fail the same way unsandboxed). Text inference
+ * is the fallback signal until a runner provides a structured one (which
+ * wins once it exists); it errs toward NOT claiming a denial, and its known
+ * residual imprecision is non-sandbox text in the active dialect (an ssh
+ * auth failure reads as a denial under Landlock, a refused `kill` under
+ * Seatbelt).
+ * @param result - the settled foreground run to classify.
+ * @param signatures - the active wrap's denial dialect, case-insensitive
+ * stderr substrings.
+ * @returns whether the run's failure reads as a sandbox denial.
+ */
+export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
+ return matchesSignature(result.exitCode, result.stderr.text, signatures)
+}
+
+/**
+ * Runner-failure classifier: a failed run whose stderr carries the SELECTED
+ * BACKEND's own runner-failure signature (`ConfinedArgv.
+ * runnerFailureSignatures`: the runner's error prefix, which also matches
+ * the shell's runner-not-found message) means the SANDBOX itself failed and
+ * the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
+ * error text can contain denial words (an unopenable grant root reports
+ * `Permission denied`) — and surfaced as the fail-closed
+ * `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
+ * on a settled background task. Same conservative-text-inference stance and
+ * residual imprecision as the denial classifier (a failing task that itself
+ * prints the runner's prefix reads as a runner failure).
+ * @param result - the settled foreground run to classify.
+ * @param signatures - the active wrap's runner-failure signatures,
+ * case-insensitive stderr substrings.
+ * @returns whether the run's failure reads as the runner itself failing.
+ */
+export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
+ return matchesSignature(result.exitCode, result.stderr.text, signatures)
+}
+
+/**
+ * The classifier core shared by foreground results and settled background
+ * tasks: failed AND signature present. Lowercases BOTH sides — the seam
+ * declares its signatures case-insensitive, and producers compose them from
+ * runtime data of any case (an `argv0` path, `No such file or directory`).
+ */
+function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
+ if (exitCode === null || exitCode === 0) return false
+ const lowered = stderr.toLowerCase()
+ return signatures.some(signature => lowered.includes(signature.toLowerCase()))
+}
+
+/**
+ * Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
+ * INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
+ * the whole swap — the tool layer is untouched). The DEFAULT mode is fixed at
+ * config time for the executor's lifetime; a single call escalates past it
+ * only through the request-level `sandboxMode` override its {@link resolve}
+ * stamps onto the spec (granted upstream via `ctx.approval` — the
+ * sandbox RFC § Escalation). The model learns of the sandbox only through
+ * result facts: the static bash tool description explains the denial marker,
+ * and every run's `result.sandbox` carries the mode it executed under and how
+ * completely the runner enforced it. Runtime default-mode switching and a
+ * current-mode prompt statement are deliberately absent until a config
+ * surface exists to drive them (TODO(sandbox-config): the sandbox RFC's
+ * future-work list brings both with the per-session config options).
+ */
+export class SandboxBashExecutor extends LocalBashExecutor {
+ static inject = ['sandbox']
+
+ // The sandbox-specific fields intersect the local executor's Config as an
+ // inline schema call: the config catalog walks `static Config` statically.
+ static override Config: z = z.intersect([
+ LocalBashExecutor.Config,
+ z.object({
+ mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
+ workspaceRoot: z.string(),
+ }),
+ ])
+
+ private readonly mode: SandboxMode
+ private readonly workspaceRoot: string
+ /**
+ * Per-task facts, keyed by task id from `start()` until the settle stamp
+ * consumes them: the mode the task runs under (per-call — an escalated task
+ * differs from its neighbors) plus its wrap facts. The seam returns facts
+ * PER WRAP — a provider may legally vary enforcement or dialect between
+ * calls — so overlapping background tasks must each classify against their
+ * OWN wrap; a single latest-wrap field would let a later `start()` clobber
+ * an earlier task's facts before it settles. A `danger-full-access` task
+ * has NO entry (nothing confined it), which is what the settle stamp keys
+ * off.
+ */
+ private readonly taskFacts = new Map()
+
+ constructor(ctx: Context, config: Config) {
+ super(ctx, config)
+ // schemastery (static Config) already filled the defaulted fields — the
+ // cast records that runtime fact (mirrors LocalBashExecutor's config
+ // cast). `workspaceRoot` and `cwd` have NO schema default, so their
+ // fallback chain is real branching.
+ this.mode = config.mode as SandboxMode
+ this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
+ }
+
+ /** The configured default mode — the capability fact the tool layer reads. */
+ override get sandboxMode(): SandboxMode {
+ return this.mode
+ }
+
+ /**
+ * Stamp the effective mode onto the spec — the request's explicit override
+ * (an approved escalation), else this executor's configured default — so
+ * defaulting stays an explicit resolve step and `run()`/`start()` read the
+ * spec, never the config.
+ */
+ override resolve(request: BashExecRequest): BashExecSpec {
+ return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
+ }
+
+ override async run(spec: BashExecSpec): Promise {
+ // resolve() always stamps the mode; the cast records that invariant
+ // (mirrors the constructor's config casts).
+ const mode = spec.sandboxMode as SandboxMode
+ if (mode === 'danger-full-access') {
+ const result = await super.run(spec)
+ return { ...result, sandbox: { mode, denied: false } }
+ }
+ const confined = this.confine(spec.command, mode)
+ const result = await super.run({ ...spec, command: confined.command })
+ // Runner failure outranks denial: the sandbox itself failed and the
+ // command NEVER RAN — surface the same structured fail-closed error a
+ // confine-time discovery throws (late detection, same outcome), with
+ // the runner's own first stderr line as the cause. Returning it as a
+ // task result would let a broken sandbox read as a failing command.
+ if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
+ throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
+ }
+ return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
+ }
+
+ override start(spec: BashExecSpec): BashTask {
+ // Same stamped-by-resolve invariant as run().
+ const mode = spec.sandboxMode as SandboxMode
+ if (mode === 'danger-full-access') return super.start(spec)
+ // Sandbox facts are stamped at settle time by {@link notifyTaskDone}
+ // (denial classification runs against the settled task's collected
+ // stderr). The map entry lands synchronously after spawn, strictly
+ // before the earliest possible settle (a process exit reaches us no
+ // sooner than the next tick).
+ const confined = this.confine(spec.command, mode)
+ const task = super.start({ ...spec, command: confined.command })
+ const { enforcement, denialSignatures, runnerFailureSignatures } = confined
+ this.taskFacts.set(task.id, { mode, enforcement, denialSignatures, runnerFailureSignatures })
+ return task
+ }
+
+ /**
+ * Stamp the sandbox facts BEFORE completion listeners run: the base
+ * executor notifies from inside the task's settle path, so overriding the
+ * notification point is what makes `task.sandbox` visible to `onTaskDone`
+ * consumers and `done` awaiters alike. Each task classifies against the
+ * facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
+ * per-task map here — settle is the entry's end of life): with per-call
+ * escalation, tasks under different modes settle side by side, so keying
+ * anything off the configured default would misreport them. A
+ * `danger-full-access` task has no map entry and carries no facts (nothing
+ * confined it); a signal-killed task (null exit code) is never a denial,
+ * mirroring the foreground classifier.
+ */
+ protected override notifyTaskDone(task: BashTask): void {
+ const facts = this.taskFacts.get(task.id)
+ if (facts !== undefined) {
+ this.taskFacts.delete(task.id)
+ const stderr = this.collectedStderr(task.id)
+ // Runner failure outranks denial (the command never ran; the runner's
+ // own error text can contain denial words). A settled task has no
+ // error channel left, so the fact IS the surface here — the foreground
+ // path throws instead.
+ const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
+ task.sandbox = {
+ mode: facts.mode,
+ denied: !runnerFailed && matchesSignature(task.exitCode, stderr, facts.denialSignatures),
+ enforcement: facts.enforcement,
+ ...(runnerFailed ? { runnerFailed } : {}),
+ }
+ }
+ super.notifyTaskDone(task)
+ }
+
+ /**
+ * Wrap one shell command via the `ctx.sandbox` provider: hand over the
+ * exact `['bash', '-c', command]` argv this executor would spawn, get back
+ * the confined argv, and re-assemble it into the `exec …` command string
+ * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
+ * `exec`s into the runner, so no extra shell lingers). Provider errors
+ * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
+ */
+ private confine(command: string, mode: ConfinedSandboxMode): {
+ command: string
+ enforcement: SandboxEnforcement
+ denialSignatures: readonly string[]
+ runnerFailureSignatures: readonly string[]
+ } {
+ const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
+ return {
+ command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
+ enforcement: confined.enforcement,
+ denialSignatures: confined.denialSignatures,
+ runnerFailureSignatures: confined.runnerFailureSignatures,
+ }
+ }
+}
+
+export default SandboxBashExecutor
diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts
new file mode 100644
index 0000000000..6a748bc389
--- /dev/null
+++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts
@@ -0,0 +1,101 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
+import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
+
+/**
+ * KEYLESS consumer-integration proof under bwrap: the REAL
+ * `LocalSandboxProvider` (nothing forced — bwrap is the ladder's first rung,
+ * so a passing probe selects it) underneath the REAL `SandboxBashExecutor`,
+ * driven through the executor's public run/start paths. Verifies the WORLD
+ * (files exist or don't) plus the stamped result facts — in particular that
+ * bwrap's EROFS denial text classifies as `denied: true` through the
+ * wrap-carried dialect; the backend-only confinement proofs live with
+ * `@deepseek-ai/dsh-sandbox-local`.
+ *
+ * Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
+ * host that denies unprivileged user namespaces.
+ *
+ * HOME-based dirs on purpose: bwrap's `/tmp` is an ephemeral mount, so only
+ * paths outside it prove the workspace-root boundary.
+ */
+
+const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
+const bwrapUsable = probe.status === 0
+
+let ctx: Context | undefined
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ await ctx?.fiber.dispose()
+ ctx = undefined
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+async function tempDir(base: string): Promise {
+ const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-'))
+ tempDirs.push(dir)
+ return dir
+}
+
+async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise {
+ ctx = new Context()
+ await ctx.plugin(LocalSandboxProvider, {})
+ await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
+ return ctx.bash as SandboxBashExecutor
+}
+
+describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.bash', () => {
+ it('read-only denies a write — the file must NOT exist, and EROFS text classifies as a denial', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
+ expect(result.exitCode).not.toBe(0)
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
+ })
+
+ it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
+ const workdir = await tempDir(homedir())
+ const outside = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'workspace-write')
+
+ const inside = await bash.run(bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
+ expect(inside.exitCode).toBe(0)
+ expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
+ expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
+
+ const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
+ expect(denied.exitCode).not.toBe(0)
+ expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
+ expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
+ })
+
+ it('classifies a background denial once the task settles', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
+ await task.done
+ expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
+ })
+
+ it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const command = `printf escalated > ${workdir}/escalated.txt`
+ const strict = await bash.run(bash.resolve({ command }))
+ expect(strict.exitCode).not.toBe(0)
+ expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
+ const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
+ expect(retried.exitCode).toBe(0)
+ expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
+ expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
+ })
+})
diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts
new file mode 100644
index 0000000000..8263dad6fe
--- /dev/null
+++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts
@@ -0,0 +1,100 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { homedir, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { launcherPath } from 'node-addon-landlock-run'
+import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
+import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
+
+/**
+ * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
+ * rung forced off, so the npm-distributed `landlock-run` confines) underneath the
+ * REAL `SandboxBashExecutor`, driven through the executor's public run/start
+ * paths. Verifies the WORLD (files exist or don't) plus the stamped result
+ * facts; the backend-only confinement proofs live with
+ * `@deepseek-ai/dsh-sandbox-local`.
+ *
+ * Self-skips when the running kernel does not enforce Landlock; the
+ * launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
+ */
+
+const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
+const landlockUsable = probe.status === 0
+/** The kernel's enforcement level from the probe report — stamped facts below must match it. */
+const enforcement = /partially enforced/.test(probe.stdout ?? '') ? 'partial' : 'full'
+
+let ctx: Context | undefined
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ await ctx?.fiber.dispose()
+ ctx = undefined
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+async function tempDir(base: string): Promise {
+ const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-'))
+ tempDirs.push(dir)
+ return dir
+}
+
+async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise {
+ ctx = new Context()
+ await ctx.plugin(LocalSandboxProvider, {})
+ ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
+ await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
+ return ctx.bash as SandboxBashExecutor
+}
+
+describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement through ctx.bash', () => {
+ it('read-only denies a write — the file must NOT exist, the result carries denial + enforcement facts', async () => {
+ const workdir = await tempDir(tmpdir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
+ expect(result.exitCode).not.toBe(0)
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
+ expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
+ })
+
+ it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
+ const workdir = await tempDir(homedir())
+ const outside = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'workspace-write')
+
+ const inside = await bash.run(bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
+ expect(inside.exitCode).toBe(0)
+ expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement })
+ expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
+
+ const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
+ expect(denied.exitCode).not.toBe(0)
+ expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement })
+ expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
+ })
+
+ it('classifies a background denial once the task settles', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
+ await task.done
+ expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
+ expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
+ })
+
+ it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const command = `printf escalated > ${workdir}/escalated.txt`
+ const strict = await bash.run(bash.resolve({ command }))
+ expect(strict.exitCode).not.toBe(0)
+ expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
+ expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
+ const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
+ expect(retried.exitCode).toBe(0)
+ expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
+ expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
+ })
+})
diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts
new file mode 100644
index 0000000000..4f92ba38f0
--- /dev/null
+++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts
@@ -0,0 +1,327 @@
+/**
+ * SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
+ * `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
+ * policy hand-off, fail-closed propagation, classification, and fact
+ * stamping all deterministic without any real runner; the real-provider
+ * integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
+ * with plain unix permissions (a 0555 directory), which exercises the same
+ * stderr signature the classifier keys on.
+ */
+
+import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
+import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
+import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
+import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
+import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
+
+const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
+
+/** One recorded provider call: the argv handed over and the policy it rode with. */
+interface ConfineCall {
+ argv: string[]
+ policy: SandboxPolicy
+}
+
+/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
+const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
+
+/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
+const RUNNER_FAILURE = ['fake-runner: '] as const
+
+/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
+const passthrough = (argv: readonly string[]): ConfinedArgv =>
+ ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
+
+/**
+ * Boot a context with a recording fake `ctx.sandbox` (behavior injectable
+ * per test) and the executor under test on top of it.
+ */
+async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
+ const calls: ConfineCall[] = []
+ class FakeSandboxProvider extends SandboxProvider {
+ confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
+ calls.push({ argv: [...argv], policy })
+ return behavior(argv, policy)
+ }
+ }
+ const ctx = new Context()
+ await ctx.plugin(FakeSandboxProvider)
+ await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
+ const bash = ctx.bash as SandboxBashExecutor
+ bash.internals = { spillDir }
+ return { ctx, bash, calls }
+}
+
+function output(text: string): CollectedOutput {
+ return { text, truncated: false }
+}
+
+function runResult(exitCode: number | null, stderr: string): BashRunResult {
+ return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
+}
+
+describe('the provider hand-off', () => {
+ it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
+ const { bash, calls } = await setup()
+ const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
+ expect(result.stdout.text).toBe('a b c\'d\n')
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
+ expect(calls).toEqual([{
+ argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
+ policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
+ }])
+ })
+
+ it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
+ // The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
+ // the sentinel only prints if the executor spawned the WRAPPED argv.
+ const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
+ const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
+ expect(result.stdout.text).toBe('1')
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
+ })
+
+ it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
+ const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
+ const result = await bash.run(bash.resolve({ command: 'true' }))
+ expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
+ expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
+ })
+
+ it('an explicit workspaceRoot wins over cwd', async () => {
+ const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
+ await bash.run(bash.resolve({ command: 'true' }))
+ expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
+ })
+
+ it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
+ const { bash, calls } = await setup()
+ await bash.run(bash.resolve({ command: 'true' }))
+ const task = bash.start(bash.resolve({ command: 'true' }))
+ await task.done
+ expect(calls).toHaveLength(2)
+ })
+
+ it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
+ expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
+ })
+})
+
+describe('fail closed', () => {
+ it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
+ const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
+ const spec = bash.resolve({ command: 'echo hi' })
+ await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
+ expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
+ })
+})
+
+describe('danger-full-access', () => {
+ it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
+ const { bash, calls } = await setup({ mode: 'danger-full-access' })
+ const result = await bash.run(bash.resolve({ command: 'echo free' }))
+ expect(result.stdout.text).toBe('free\n')
+ expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
+ expect(calls).toHaveLength(0)
+ })
+
+ it('start() passes through unwrapped and stamps nothing at settle', async () => {
+ const { bash, calls } = await setup({ mode: 'danger-full-access' })
+ const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
+ await task.done
+ expect(task.sandbox).toBeUndefined()
+ expect(bash.readOutput(task.id).delta).toContain('free-bg')
+ expect(calls).toHaveLength(0)
+ })
+})
+
+describe('per-call sandboxMode override (the escalation mechanism)', () => {
+ it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
+ const { bash } = await setup()
+ expect(bash.sandboxMode).toBe('read-only')
+ expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
+ })
+
+ it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
+ const { bash, calls } = await setup()
+ expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
+ await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
+ await bash.run(bash.resolve({ command: 'true' }))
+ expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
+ })
+
+ it('an escalated run reports the mode it ACTUALLY ran under', async () => {
+ const { bash } = await setup()
+ const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
+ expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
+ })
+
+ it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
+ const { bash, calls } = await setup()
+ const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
+ expect(result.stdout.text).toBe('free\n')
+ expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
+ expect(calls).toHaveLength(0)
+ })
+
+ it('overlapping background tasks settle with their OWN modes (an escalated task next to a default one)', async () => {
+ // With per-call policy, tasks under different modes are in flight at
+ // once — anything keyed off the configured default would misreport the
+ // escalated one at its settle stamp.
+ const { bash } = await setup()
+ const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
+ const plain = bash.start(bash.resolve({ command: 'true' }))
+ await plain.done
+ await escalated.done
+ expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
+ expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
+ })
+
+ it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
+ const { bash, calls } = await setup()
+ const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
+ await task.done
+ expect(task.sandbox).toBeUndefined()
+ expect(bash.readOutput(task.id).delta).toContain('bg-free')
+ expect(calls).toHaveLength(0)
+ })
+})
+
+describe('classifyDenial', () => {
+ it('never classifies a clean exit or a signal kill as a denial', () => {
+ expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
+ expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
+ })
+
+ it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
+ expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
+ expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
+ // Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
+ // it unsandboxed too, and the mode vocabulary governs file effects only —
+ // claiming a file denial here would tell the model the sandbox blocked
+ // something it never governed.
+ expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
+ expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
+ })
+
+ it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
+ // The same stderr flips meaning with the backend: under Seatbelt, EPERM
+ // text IS how the kernel refuses a governed file write; under bwrap's
+ // EROFS-only dialect, `Permission denied` is ordinary DAC, not the
+ // sandbox — per-wrap signatures are what keep both classifications honest.
+ expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
+ expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
+ })
+})
+
+describe('classifyRunnerFailure', () => {
+ it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
+ const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
+ expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
+ expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
+ })
+})
+
+describe('result facts', () => {
+ it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
+ const { bash } = await setup()
+ const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
+ mkdirSync(lockedDir)
+ chmodSync(lockedDir, 0o555)
+ const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
+ expect(result.exitCode).not.toBe(0)
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ })
+
+ it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
+ const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
+ const result = await bash.run(bash.resolve({ command: 'true' }))
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
+ })
+})
+
+describe('background sandbox facts', () => {
+ it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
+ const { bash } = await setup()
+ const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
+ await task.done
+ expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ })
+
+ it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
+ // The wrap's runner prefix on a failed run means the SANDBOX broke and
+ // the command never ran — the late twin of the confine-time throw, with
+ // the runner's own first stderr line carried as the cause.
+ const { bash } = await setup()
+ const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
+ await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
+ await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
+ })
+
+ it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
+ const { bash } = await setup()
+ await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
+ .rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
+ })
+
+ it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
+ const { bash } = await setup()
+ const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
+ await task.done
+ expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
+ })
+
+ it('completion listeners already see the stamped facts (stamp precedes notify)', async () => {
+ const { ctx, bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
+ const seen: unknown[] = []
+ ctx.bash.onTaskDone((task) => { seen.push(task.sandbox) })
+ const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
+ await task.done
+ expect(seen).toEqual([{ mode: 'read-only', denied: true, enforcement: 'partial' }])
+ })
+
+ it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
+ // The seam returns facts PER WRAP — a legal provider may vary them
+ // between calls. The slow task settles AFTER the quick one started, so a
+ // latest-wrap field would classify its denial against the quick task's
+ // dialect (missing it) and stamp the wrong enforcement.
+ const wraps: Array> = [
+ { enforcement: 'partial', denialSignatures: ['permission denied'] },
+ { enforcement: 'full', denialSignatures: ['read-only file system'] },
+ ]
+ let call = 0
+ const { bash } = await setup({}, (argv) => {
+ const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick
+ return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
+ })
+ const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
+ const quick = bash.start(bash.resolve({ command: 'true' }))
+ await quick.done
+ await slow.done
+ expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
+ expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
+ })
+
+ it('a signal-killed task is never a denial (null exit code)', async () => {
+ const { bash } = await setup()
+ const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
+ // Let the stderr land before the kill so the classifier sees the
+ // signature and must still refuse it on the null exit code alone.
+ await vi.waitFor(() => { expect(bash.readOutput(task.id).delta).toContain('Permission denied') })
+ bash.kill(task.id)
+ await task.done
+ expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
+ })
+
+ it('disposal kills wrapped background tasks (inherited HMR safety)', async () => {
+ const { ctx, bash } = await setup()
+ const task = bash.start(bash.resolve({ command: 'sleep 30' }))
+ await ctx.fiber.dispose()
+ expect(task.status).toBe('killed')
+ })
+})
diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
new file mode 100644
index 0000000000..8ae25a8d39
--- /dev/null
+++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
@@ -0,0 +1,101 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
+import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
+
+/**
+ * KEYLESS consumer-integration proof on macOS: the REAL `LocalSandboxProvider`
+ * (Linux rungs forced off, so `sandbox-exec`/Seatbelt confines) underneath
+ * the REAL `SandboxBashExecutor`, driven through the executor's public
+ * run/start paths. Verifies the WORLD (files exist or don't) plus the
+ * stamped result facts — in particular that Seatbelt's EPERM denial text
+ * classifies as `denied: true` through the wrap-carried dialect; the
+ * backend-only confinement proofs live with `@deepseek-ai/dsh-sandbox-local`.
+ *
+ * Self-skips wherever the functional probe fails — every non-macOS host, or
+ * a macOS whose `sandbox-exec` refuses the profile.
+ */
+
+const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
+const seatbeltUsable = probe.status === 0
+
+let ctx: Context | undefined
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ await ctx?.fiber.dispose()
+ ctx = undefined
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+async function tempDir(base: string): Promise {
+ const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-'))
+ tempDirs.push(dir)
+ return dir
+}
+
+async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise {
+ ctx = new Context()
+ await ctx.plugin(LocalSandboxProvider, {})
+ ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
+ await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
+ return ctx.bash as SandboxBashExecutor
+}
+
+describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement through ctx.bash', () => {
+ it('read-only denies a write — the file must NOT exist, and EPERM text classifies as a denial', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
+ expect(result.exitCode).not.toBe(0)
+ expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
+ })
+
+ it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
+ // HOME-based dirs on purpose: workspace-write grants /tmp and the
+ // per-user temp dir wholesale, so only paths outside both prove the
+ // workspace-root boundary.
+ const workdir = await tempDir(homedir())
+ const outside = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'workspace-write')
+
+ const inside = await bash.run(bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
+ expect(inside.exitCode).toBe(0)
+ expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
+ expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
+
+ const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
+ expect(denied.exitCode).not.toBe(0)
+ expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
+ expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
+ })
+
+ it('classifies a background denial once the task settles', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
+ await task.done
+ expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
+ })
+
+ it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
+ const workdir = await tempDir(homedir())
+ const bash = await sandboxedBash(workdir, 'read-only')
+ const command = `printf escalated > ${workdir}/escalated.txt`
+ const strict = await bash.run(bash.resolve({ command }))
+ expect(strict.exitCode).not.toBe(0)
+ expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+ expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
+ const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
+ expect(retried.exitCode).toBe(0)
+ expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
+ expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
+ })
+})
diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json
new file mode 100644
index 0000000000..6dad98d54f
--- /dev/null
+++ b/packages/bash/bash-sandbox/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../util/brand"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../sandbox/sandbox"
+ },
+ {
+ "path": "../../bash/bash"
+ },
+ {
+ "path": "../../bash/bash-local"
+ }
+ ]
+}
diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md
index 39318ae371..e245148e93 100644
--- a/packages/bash/bash/README.md
+++ b/packages/bash/bash/README.md
@@ -2,15 +2,16 @@
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
-This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
+This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
+| `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
-The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
+The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface, tool schemas untouched; a containerized or remote executor slots in the same way.
## Service API (`ctx.bash`)
@@ -19,6 +20,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
| `get(id)` / `list()` | Task lookup. |
+| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
@@ -28,6 +30,8 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
## Vocabulary
-`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
+`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input (the staged escalation and per-session overrides of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md) ride it); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
+
+The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json
index f1bc43c2b4..bfa71d73e3 100644
--- a/packages/bash/bash/package.json
+++ b/packages/bash/bash/package.json
@@ -23,10 +23,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
+ "@deepseek-ai/dsh-sandbox": "^0.0.1",
+ "@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-sandbox": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts
index b629f10e4d..396249d3d7 100644
--- a/packages/bash/bash/src/index.ts
+++ b/packages/bash/bash/src/index.ts
@@ -15,6 +15,7 @@
*/
import { Context, Service } from 'cordis'
+import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
export { BashTaskId, OwnerToken } from './types.ts'
@@ -22,6 +23,7 @@ export type {
BashExecRequest,
BashExecSpec,
BashRunResult,
+ BashSandboxInfo,
BashTask,
BashTaskListener,
BashTaskRead,
@@ -70,6 +72,21 @@ export abstract class BashExecutor extends Service {
}, 'bash listener teardown')
}
+ /**
+ * The sandbox mode this executor confines commands under BY DEFAULT, or
+ * `undefined` when it does not sandbox at all — the capability fact the
+ * tool layer reads to advertise escalation honestly (a mode-widening lever
+ * is only offered when a sandboxing executor is mounted to honor it, and
+ * only for modes strictly wider than this one). Composition truth, not
+ * configuration: the base class reports `undefined`; a sandboxing
+ * implementation overrides the getter with its configured mode.
+ * @returns the configured default mode of a sandboxing executor;
+ * `undefined` for an executor that never confines.
+ */
+ get sandboxMode(): SandboxMode | undefined {
+ return undefined
+ }
+
/**
* Resolve a caller's {@link BashExecRequest} into a fully-specified
* {@link BashExecSpec}, applying this implementation's config defaults and
diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts
index 4715ace318..39dbc162c6 100644
--- a/packages/bash/bash/src/types.ts
+++ b/packages/bash/bash/src/types.ts
@@ -7,6 +7,7 @@
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Identifies one background task within an executor (generated `bash-N`). */
export type BashTaskId = Branded<'BashTaskId'>
@@ -40,6 +41,48 @@ export function OwnerToken(id: string): OwnerToken {
return id as OwnerToken
}
+/**
+ * Sandbox facts for one foreground run — present on {@link BashRunResult} iff
+ * a sandboxing executor ran the command (an unsandboxed executor reports no
+ * `sandbox` field at all). Reported independently of `exitCode`/`signal`
+ * (orthogonal outcomes), so a caller can tell "the command failed on its own"
+ * from "the sandbox blocked a file operation". The mode/enforcement
+ * vocabulary lives on the `@deepseek-ai/dsh-sandbox` seam; this shape is the
+ * bash seam's result-fact carrier for it.
+ */
+export interface BashSandboxInfo {
+ /** The mode the command actually ran under. */
+ mode: SandboxMode
+ /**
+ * True when the executor classifies this run's failure as the sandbox
+ * denying a file operation. The classification is CONSERVATIVE (a failed
+ * exit whose stderr carries a filesystem-permission signature) and reads
+ * the COLLECTED stderr — the bounded in-memory tail per
+ * {@link CollectedOutput} semantics, so a signature that survives only in a
+ * spill file is missed toward `denied: false`. A plain command failure
+ * keeps `denied: false` even under a sandboxed mode.
+ */
+ denied: boolean
+ /**
+ * How completely the runner enforced `mode`'s file effects — see
+ * {@link SandboxEnforcement}. Absent exactly when `mode` is
+ * `danger-full-access`: nothing is confined, so there is no enforcement to
+ * report.
+ */
+ enforcement?: SandboxEnforcement
+ /**
+ * True when the executor classifies this failure as the SANDBOX RUNNER
+ * itself failing (missing binary, refused profile, fail-closed refusal
+ * before exec) — the command NEVER RAN; this is a sandbox failure, not a
+ * task failure, and it outranks `denied` (a runner's own error text can
+ * contain denial words). Only ever stamped on settled BACKGROUND tasks: a
+ * foreground run surfaces the same condition as the thrown
+ * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error
+ * channel; a settled task's facts are its only channel).
+ */
+ runnerFailed?: boolean
+}
+
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link BashExecutor.resolve} from the implementation's config.
@@ -81,6 +124,20 @@ export interface BashExecRequest {
* ownerless background start (a non-agent caller).
*/
owner?: OwnerToken | undefined
+ /**
+ * Explicit per-call sandbox-policy input, overriding the executor's
+ * configured default mode for THIS call. Never a silent default: a
+ * consumer sets it only from an explicit policy source — an
+ * `'allowed-once'` grant a human just issued through `ctx.approval` (the
+ * escalation flow in the sandbox RFC § Escalation, which outranks), or the
+ * session's standing override folded from its own `bash/sandbox-mode`
+ * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
+ * choice). A sandboxing executor confines THIS call under the given mode;
+ * a non-sandboxing executor carries the field and confines nothing (the
+ * tool layer stamps neither escalation nor overrides without a sandboxing
+ * executor — see {@link BashExecutor.sandboxMode}).
+ */
+ sandboxMode?: SandboxMode | undefined
}
/**
@@ -122,6 +179,16 @@ export interface BashExecSpec {
* task. `start()` stores it; `run()` (foreground) ignores it.
*/
owner: OwnerToken | undefined
+ /**
+ * The sandbox mode this call executes under, REQUIRED-but-nullable for the
+ * same visibility reason as `owner`. A sandboxing executor's `resolve()`
+ * stamps the effective mode (the request's explicit override, else its
+ * configured default) so `run()`/`start()` read the spec, never the config;
+ * a non-sandboxing executor carries the request value through verbatim and
+ * ignores it (`undefined` under such an executor means what its README says:
+ * unconfined execution).
+ */
+ sandboxMode: SandboxMode | undefined
}
/** One captured stream: the (possibly truncated) text plus recovery info. */
@@ -148,6 +215,12 @@ export interface BashRunResult {
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
+ /**
+ * Sandbox facts, present iff a sandboxing executor ran the command — an
+ * unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
+ * {@link BashSandboxInfo} for the `denied` classification semantics.
+ */
+ sandbox?: BashSandboxInfo
}
/** Lifecycle of a background task. */
@@ -164,6 +237,16 @@ export interface BashTask {
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects). */
readonly done: Promise
+ /**
+ * Sandbox facts for this task's execution, stamped by a sandboxing executor
+ * once the task settles and BEFORE completion listeners are notified — an
+ * `onTaskDone` consumer and a `done` awaiter both see it. Denial
+ * classification runs against the settled task's collected stderr, so the
+ * field cannot exist earlier: absent while the task is running and under an
+ * executor that does not sandbox. See {@link BashSandboxInfo} for the
+ * `denied` semantics.
+ */
+ sandbox?: BashSandboxInfo
}
/** One incremental {@link BashExecutor.readOutput} read. */
diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts
index 81530843ed..94d299f175 100644
--- a/packages/bash/bash/tests/service.spec.ts
+++ b/packages/bash/bash/tests/service.spec.ts
@@ -15,6 +15,7 @@ class StubExecutor extends BashExecutor {
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
+ sandboxMode: request.sandboxMode,
}
}
@@ -96,6 +97,11 @@ describe('BashExecutor service seam', () => {
expect(result.exitCode).toBe(0)
})
+ it('reports no default sandbox mode (composition truth: the base never confines)', async () => {
+ const { bash } = await setup()
+ expect(bash.sandboxMode).toBeUndefined()
+ })
+
it('onTaskDone delivers completions to registered listeners', async () => {
const { bash } = await setup()
const seen: string[] = []
diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json
index 342f636170..13d297a292 100644
--- a/packages/bash/bash/tsconfig.json
+++ b/packages/bash/bash/tsconfig.json
@@ -16,6 +16,12 @@
},
{
"path": "../../util/brand"
+ },
+ {
+ "path": "../../sandbox/sandbox"
+ },
+ {
+ "path": "../../core/session"
}
]
}
diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md
index eabb9298aa..b5f6680717 100644
--- a/packages/bash/tool-bash/README.md
+++ b/packages/bash/tool-bash/README.md
@@ -4,7 +4,7 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
-The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
+The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Under a sandboxing executor it additionally contributes the per-agent `env:bash-sandbox` section (order 110) stating each session's EFFECTIVE mode, and the pre-step narrator — see [Per-session mode](#per-session-mode-switching-and-visibility).
## Tools
@@ -17,14 +17,16 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
+| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the strictly wider mode a denied command needs (`read-only` offers `workspace-write`/`danger-full-access`; `workspace-write` offers `danger-full-access`; nothing above `danger-full-access`, so the fields vanish). |
+| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
-Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: ]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
+Result text: stdout, then a `[stderr]` section, then status markers — `[sandbox: file access denied under mode]` when a sandboxing executor classified the failure as a policy denial (reported first so `[exit code: N]` stays the last line; the static description tells the model a denial is policy, not a command bug, and forbids retrying around it), `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: ]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
### `bash_output`
-`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
+`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). A settled task classified as a sandbox denial carries the same `[sandbox: file access denied under mode]` marker on every read that sees it (denials are only classifiable once the whole stderr has been collected). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
### `bash_kill`
@@ -46,6 +48,9 @@ When a background task finishes, a short notice is injected into the owning agen
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
-## Permissions
+## Permissions and escalation
+
+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.
-`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json
index d8836d6a21..b08ccd7b74 100644
--- a/packages/bash/tool-bash/package.json
+++ b/packages/bash/tool-bash/package.json
@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
+ "@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
@@ -34,8 +35,10 @@
"@deepseek-ai/dsh-agent-loop": "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-session": "workspace:^",
+ "@deepseek-ai/dsh-sandbox": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-local": "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 d4a3105165..9544858c9e 100644
--- a/packages/bash/tool-bash/src/index.ts
+++ b/packages/bash/tool-bash/src/index.ts
@@ -30,10 +30,15 @@
* completion landing during the reload gap still drops its one notice — the
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
*
- * TODO(permissions): commands run with the executor's full authority. The
- * permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
- * sandboxing `BashExecutor` implementations — see docs/architecture.md
- * § Extending The Harness.
+ * 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).
*
* @module @deepseek-ai/dsh-tool-bash
*/
@@ -115,6 +120,12 @@ export function renderResult(result: BashRunResult): string {
if (body.length === 0) body = '(no output)'
const markers: string[] = []
+ // The sandbox marker precedes the exit-status markers so `[exit code: N]`
+ // stays the LAST line (exitStatus() anchors its parse there). Denial is a
+ // 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]`)
+ }
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
@@ -360,6 +371,7 @@ export function apply(ctx: Context): void {
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`.',
@@ -428,6 +440,17 @@ export function apply(ctx: Context): void {
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
}
text += `\n${statusLine(read.task)}`
+ if (read.task.sandbox?.runnerFailed) {
+ // The sandbox RUNNER itself failed — the command never ran. The
+ // foreground path surfaces this as the structured SANDBOX_UNAVAILABLE
+ // 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.
+ text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
+ }
return Promise.resolve([{ type: 'text', text }])
},
presentCall: args => presentTaskCall('Read output from', args),
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index 7d4b34f74f..2af987e941 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -1,4 +1,4 @@
-import { mkdtempSync } from 'node:fs'
+import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -11,11 +11,20 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+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 * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
+// Pure-config passthrough runner (same knob the snapshot tier uses): skips the
+// profile args up to `--` and execs the command unconfined — deterministic
+// without a host bwrap.
+const PASSTHROUGH_RUNNER = ['bash', '-c', 'while [ "$1" != "--" ]; do shift; done; shift; exec "$@"', 'passthrough-runner']
+
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -100,6 +109,7 @@ class LossyReadBashExecutor extends BashExecutor {
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
+ sandboxMode: request.sandboxMode,
}
}
@@ -894,6 +904,7 @@ describe('the model-facing bash tool builds its request from named args only (no
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
+ sandboxMode: request.sandboxMode,
}
}
run(): Promise {
@@ -970,3 +981,89 @@ describe('the model-facing bash tool builds its request from named args only (no
expect('owner' in request).toBe(true)
})
})
+
+describe('sandbox rendering', () => {
+ const sandboxResult = (denied: boolean, exitCode: number): BashRunResult => ({
+ exitCode,
+ signal: null,
+ timedOut: false,
+ aborted: false,
+ timeoutMs: 1000,
+ stdout: { text: '', truncated: false },
+ stderr: { text: denied ? 'bash: /x: Read-only file system' : 'boom', truncated: false },
+ sandbox: { mode: 'read-only', denied },
+ })
+
+ it('renders a denial marker BEFORE the exit-code marker (the $-anchored parse survives)', () => {
+ const text = renderResult(sandboxResult(true, 1))
+ expect(text).toMatch(/\[sandbox: file access denied under read-only mode\]\n\[exit code: 1\]$/)
+ })
+
+ it('renders no sandbox marker for a plain failure under a sandboxed mode', () => {
+ expect(renderResult(sandboxResult(false, 2))).not.toContain('[sandbox:')
+ })
+
+ it('bash_output reports a settled background denial with the same marker', async () => {
+ 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 })
+ const bash = ctx.bash as SandboxBashExecutor
+ bash.internals = { spillDir }
+ await ctx.plugin(ToolBash)
+ const started = await call(ctx, 'bash', { command: 'echo "x: Permission denied" >&2; exit 1', description: 'test command', run_in_background: true })
+ 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\]$/)
+ })
+
+ it('bash_output reports a settled background RUNNER failure as a sandbox problem, outranking the denial marker', async () => {
+ // A provider whose wrap carries a runner-failure signature: the settled
+ // task's stderr matching it means the sandbox itself broke and the
+ // command never ran — even though the same stderr also carries denial
+ // words (a runner's error text may contain them).
+ class FakeProvider extends SandboxProvider {
+ confine(argv: readonly string[]): ConfinedArgv {
+ return { argv: [...argv], enforcement: 'full', denialSignatures: ['permission denied'], runnerFailureSignatures: ['fake-runner: '] }
+ }
+ }
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(FakeProvider)
+ await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
+ const bash = ctx.bash as SandboxBashExecutor
+ bash.internals = { spillDir }
+ await ctx.plugin(ToolBash)
+ const started = await call(ctx, 'bash', { command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125', description: 'test command', run_in_background: true })
+ 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(/\[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; /)
+ expect(text(read)).toMatch(/this is a sandbox problem, not a command failure\]$/)
+ expect(text(read)).not.toContain('file access denied')
+ })
+
+ it('reports a real denial end-to-end through the shipping sandbox executor', async () => {
+ 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 })
+ const bash = ctx.bash as SandboxBashExecutor
+ bash.internals = { spillDir }
+ await ctx.plugin(ToolBash)
+ const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-tool-bash-denied-')), 'locked')
+ mkdirSync(lockedDir)
+ 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+\]$/)
+ })
+})
+
diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json
index 89b10bfea8..6f70e81873 100644
--- a/packages/bash/tool-bash/tsconfig.json
+++ b/packages/bash/tool-bash/tsconfig.json
@@ -25,6 +25,15 @@
},
{
"path": "../../bash/bash"
+ },
+ {
+ "path": "../../core/system-prompt"
+ },
+ {
+ "path": "../../approval/approval"
+ },
+ {
+ "path": "../../sandbox/sandbox"
}
]
}
diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts
index 1972a39c99..6f52198a93 100644
--- a/packages/hooks/hook-protocol/tests/runner.spec.ts
+++ b/packages/hooks/hook-protocol/tests/runner.spec.ts
@@ -26,6 +26,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise): {
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
+ sandboxMode: request.sandboxMode,
}
},
async run(spec: BashExecSpec): Promise {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 27302831d6..1afe78f76e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -98,6 +98,12 @@ importers:
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
+ '@deepseek-ai/dsh-sandbox':
+ specifier: workspace:^
+ version: link:../../sandbox/sandbox
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -118,6 +124,31 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+ packages/bash/bash-sandbox:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@deepseek-ai/dsh-bash':
+ specifier: workspace:^
+ version: link:../bash
+ '@deepseek-ai/dsh-bash-local':
+ specifier: workspace:^
+ version: link:../bash-local
+ '@deepseek-ai/dsh-sandbox':
+ specifier: workspace:^
+ version: link:../../sandbox/sandbox
+ '@deepseek-ai/dsh-sandbox-local':
+ specifier: workspace:^
+ version: link:../../sandbox/sandbox-local
+ cordis:
+ specifier: ^4.0.0-rc.6
+ version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+ node-addon-landlock-run:
+ specifier: 0.0.0-test.0
+ version: 0.0.0-test.0
+
packages/bash/tool-bash:
devDependencies:
'@deepseek-ai/dsh-agent':
@@ -132,12 +163,18 @@ importers:
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../bash-local
+ '@deepseek-ai/dsh-bash-sandbox':
+ specifier: workspace:^
+ version: link:../bash-sandbox
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
- '@deepseek-ai/dsh-session':
+ '@deepseek-ai/dsh-sandbox':
specifier: workspace:^
- version: link:../../core/session
+ version: link:../../sandbox/sandbox
+ '@deepseek-ai/dsh-sandbox-local':
+ specifier: workspace:^
+ version: link:../../sandbox/sandbox-local
'@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 dd6c6fb67d..ae52bdb307 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -156,9 +156,9 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'bash',
title: 'Bash executor seam',
mode: 'seam',
- implementations: ['bash-local'],
+ implementations: ['bash-local', 'bash-sandbox'],
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
- note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
+ note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
},
{
key: 'sandbox',
@@ -166,7 +166,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Process-sandbox seam',
mode: 'seam',
implementations: ['sandbox-local'],
- consumers: [],
+ consumers: ['bash-sandbox'],
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
},
{
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 881ce06578..d1481c24ba 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -59,6 +59,8 @@
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
+ { "doc": "docs/core-data-structures/bash.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
+ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
diff --git a/tsconfig.build.json b/tsconfig.build.json
index c6c814c221..f5015b0906 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -21,6 +21,7 @@
{ "path": "./packages/approval/approval" },
{ "path": "./packages/sandbox/sandbox" },
{ "path": "./packages/sandbox/sandbox-local" },
+ { "path": "./packages/bash/bash-sandbox" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/core/tools" },
diff --git a/tsconfig.json b/tsconfig.json
index cd458490f7..59d9c3598a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -32,6 +32,7 @@
{ "path": "./packages/approval/approval" },
{ "path": "./packages/sandbox/sandbox" },
{ "path": "./packages/sandbox/sandbox-local" },
+ { "path": "./packages/bash/bash-sandbox" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/core/tools" },