Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks

# Conflicts:
#	docs/architecture.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/bash.md
#	docs/event-producer-consumer.md
#	docs/rfc/implemented/feature/2026-07-06-sandbox.md
#	docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
#	examples/AGENTS.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md
#	examples/coding-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/bash/bash-local/README.md
#	packages/bash/bash-local/src/index.ts
#	packages/bash/bash-local/tests/executor.spec.ts
#	packages/bash/bash-sandbox/README.md
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/README.md
#	packages/bash/bash/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-core/README.md
#	packages/core/agent-core/src/index.ts
#	packages/subagent/subagent/README.md
#	packages/subagent/tool-subagent/README.md
#	packages/subagent/tool-subagent/src/index.ts
#	packages/ui/acp-agent/README.md
#	packages/ui/acp/README.md
#	packages/ui/stdio-agent/README.md
#	packages/util/brand/src/index.ts
#	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-07-14 18:05:46 +08:00
669 changed files with 10081 additions and 13745 deletions

View File

@@ -9,4 +9,4 @@ The canonical three-package capability seam (see [capability seams](../../docs/r
| `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` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
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/)).
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 [the acp-agent example's default composition](../../examples/acp-agent/)).

View File

@@ -25,6 +25,16 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Sandboxing
## Model Experience
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/implemented/feature/2026-07-06-sandbox.md)); per-call allow/deny/ask policy belongs on the `tools/pre-execute` gate.
Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
## Known Limitations and Deferred Work
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them.
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.

View File

@@ -143,11 +143,9 @@ export class LocalBashExecutor extends BashExecutor {
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our
// timeout cut the command short; any other abort — an upstream cancel, or a
// foreign (outer) deadline's timeout under nesting — is aborted. Scoping to
// our own code keeps a nested outer deadline from reading as our timeout.
// Mutually exclusive by construction — the fused signal reports one cause.
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our timeout cut the
// command short; any other abort — an upstream cancel, or a foreign (outer) deadline's
// timeout under nesting — is aborted.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }

View File

@@ -1,23 +1,7 @@
/**
* Process plumbing for the local bash executor: spawn, output collection
* with tail-keep + spill-to-disk truncation, and process-group kill with
* SIGTERM→SIGKILL escalation.
*
* Everything here is deliberately free of Cordis concepts so it can be unit
* tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
*
* runBash owns NO timing: it kills the process group when its `spec.signal`
* fires and does not distinguish a timeout from a cancel. The executor fuses
* timeout + upstream cancellation into that one signal via
* `@deepseek-ai/dsh-timeout`'s `deadline`, and classifies the outcome from the
* signal afterward — the timing/classification half is shared, the kill is not.
*
* Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
* the package README): spawn-per-call with `detached: true` so the child
* leads its own process group; kills target the group (`kill(-pid)`) so
* pipelines and subshells die with the parent. SIGTERM first, SIGKILL after a
* grace period (OpenCode's escalation; Codex/pi jump straight to SIGKILL).
*
* Process plumbing for the local bash executor: detached process-group spawn,
* tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer
* reacts to an abort signal; the executor owns deadlines and classifies causes.
* @module dsh-bash-local/run
*/
@@ -50,18 +34,9 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* `process.env` minus credential-shaped vars, plus the model-friendly
* overrides, plus any caller-supplied `extra` entries.
* Build a child environment by scrubbing credential-shaped ambient variables,
* applying model-friendly overrides, then merging trusted caller entries last.
*
* Layering matters: the scrub drops `process.env` credentials, then
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
* merged LAST so an explicit caller entry wins even when its name matches the
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
* credentials leaking into a spawned command; a caller that explicitly sets a
* var named a value it already holds, not that ambient secret). `extra` is set
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
* builds its request from named fields only and does not forward model input
* here (see its README, § "The tool builds its request from named args only").
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
* @returns the environment to hand to `spawn` for the child process.
*/
@@ -262,10 +237,8 @@ export class OutputCollector {
try {
closeSync(this.spillFd)
} catch {
// close can surface delayed writeback failures (for example EIO/ENOSPC)
// after writeSync appeared to succeed. Keep finalize total so runBash's
// close handler still resolves, but stop advertising a spill file that
// may be missing its tail.
// A delayed writeback failure makes the spill unreliable; keep finalize
// total but stop advertising that file.
this.spillFile = undefined
}
this.spillFd = undefined
@@ -275,13 +248,9 @@ export class OutputCollector {
}
/**
* Send `sig` to the process GROUP led by `pid` (requires the child to have
* been spawned with `detached: true`). NEVER throws: kills race process exit
* by design (ESRCH), and the other failure modes (EPERM from setuid
* children, …) fire inside timer callbacks where a throw would crash the
* host process — a kill that cannot be delivered is reported by the process
* NOT dying, which callers already handle via escalation/timeouts. No-op for
* non-positive pids (spawn never started a process).
* Send `sig` to a detached process group. Never throws: delivery races process
* exit and may run in a timer callback, so failures are contained and a
* non-positive pid is a no-op.
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
* @param sig - the signal to deliver to the whole group.
*/
@@ -311,24 +280,13 @@ export interface RunningBash {
}
/**
* Spawn `bash -c <command>` in its own process group and collect output.
*
* Outcome semantics: the returned promise REJECTS only for spawn-level
* failures (bad cwd → ENOENT, missing binary, pre-aborted signal); every
* runtime outcome — nonzero exit, timeout kill, abort kill, signal death —
* RESOLVES with a {@link SpawnOutcome} describing what happened, so callers
* shape one consistent report for the model.
*
* XXX(stateful-shell): per the agent-tool survey there are two proven
* stateful designs worth revisiting — Claude Code persists ONLY cwd between
* calls (captures `pwd -P` after each command), and Codex keeps whole PTY
* exec sessions addressable via session ids + stdin writes. We deliberately
* spawn a fresh non-login `bash -c` per call for determinism (no rc files,
* no inherited shell state); revisit when real workflows demand it.
* @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here.
* @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir.
* @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`.
* Spawn one isolated `bash -c` process group and collect its output.
* Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
* @param spec - fully resolved command, cwd, limits, and cancellation.
* @param internals - test-only process and spill-directory overrides.
* @returns live process handle and outcome promise.
*/
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
const spillDir = internals.spillDir ?? privateSpillDir()
@@ -336,16 +294,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
// stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
// (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
// and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
// socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
// /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
// (every model-driven call) must keep /dev/null rather than regress to a socket.
// Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
// typed `spawn` overload infer non-null stdout/stderr, which the
// `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
// stderr the non-null `Readable` the collectors attach to without a cast).
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
@@ -358,8 +307,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
let graceTimer: NodeJS.Timeout | undefined
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
// the 'error' handler rejects `done` and kills become no-ops via pid -1.
// Failed spawns use pid -1 so kill remains a no-op.
const pid = child.pid ?? -1
const kill = (): void => {
@@ -368,27 +316,11 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
// runBash owns no timer: the executor's `run()` fuses timeout+cancel into one
// deadline signal (`@deepseek-ai/dsh-timeout`) and passes it here; we only
// listen and run the SIGTERM→grace→SIGKILL kill. Whether the abort was a
// timeout or an upstream cancel is classified by the executor from that
// signal, not tracked here.
// The executor owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
// stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
// handler must exist whenever we write: an unhandled 'error' on the stream
// would throw and crash the host. We swallow the error rather than reject
// `done`, and that is correct for ANY stdin-write error, not just the common
// one — the stdin write is BEST-EFFORT, while the command's authoritative
// outcome is its exit code + captured output, which the `close` handler reports
// regardless of whether the write landed. The expected case is EPIPE (the child
// exited without reading, so closing our end of a still-full pipe fails); a rare
// non-EPIPE pipe fault means the command ran with incomplete stdin, and it
// surfaces that itself through its own exit/output (e.g. a hook that gets
// truncated JSON errors out) — rejecting here would instead discard that real
// output and turn it into an opaque infrastructure error, which is worse.
// Stdin writes are best-effort; process exit and captured output remain authoritative.
if (child.stdin !== null) {
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
child.stdin.end(spec.stdin)
@@ -396,8 +328,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
const done = new Promise<SpawnOutcome>((resolve, reject) => {
child.on('error', (error) => {
// Spawn-level failure (ENOENT cwd, EACCES, …): no close event with
// meaningful output follows; clean up and reject.
// No meaningful close outcome follows a spawn failure.
cleanup()
reject(error)
})

View File

@@ -189,12 +189,8 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// The no-stdin path must stay observationally identical to the pre-seam
// `ignore` default: a command that probes stdin's file type sees a char
// device (/dev/null). Regressing to an always-open pipe would make fd 0 a
// socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
// `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
// fd 0 is that pipe (a socket), as it must be to carry them.
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
expect(none.stdout.text).toBe('char\n')
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
@@ -219,9 +215,8 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits immediately without reading; closing our end of a stdin
// pipe still holding ~1MiB triggers EPIPE on the write. The handler must
// swallow it: `done` resolves normally with the child's real exit.
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
// The handler swallows that write error and `done` reports the child's real exit.
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
@@ -360,7 +355,7 @@ describe('abort edge cases', () => {
})
})
describe('review fixes: env scrubbing and spill hardening', () => {
describe('environment and spill-file hardening', () => {
it('scrubs credential-shaped env vars from child processes', async () => {
process.env.DSH_TEST_API_KEY = 'super-secret'
process.env.DSH_TEST_TOKEN = 'also-secret'

View File

@@ -1,6 +1,6 @@
# @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.
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/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
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.
@@ -30,4 +30,31 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
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.
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 [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
## Model Experience
### Bash tool schema, indirectly
**What the model sees**: The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated.
**Token effect**: Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens.
### Bash tool result, indirectly
**What the model sees**: After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under <mode> mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`.
**Token effect**: Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction.
### Bash tool error, indirectly
**What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
**Token effect**: Conditional error text is visible for that call and retained in history until compaction.
## Known Limitations and Deferred Work
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -1,43 +1,9 @@
/**
* `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/implemented/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 its session's standing mode (or the configured
* default when that session has no override).
*
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
* @module @deepseek-ai/dsh-bash-sandbox
*/
@@ -80,24 +46,11 @@ export function shellQuote(text: string): string {
}
/**
* 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).
* Conservatively classify a nonzero, non-signal run using only the selected
* backend's denial signatures. Text inference may miss a denial or match
* unrelated stderr in that dialect; it never uses another backend's terms.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's denial dialect, case-insensitive
* stderr substrings.
* @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 {
@@ -105,17 +58,9 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* 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).
* Classify a nonzero run using the selected backend's runner-failure
* signatures. Callers check this before denial because runner diagnostics may
* contain denial words; the command did not run.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's runner-failure signatures,
* case-insensitive stderr substrings.
@@ -138,15 +83,11 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r
}
/**
* 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). Its configured mode is the
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
* request, while an approved escalation may stamp a strictly wider mode for
* one call. The tool's per-agent prompt section states that same effective
* mode, and each run's `result.sandbox` reports what actually executed plus
* enforcement completeness.
* Registers as `ctx.bash` in place of the local executor and requires a
* `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
* the fallback, while a session override or approved one-shot escalation may
* select each call's mode. The prompt does not state the standing mode;
* `result.sandbox` reports the mode and enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox']
@@ -165,12 +106,12 @@ export class SandboxBashExecutor extends LocalBashExecutor {
private readonly workspaceRoot: string
/**
* Per-process facts, keyed by handle from `start()` until the settle stamp
* consumes them: the mode the process runs under (per-call — an escalated task
* consumes them: the mode the process runs under (per-call — an escalated process
* 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
* calls — so overlapping background processes 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
* an earlier process's facts before it settles. A `danger-full-access` process
* has NO entry (nothing confined it), which is what the settle stamp keys
* off.
*/
@@ -216,11 +157,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
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.
// Runner failure outranks denial because the command did not run. Throw the
// same fail-closed error as confine-time discovery with the first stderr line.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
}
@@ -244,17 +182,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
/**
* 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.
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: BashProcess, running: RunningBash): void {
const facts = this.processFacts.get(proc)

View File

@@ -9,20 +9,13 @@ import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox
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`.
* Keyless integration of the real provider and executor through public run/start paths. With
* no rung forced, a passing bwrap probe selects the ladder's first rung. The tests check world
* effects and stamped facts, including EROFS classification through the wrap-carried dialect;
* backend-only confinement is covered by `@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.
* Skips when bwrap or unprivileged user namespaces are unavailable. HOME-based paths are
* intentional because bwrap replaces `/tmp`, which cannot prove the workspace-root boundary.
*/
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })

View File

@@ -1,11 +1,8 @@
/**
* 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.
* Consumer-side `SandboxBashExecutor` tests. A fake Cordis sandbox service makes wrapping,
* policy hand-off, fail-closed propagation, classification, and fact stamping deterministic;
* real-provider integration lives in `tests/landlock.e2e.ts`. A mode-0555 directory supplies
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
*/
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
@@ -291,10 +288,9 @@ describe('background sandbox facts', () => {
})
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.
// Facts belong to each wrap and may vary between calls. The slow task settles after the
// quick task starts; a shared latest-wrap field would classify and stamp it with the wrong
// task's dialect and enforcement.
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
{ enforcement: 'full', denialSignatures: ['read-only file system'] },

View File

@@ -9,16 +9,11 @@ import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sand
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.
* Keyless macOS integration of the real provider and executor through public run/start paths.
* Linux rungs are forced off so Seatbelt is selected. The tests check world effects and stamped
* facts, including EPERM classification through the wrap-carried dialect; backend-only
* confinement is covered by `@deepseek-ai/dsh-sandbox-local`. Skips off macOS or when
* `sandbox-exec` rejects the profile.
*/
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })

View File

@@ -11,7 +11,7 @@ This package is the interface quarter of the bash capability, split so each conc
| `@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. `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.
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; the consumer detects its `sandboxMode` capability and adds escalation fields without importing the implementation. A containerized or remote executor slots in the same way.
## Service API (`ctx.bash`)
@@ -31,4 +31,13 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
`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).
`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; a missing value means "none". See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Model Experience
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
## Known Limitations and Deferred Work
- **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept.
- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).

View File

@@ -80,12 +80,7 @@ export abstract class BashExecutor extends Service {
}
/**
* Resolve a caller's {@link BashExecRequest} into a fully-specified
* {@link BashExecSpec}, applying this implementation's config defaults and
* caps (working directory, default/max timeout). Consumers (tool layer)
* call this, then pass the result to {@link run}/{@link start} — keeping
* defaulting in the implementation that owns the config while the seam type
* stays explicit (no hidden `?? default` inside run/start).
* Apply implementation-owned defaults and caps to a request before execution.
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.

View File

@@ -1,17 +1,9 @@
/**
* Per-session sandbox-mode override: the session log as the store. A runtime
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
* one `bash/sandbox-mode` event on the session it applies to;
* `effective = fold(events) ?? the executor's configured default`, so an
* override survives restart by replay, two sessions can never see each
* other's state, and there is no external config store. The event is
* log-only (the `approval/*` precedent): the model learns the mode from the
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
* never from the event itself. EXECUTION honors the fold in the tool layer —
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
* (weakest-precedence: an escalation grant for the call outranks it) — the
* executor itself stays a config-fixed default plus per-call overrides.
*
* Per-session sandbox-mode override stored as log-only events. Folding the log
* isolates sessions and survives replay; the tool stamps the override onto
* each call unless an approved one-shot escalation outranks it, and the
* executor default applies when neither exists. The model receives neither the
* event nor a standing-mode notice; denial results name the effective mode.
* @module dsh-bash/session-mode
*/
@@ -21,12 +13,9 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session's sandbox mode was switched — log-only (like `approval/*`;
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
* never in the model transcript. The LAST such event is the session's
* override ({@link effectiveSandboxMode}); who asked for it is derivable
* from position (an event after the log's last `request/header*` was a
* runtime switch by the user; see the tool layer's narrator).
* Durable log-only sandbox-mode override; never a surface event or model
* message. Execution and ACP option reporting fold the latest event through
* {@link effectiveSandboxMode} without adding a prompt notice.
*/
'bash/sandbox-mode': { mode: SandboxMode }
}
@@ -37,9 +26,8 @@ export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-wr
/**
* The session's sandbox-mode override: the last `bash/sandbox-mode` event in
* the log, or undefined when the session never switched (callers apply the
* executor's configured default). The pure fold — resume needs no catch-up
* machinery because replaying the log IS the state.
* the log, or undefined when the session never switched and callers should use
* the executor default. Replay needs no separate catch-up state.
* @param events - session events in log order (other event types are skipped).
* @returns the mode of the last switch event, or undefined without one.
*/
@@ -52,10 +40,9 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo
}
/**
* THE write path for a session's sandbox-mode override: appends exactly one
* `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode
* state out of band. Takes effect on the session's next bash call and next
* prompt assembly (the consumers fold on every read).
* Append one `bash/sandbox-mode` event as the only override write path.
* Execution and ACP option reporting fold it on read; prompt assembly does not
* consume it.
* @param session - the session the override belongs to.
* @param mode - the mode every subsequent bash call in this session runs
* under (until the next switch).

View File

@@ -22,7 +22,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
`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 — `[sandbox: file access denied under <mode> 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: <path>]` 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 contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
@@ -38,8 +38,52 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted i
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).
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../ui/user-approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
## Per-session mode switching
Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state.
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
**Token effect**: Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches.
#### Bash guidance
```markdown
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
```
### Tool schemas
**What the model sees**: The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
### Foreground result
**What the model sees**: The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: <path-or-(unavailable)>]`, `[sandbox: file access denied under <mode> mode]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md).
**Token effect**: Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
### Background task context and results
**What the model sees**: Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
**Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
### Tool errors
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
## Known Limitations and Deferred Work
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -115,7 +115,17 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ background
if (escalationModes.length === 0) return base
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. The approval prompt raised by that retry is how the user consents. Never escalate speculatively, and treat a rejected escalation as final for that command.'
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
+ 'A rejected escalation is final for that command — stop and explain, never work around '
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
@@ -125,10 +135,10 @@ function streamText(output: CollectedOutput): string {
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* Shape one finished run into model-visible stdout, marked stderr, and status
* facts. Non-zero exits and sandbox denials remain ordinary results; only
* infrastructure failure or abort makes the tool call itself fail.
*
* @param result - the completed foreground run from the executor.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the model-facing text: output body (or `(no output)`), then any sandbox/timeout/signal/exit markers, each on its own line.
@@ -259,8 +269,7 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
content: [{ type: 'text', text: args.description }],
}
}
// A foreground run IS a terminal: the command titles the card, the description
// renders above it, and the cwd (when the model gave a workdir) heads it.
// A foreground run is a terminal; an explicit workdir supplies its cwd.
return {
card: 'terminal',
title: args.command,
@@ -270,26 +279,8 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* return a `generic` result whose content is the fenced ```console block. A
* finished foreground run returns a `terminal` result carrying the RAW output
* and the parsed exit status; the BRIDGE derives the fenced fallback from
* `output` for a UI without terminal support, so the tool does not double-encode
* it. A non-text result (unexpected for bash) falls through to `undefined`.
* Present completed foreground output as a terminal; background acknowledgements
* and execution errors use generic fenced output without an exit-status pill.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
@@ -301,35 +292,14 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
// A finished foreground run: RAW output + parsed exit for the terminal card.
// A finished foreground run supplies raw output and parsed exit status.
// The bridge derives the no-capability fenced fallback from `output`.
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
*
* Why parse rendered text at all: `presentResult` is replay-safe and on a
* `session/load` the ONLY thing persisted is this content text — the structured
* `BashRunResult` is long gone — so unless the exit were added to the persisted
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
* is the only channel. The match is anchored to a LEADING newline + end-of-string
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
* a non-empty body: a real marker is therefore always its own final line. That
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
*
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
* whose body's FINAL line is itself exactly the marker text — `[exit code: N]`
* or `[killed by signal: SIG]`, printed by the program with nothing after — is
* still indistinguishable from a real marker and would show a wrong pill. This is
* display-only (execution and the model-facing text are unaffected) and narrow;
* the complete fix is to persist a structured exit on the result event, which the
* RFC names as the escape hatch.
* Recover exit status from the final marked line emitted by {@link renderResult}.
* A program whose own final line exactly mimics a marker remains ambiguous for UI display.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
@@ -340,15 +310,8 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string
}
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
* so a relative one should be relative to the session's root, not `process.cwd()`).
* Returns `undefined` when neither is available (no agent / headerless session /
* no session cwd) — the executor then applies its own config/`process.cwd()`
* default, preserving today's non-ACP behavior.
* Resolve an explicit workdir first, making a relative one session-cwd-relative;
* otherwise use the session cwd and leave executor defaulting as the fallback.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd

View File

@@ -860,11 +860,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
const ctx = await setup()
const args = { command: 'printf "[exit code: 5]"', description: 'print' }
// A successful command can print text that looks like a marker. renderResult
// for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
// own tail is `[exit code: 5]`. The parse requires a LEADING newline before
// the marker (renderResult always inserts one before a REAL marker), so this
// no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
// A successful command may print marker-like text. A clean result appends no marker or
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
// Same for a fake signal marker with no leading newline.
@@ -919,10 +916,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
const ctx = await setup()
// defineTool wraps presentCall to soft-validate against the schema and fall
// back to undefined (a generic UI presentation) rather than throwing on the
// display path — it may run on replay of arbitrary logged args. The
// ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
// `defineTool` soft-validates replayed logged args before presentation. Invalid shapes return
// undefined for generic UI rendering rather than throwing; `presentCall` accepts `unknown`.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
})
})
@@ -987,12 +982,9 @@ describe('the model-facing bash tool builds its request from named args only (no
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Extra args: the model includes `env` and `stdin` keys hoping they reach the
// executor. The bash tool's schema ignores unknown keys, and execute() builds
// the request from only command/workdir/timeoutMs/signal — so the recorded
// request carries NEITHER. (Not a security wall — the model could set an env
// var or feed stdin via shell syntax anyway; this just keeps the request
// shape honest so a future `...args` spread can't silently forward input.)
// Unknown `env` and `stdin` keys are ignored by the schema and named request construction.
// This preserves the request shape; it is not a security boundary because shell syntax can
// already set environment variables or feed stdin.
await ctx.tools.execute({
callId: CallId('no-forward-1'),
name: 'bash',