mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'codex/simp-prune-tools-prompt-surface' into codex/simp-drop-assembled-section-order
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md
This commit is contained in:
@@ -22,7 +22,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
|
||||
- **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).
|
||||
- **Model-friendly environment** — ambient credential-shaped variables are removed before noninteractive terminal defaults and explicit caller entries are applied. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. Trusted plugins use `env` and `stdin`, but the model-facing tool does not expose them. See the [bash stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
|
||||
|
||||
## Model Experience
|
||||
@@ -37,6 +37,5 @@ Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdou
|
||||
- **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.
|
||||
- **Finished background tasks are never evicted** — they stay in the task map, retaining their in-memory output tails, until executor disposal.
|
||||
- **`OutputCollector.snapshot()` / `totalBytes` are test-shaped residuals** — the live poll path uses `readFrom()` and a marked cleanup can inline the final snapshot and remove the unused public getter.
|
||||
|
||||
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
/**
|
||||
* `LocalBashExecutor`: the local-subprocess implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
|
||||
* own process group (see `./run.ts` for the plumbing and the agent-tool
|
||||
* survey notes), tracks background tasks, and kills everything on dispose.
|
||||
*
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — use
|
||||
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
|
||||
* § Extending The Harness) or implement a sandboxing `BashExecutor`.
|
||||
* Reference points:
|
||||
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
|
||||
* seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
*
|
||||
* Local-subprocess implementation of the bash seam. Each call runs in its own
|
||||
* process group, background tasks are tracked, and disposal kills and awaits
|
||||
* them. Execution policy belongs in `tools/pre-execute` or a sandboxing
|
||||
* executor, not this local process layer.
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
@@ -90,10 +82,9 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
ctx.effect(() => async () => {
|
||||
// Kill every live process group and WAIT for the processes to close so
|
||||
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
||||
// held until the SIGKILL escalation lands. The base class already
|
||||
// silenced listeners, so these kills complete without notices.
|
||||
// Kill every live process group and WAIT for the processes to close so nothing outlives
|
||||
// the fiber (HMR safety) — a TERM-trapping child is held until the SIGKILL escalation
|
||||
// lands.
|
||||
const pending: Promise<void>[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === 'running') {
|
||||
@@ -154,23 +145,18 @@ 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 }
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
// No timeout for background tasks (matches Claude Code, which detaches
|
||||
// the timeout when backgrounding); callers stop tasks via kill() — or
|
||||
// via spec.signal, which the seam contract honors for background runs
|
||||
// too (runBash wires it to the group kill). No deadline is created here,
|
||||
// so spec.timeoutMs is ignored by design — background tasks stay
|
||||
// timeout-free (see the timeout-library RFC).
|
||||
// No timeout for background tasks (matches Claude Code, which detaches the timeout when
|
||||
// backgrounding); callers stop tasks via kill() — or via spec.signal, which the seam
|
||||
// contract honors for background runs too (runBash wires it to the group kill).
|
||||
const running = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -337,7 +337,7 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: lifecycle hardening', () => {
|
||||
describe('executor cancellation, callback, and disposal contracts', () => {
|
||||
it('start honors a pre-aborted or later-aborted AbortSignal', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -14,7 +14,7 @@ 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/implemented/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.
|
||||
- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. The model learns standing mode only from tool/result facts, not a system-prompt announcement.
|
||||
- **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/).
|
||||
|
||||
@@ -48,7 +48,7 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc
|
||||
|
||||
### Bash tool error, indirectly
|
||||
|
||||
**What the model sees**: If no runner can enforce a confined mode, the foreground call fails with code `SANDBOX_UNAVAILABLE` and the exact message `sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access.` An execution-time runner failure appends ` Runner failure: <first stderr line>`.
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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
|
||||
* tasks carry `runnerFailed`. The tool owns approval and passes per-call modes.
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
@@ -79,24 +45,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 {
|
||||
@@ -104,17 +57,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.
|
||||
@@ -137,15 +82,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 prompt deliberately does not state the mode; each run's
|
||||
* `result.sandbox` reports what actually executed plus enforcement
|
||||
* completeness, and the tool layer renders denial or runner-failure facts.
|
||||
* 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']
|
||||
@@ -163,15 +104,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
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.
|
||||
* Per-task mode and wrap facts retained until settlement. Overlapping tasks
|
||||
* may use different modes or provider facts, so one latest-wrap field would
|
||||
* misclassify earlier completions.
|
||||
*/
|
||||
private readonly taskFacts = new Map<BashTaskId, {
|
||||
mode: ConfinedSandboxMode
|
||||
@@ -215,11 +150,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])
|
||||
}
|
||||
@@ -230,11 +162,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
// 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).
|
||||
// Classification needs settled stderr. Store facts synchronously after
|
||||
// spawn, before the earliest process completion can be observed.
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const task = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
@@ -243,27 +172,16 @@ 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-task sandbox facts before completion listeners and `done` settle.
|
||||
* Full-access tasks have no facts; signal deaths are not denials.
|
||||
*/
|
||||
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.
|
||||
// Runner failure outranks denial. Background settlement has no throw
|
||||
// channel, so this fact is its counterpart to the foreground exception.
|
||||
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
task.sandbox = {
|
||||
mode: facts.mode,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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'
|
||||
@@ -286,10 +283,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'] },
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -32,7 +32,7 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
`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: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); 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 seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. 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.
|
||||
The seam owns per-session sandbox overrides through the log-only `bash/sandbox-mode` event, `effectiveSandboxMode`, and `setSandboxMode`; writers preserve turn enclosure, and replay restores the last override. `BashTaskId` and `OwnerToken` are distinct brands. Foreground `run` returns exit, timeout, cancellation, output, and optional sandbox facts; background `start` and `readOutput` use task records. A sandboxing executor reports the executed mode, conservative denial classification, and enforcement completeness. See [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md) for full shapes.
|
||||
|
||||
`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).
|
||||
|
||||
@@ -43,5 +43,4 @@ Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox fac
|
||||
## 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.
|
||||
- **Two overlapping surfaces flagged for pruning** — `BashTask.done` duplicates `onTaskDone` (shipped consumers use only the latter), and `get()`/`list()` have test-harness consumers only; both are marked in [the long-running-runtime RFC](../../../docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md).
|
||||
- **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).
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
/**
|
||||
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
|
||||
* bash backend does — run commands, manage background tasks — without saying
|
||||
* HOW. Implementations subclass {@link BashExecutor} and register themselves
|
||||
* as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
|
||||
* is the first. Future implementations swap in sandboxes, containers, or
|
||||
* remote exec servers without touching the tool schemas that consume them
|
||||
* (`@deepseek-ai/dsh-tool-bash`).
|
||||
*
|
||||
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
|
||||
* surveyed agents: pi hides execution behind a `BashOperations` interface
|
||||
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
|
||||
*
|
||||
* The bash executor seam (`ctx.bash`): an abstract service defining what a bash backend does —
|
||||
* run commands, manage background tasks — without saying how.
|
||||
* @module @deepseek-ai/dsh-bash
|
||||
*/
|
||||
|
||||
@@ -39,25 +29,11 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract bash execution service. Subclass, implement the abstract methods,
|
||||
* and load the subclass as a plugin — it registers as `ctx.bash` (one
|
||||
* implementation per context; loading a second throws, which is cordis'
|
||||
* standard duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link run} REJECTS only for infrastructure failures (unusable workdir,
|
||||
* missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
|
||||
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
|
||||
* a failed command is the tool layer's job, not an exception.
|
||||
* - {@link start} returns immediately; no timeout applies to background
|
||||
* tasks (callers stop them via {@link kill} or the spec's AbortSignal).
|
||||
* Completion must fire the {@link onTaskDone} listeners exactly once per
|
||||
* task, and must NOT fire after the service is disposed.
|
||||
* - {@link readOutput} is incremental: consecutive reads never re-deliver
|
||||
* output. Implementations bound their buffers; reads that lost data flag
|
||||
* `lossy` and point at full-stream spill files when available.
|
||||
* - Disposal kills every running task and awaits their exit (no orphan
|
||||
* processes survive `fiber.dispose()`).
|
||||
* Registers one `ctx.bash` implementation. Runtime command failures resolve as
|
||||
* {@link BashRunResult}; only infrastructure failures reject. Background starts
|
||||
* return immediately without a timeout, report completion exactly once while
|
||||
* live, and remain cancellable by signal or {@link kill}. Output reads are
|
||||
* incremental and flag lost buffered data; disposal kills and awaits all tasks.
|
||||
*/
|
||||
export abstract class BashExecutor extends Service {
|
||||
private listeners = new Set<BashTaskListener>()
|
||||
@@ -74,14 +50,11 @@ export abstract class BashExecutor extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox mode this executor confines commands under BY DEFAULT, or
|
||||
* `undefined` when it does not sandbox at all — the capability fact the
|
||||
* tool and ACP layers read to advertise sandbox controls honestly. The
|
||||
* getter proves a sandboxing executor is mounted and supplies its fallback
|
||||
* mode; a session override may make the effective mode narrower or wider,
|
||||
* so strict escalation widening is checked per call rather than encoded in
|
||||
* this default-relative capability fact. The base class reports
|
||||
* `undefined`; a sandboxing implementation overrides the getter.
|
||||
* The sandbox mode this executor confines commands under BY DEFAULT, or `undefined` when it
|
||||
* does not sandbox at all — the capability fact the tool and ACP layers read to advertise
|
||||
* sandbox controls honestly.
|
||||
* A session or call may override this default, so widening is evaluated per
|
||||
* execution rather than encoded in this getter.
|
||||
* @returns the configured default mode of a sandboxing executor;
|
||||
* `undefined` for an executor that never confines.
|
||||
*/
|
||||
@@ -90,12 +63,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}.
|
||||
@@ -125,17 +93,10 @@ export abstract class BashExecutor extends Service {
|
||||
abstract get(id: BashTaskId): BashTask | undefined
|
||||
|
||||
/**
|
||||
* The opaque OWNER token recorded for a background task at {@link start}
|
||||
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
|
||||
* OR a known-but-ownerless task. The executor stores and returns the token
|
||||
* verbatim — it never interprets it; the access POLICY (who may read/kill a
|
||||
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
|
||||
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
|
||||
* known-but-unowned into the same `undefined` is fine: the consumer's access
|
||||
* gate treats `undefined` as "open", and a genuinely unknown id then fails
|
||||
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
|
||||
* Storing ownership in the executor (disposed with ITS fiber) — not in the
|
||||
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
|
||||
* The opaque OWNER token recorded for a background task at {@link start} (from the {@link
|
||||
* BashExecSpec}'s `owner`), or `undefined` for an unknown id OR a known-but-ownerless task.
|
||||
* The executor stores the token without interpreting policy; keeping it here
|
||||
* lets ownership survive a consumer-plugin reload.
|
||||
* @param id - the background task id to look up ownership for.
|
||||
* @returns the token recorded at start, verbatim; undefined for an unknown
|
||||
* id or a known-but-ownerless task.
|
||||
|
||||
@@ -1,18 +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 receives neither this event
|
||||
* nor a standing mode statement. `@deepseek-ai/dsh-tool-bash` names the mode
|
||||
* only when it renders a sandbox denial. 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
|
||||
*/
|
||||
|
||||
@@ -22,11 +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}); execution and ACP config-option
|
||||
* reporting fold it without adding prompt text or a context notice.
|
||||
* 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. Subsequent execution and ACP config-option reporting fold
|
||||
* it on read; no prompt assembly consumes it.
|
||||
* 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).
|
||||
|
||||
@@ -71,14 +71,8 @@ export interface BashSandboxInfo {
|
||||
*/
|
||||
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).
|
||||
* The sandbox runner failed before executing the command. Set only on settled
|
||||
* background tasks; foreground runs throw `SANDBOX_UNAVAILABLE` instead.
|
||||
*/
|
||||
runnerFailed?: boolean
|
||||
}
|
||||
@@ -125,17 +119,9 @@ export interface BashExecRequest {
|
||||
*/
|
||||
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}).
|
||||
* Explicit per-call sandbox policy. The tool stamps a session override or a
|
||||
* one-shot approved escalation, with the grant taking precedence. Sandboxing
|
||||
* executors honor it for this call; non-sandboxing executors do not confine.
|
||||
*/
|
||||
sandboxMode?: SandboxMode | undefined
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
|
||||
`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`.
|
||||
|
||||
### `bash_output`
|
||||
|
||||
@@ -34,29 +34,29 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[sandbo
|
||||
|
||||
### Task ownership (cross-session isolation)
|
||||
|
||||
The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a 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.)
|
||||
The executor stores the spawning session id as the task's owner. `bash_output` and `bash_kill` reject a caller with a different session id; agent-less tasks remain unowned, while agent-less calls cannot access owned tasks. Storing ownership on the task prevents predictable global ids from crossing ACP sessions and preserves the fence across tool-plugin reloads. Completion notices remain effect-scoped and may be missed during a reload gap.
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI while the tool keeps model-facing result text unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
UI presentation is tool-owned through `presentCall` and `presentResult`. Foreground `bash` uses a terminal card whose title is the exact command and whose optional description is separate; cwd follows an explicit `workdir`—resolved by the bridge against the session when relative—or the session cwd. Its result carries raw output plus exit or signal data, and clients without terminal support receive a bridge-derived fenced console fallback. Background runs, spawn failures, `bash_output`, and `bash_kill` use generic cards. Presenters are pure and replay-safe; malformed older arguments fall back to generic rendering. See [`dsh-tools`](../../core/tools/) and [`dsh-acp`](../../ui/acp/) for card semantics.
|
||||
|
||||
## Background completion notices
|
||||
|
||||
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
|
||||
When a task finishes, the plugin resolves its owner token to a live agent and injects a durable completion notice. If the owner no longer exists, the notice is dropped. Injection affects the next request but does not wake an idle agent, so the model must poll with `bash_output` when it needs completion promptly.
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
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).
|
||||
The seam supports trusted-plugin `stdin` and `env`, but the model-facing tool does not. It builds requests only from its declared arguments, signal, and owner; extra model keys are ignored. Shell syntax already provides equivalent command-level behavior, while the local executor's credential scrub protects ambient secrets. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## 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).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
/**
|
||||
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
|
||||
* schema + text shaping — every process concern lives behind the `ctx.bash`
|
||||
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
|
||||
* executor implementations swap in without touching what the model sees.
|
||||
*
|
||||
* Background notifications: when a background task completes, a short notice
|
||||
* is injected into the owning agent's session (`agent.inject()` — the
|
||||
* documented context seam). Injection is durable context for the NEXT model
|
||||
* request, not a wake-up: an idle agent stays idle until something sends a
|
||||
* message, which is why the tool descriptions tell the model to poll with
|
||||
* `bash_output`.
|
||||
*
|
||||
* Task ownership: a background task's OWNER is an opaque token — the owning
|
||||
* agent's `session.header.id` — passed to the executor at spawn
|
||||
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
|
||||
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
|
||||
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
|
||||
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
|
||||
* !== caller`); an unowned task (no token — started by a non-agent caller) is
|
||||
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
|
||||
* multi-session ACP (RFC 011) this token check is the fence that stops one
|
||||
* session's agent from reading or killing another session's background task.
|
||||
*
|
||||
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
|
||||
* fiber), rather than in this plugin, is what makes ownership survive a
|
||||
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
|
||||
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
|
||||
* to this plugin's `apply`, so a
|
||||
* 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.)
|
||||
*
|
||||
* 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. Under a sandboxing
|
||||
* executor this plugin also advertises the ESCALATION surface
|
||||
* (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation,
|
||||
* docs/rfc/implemented/feature/2026-07-06-sandbox.md): a command the
|
||||
* sandbox denied may be retried once under a strictly wider mode, resolved
|
||||
* through `ctx.approval` BEFORE anything executes and failing closed on every
|
||||
* unanswerable path. The fields exist only when the mounted executor reports
|
||||
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
|
||||
* that the composition cannot honor.
|
||||
*
|
||||
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
|
||||
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
|
||||
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
|
||||
* call is stamped `escalation grant > session override > executor default`.
|
||||
* The prompt deliberately does NOT state the mode and no switch is narrated:
|
||||
* the model learns the boundary from the denial marker (which names the mode
|
||||
* it ran under) exactly when it matters, instead of preemptively refusing
|
||||
* work a standing declaration would discourage.
|
||||
*
|
||||
* Model-facing `bash`, `bash_output`, and `bash_kill` tools over the executor
|
||||
* seam. Background tasks are fenced by owning session, completion injects a
|
||||
* durable notice, and confining executors add one-shot approval-based escalation.
|
||||
* Notices do not wake idle agents. Ownership is stored with the executor task so
|
||||
* it survives this plugin's reload; per-call authority is escalation grant,
|
||||
* session override, then executor default. See the package README for the tool contract.
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
|
||||
@@ -74,14 +27,8 @@ export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
* arg-validation RFC), so type/required/enum checks are already done and `args`
|
||||
* is the validated `InferArgs` shape here. What remains are value constraints
|
||||
* the DSL has no vocabulary for: non-empty strings, a positive finite timeout,
|
||||
* and the escalation pairing (`sandbox_permissions` and `justification` travel
|
||||
* together — an approval prompt without a reason, or a reason driving nothing,
|
||||
* is a malformed ask).
|
||||
* Validate value constraints absent from SchemaSpec: non-empty strings, a
|
||||
* positive finite timeout, and paired escalation mode and justification.
|
||||
*/
|
||||
function validateBashArgs(args: BashToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
@@ -105,9 +52,7 @@ function validateBashArgs(args: BashToolArgs): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type and presence are guaranteed by the
|
||||
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
* Reject an empty `task_id`; SchemaSpec already validates type and presence.
|
||||
*/
|
||||
function validateTaskId(value: string): BashTaskId {
|
||||
if (value.length === 0) {
|
||||
@@ -117,10 +62,8 @@ function validateTaskId(value: string): BashTaskId {
|
||||
}
|
||||
|
||||
/**
|
||||
* The bash tool's validated argument shape — the base parameters plus the two
|
||||
* escalation fields, which are ADVERTISED only when the mounted executor
|
||||
* reports a confining default mode (absent from the schema otherwise, so the
|
||||
* SchemaSpec validator rejects them before `execute` ever sees one).
|
||||
* Validated bash arguments. Escalation fields are advertised only when the
|
||||
* mounted executor reports a confining mode.
|
||||
*/
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
@@ -133,10 +76,8 @@ interface BashToolArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into the schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
* Strictly wider modes for each effective mode. Execution checks this table
|
||||
* because the schema is global while the effective mode is per call.
|
||||
*/
|
||||
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
@@ -144,24 +85,16 @@ const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed escalation-target vocabulary — every mode a call could ever
|
||||
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
|
||||
* whenever the mounted executor confines: cutting the enum down to the modes
|
||||
* wider than the executor's DEFAULT would strand a session whose effective
|
||||
* mode sits below it (a `danger-full-access` default would advertise nothing
|
||||
* while a narrower-switched session stays confined with no lever).
|
||||
* All possible escalation targets. Advertise the global set because a session
|
||||
* override may be narrower than the executor default; execution rejects a
|
||||
* target that is not wider for that call.
|
||||
*/
|
||||
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The bash tool's static description. The base text is byte-stable regardless
|
||||
* of composition (it is part of the pinned snapshot header); the escalation
|
||||
* teaching rides only when the mounted executor actually honors the fields —
|
||||
* it names the ONE sanctioned exception to the base text's "do not retry
|
||||
* another way" rule. Its deference clause ("If the session states approval
|
||||
* prompts are disabled…") points at the approval plugin's never-policy prompt
|
||||
* sentence by meaning, not by parsed wording — a rendezvous kept working by
|
||||
* that sentence continuing to open with the approvals-disabled claim.
|
||||
* The bash tool's byte-stable base description. Escalation guidance is added
|
||||
* only when the mounted executor can honor it, as the one exception to the
|
||||
* ordinary no-retry guidance.
|
||||
*/
|
||||
function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
@@ -173,15 +106,15 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.'
|
||||
if (escalationModes.length === 0) return base
|
||||
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
||||
+ 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it '
|
||||
+ 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry '
|
||||
+ '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 '
|
||||
+ '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 '
|
||||
+ '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 '
|
||||
+ '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.'
|
||||
}
|
||||
|
||||
@@ -192,15 +125,15 @@ 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 - the escalation targets this composition advertises;
|
||||
* non-empty adds the same-turn escalation hint after a denial marker
|
||||
* (default `[]`: no hint).
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
* @param escalationModes - the escalation targets this composition advertises; non-empty
|
||||
* adds the same-turn escalation hint after a denial marker (default `[]`: no hint).
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any
|
||||
* timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderResult(
|
||||
result: BashRunResult,
|
||||
@@ -218,15 +151,12 @@ export function renderResult(
|
||||
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.
|
||||
// Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial,
|
||||
// like a timeout, remains a reported fact for the model to handle.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
||||
// The same-turn nudge lives at the decision point: only when this
|
||||
// composition advertises the fields (a lever is never hinted that the
|
||||
// schema does not offer), and inside the sandbox marker family so the
|
||||
// exit-code marker stays the last line.
|
||||
// Add the retry hint only when the schema advertises escalation, before
|
||||
// the final exit marker.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
}
|
||||
@@ -247,33 +177,10 @@ export function renderResult(
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
|
||||
// renders a bash call's pending and completed states. They are display-only and
|
||||
// pure — a UI may call them during live streaming AND a session-log replay.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure tool-owned presentation used for both live events and replay.
|
||||
|
||||
/**
|
||||
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
|
||||
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
|
||||
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
|
||||
* = !is_terminal_tool`), so the command must BE the title to be seen. This
|
||||
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
|
||||
* use the bare command as an execute tool's title. The model-written
|
||||
* `description` (a readable summary) rides as a `content` text block shown ABOVE
|
||||
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
|
||||
* shows only the card; surfacing it as a content block is a deliberate
|
||||
* divergence here — we keep the human summary visible alongside the card.)
|
||||
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
|
||||
*
|
||||
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
|
||||
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
|
||||
* immediately (it never streams a terminal; its output is polled via
|
||||
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* execute card. For a foreground run the `terminal.cwd` (header) is the model
|
||||
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
|
||||
* against the session cwd; when omitted the bridge fills the session workspace
|
||||
* cwd (this PURE presenter, args only, can't see it).
|
||||
* Present foreground calls as terminals and background starts as generic cards.
|
||||
*/
|
||||
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
|
||||
|
||||
@@ -289,8 +196,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,
|
||||
@@ -300,26 +206,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
|
||||
@@ -331,35 +219,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)
|
||||
@@ -375,15 +242,8 @@ function presentTaskCall(verb: string, args: { task_id: string }): GenericCallVi
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -404,9 +264,7 @@ function statusLine(task: BashTask): string {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// The bash tools' cross-call HABIT, which the per-tool descriptions cannot
|
||||
// carry (they describe one call each): the exit-code marker is only useful
|
||||
// if the model actually checks it every time.
|
||||
// Cross-call guidance belongs in the prompt rather than one tool description.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
order: 105,
|
||||
@@ -414,26 +272,15 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's owner TOKEN — the owning agent's `session.header.id`, or
|
||||
* `undefined` for a non-agent caller. Read `session.header.id` (NOT
|
||||
* `session.id`): every other subsystem keys off the header id (the ACP bridge,
|
||||
* both persistence backends), and the sibling `resolveWorkdir` already reads
|
||||
* `session.header.cwd`, so using `session.id` here would be the asymmetry smell
|
||||
* the conventions flag. The two are equal in production, but the header is the
|
||||
* canonical identity.
|
||||
* Return the canonical session-header id used by ACP and persistence as the
|
||||
* task owner, or undefined for a non-agent caller.
|
||||
*/
|
||||
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
|
||||
exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
|
||||
|
||||
/**
|
||||
* Authorize a `bash_output`/`bash_kill` call against the task's stored owner
|
||||
* token. Rejects when the task HAS an owner and it differs from the caller's
|
||||
* token — using `!== undefined` semantics, NOT truthiness, so an empty-string
|
||||
* token is still a real owner (never treated as unowned). An unowned task
|
||||
* (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also
|
||||
* `undefined` here and then fails loudly at the subsequent
|
||||
* `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
|
||||
* (`callerToken` undefined) cannot match an owned task and is rejected.
|
||||
* Reject access when a task has a different session owner. Unowned tasks are
|
||||
* allowed; unknown ids still fail in the subsequent read or kill.
|
||||
*/
|
||||
const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
|
||||
const owner = ctx.bash.ownerOf(taskId)
|
||||
@@ -442,15 +289,8 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Background completion → inject a notice into the owning agent's session.
|
||||
// Find the live agent by its session id token via the agent registry, read
|
||||
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
|
||||
// this listener runs from `task.done.then` on the bash fiber — a foreign
|
||||
// fiber — where the `ctx.agents` property proxy would throw through the
|
||||
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
|
||||
// registry mounted (`undefined`) → drop the notice. Match on
|
||||
// `agent.session.header.id`, NOT the registry key: a config agent's id differs
|
||||
// from its session id, and the owner token IS the session id.
|
||||
// Completion runs on the bash fiber, so use topology-independent lookup and
|
||||
// match the executor's stored session-owner token to a live agent.
|
||||
ctx.bash.onTaskDone((task) => {
|
||||
const ownerToken = ctx.bash.ownerOf(task.id)
|
||||
if (ownerToken === undefined) return
|
||||
@@ -462,62 +302,38 @@ export function apply(ctx: Context): void {
|
||||
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// The ONE expected failure: the agent was disposed between task
|
||||
// completion and this injection (ReactLoopAgent.inject throws
|
||||
// `agent "<id>" is disposed`). That race is benign — drop the notice.
|
||||
// Anything else is a real bug and must surface, not be swallowed.
|
||||
// The one expected failure: the agent was disposed between task completion and this
|
||||
// injection (ReactLoopAgent.inject throws `agent "<id>" is disposed`).
|
||||
if (error instanceof Error && error.message.includes('is disposed')) return
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
// The escalation surface exists whenever the mounted executor confines.
|
||||
// Its enum is the closed target vocabulary, deliberately NOT cut down by
|
||||
// the configured default: a session may switch to a narrower effective mode
|
||||
// while sharing this globally registered schema. Strict widening therefore
|
||||
// belongs to the per-call check below. An executor swap restarts this fiber
|
||||
// (static inject) and re-registers the schema.
|
||||
// Advertise the closed target vocabulary globally, then enforce strict
|
||||
// widening against each call's effective session mode.
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
|
||||
* onto the request so execution follows the fold without stating it in the
|
||||
* prompt. Weakest precedence — an escalation grant (freshly approved for
|
||||
* exactly this call) outranks it, and without either the executor's
|
||||
* `resolve()` applies its configured default. Undefined for a non-sandboxing
|
||||
* executor (nothing honors it) and for agent-less callers (no session to
|
||||
* fold).
|
||||
* Return the calling session's folded standing mode. Approval outranks this
|
||||
* value and the executor default applies when it is absent; non-sandboxing
|
||||
* and agent-less calls have no override.
|
||||
*/
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes. Returns the granted mode to stamp onto the bash
|
||||
* request; throws the distinct fail-closed text for every other path (no
|
||||
* service composed, an agent-less execution, a rejection, a cancellation,
|
||||
* an unanswerable ask) — the registry turns the throw into this call's
|
||||
* isError result, and nothing has run. The seam is consumed
|
||||
* opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a
|
||||
* deployment without it degrades per call, never at registration.
|
||||
* Request one-shot escalation before execution. Missing approval context,
|
||||
* rejection, cancellation, and unavailable answers throw without running the
|
||||
* command; the optional seam is resolved per call through `ctx.get`.
|
||||
*/
|
||||
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
// Schema validation only checks ADVERTISED keys, so an unadvertised
|
||||
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
|
||||
// human is never prompted to "escalate" a sandbox that is not there. When
|
||||
// the fields ARE advertised, the registry's SchemaSpec enum has already
|
||||
// pinned `mode` to this ladder for every caller.
|
||||
// Reject an unadvertised escalation before prompting for a nonexistent sandbox.
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
// Strict widening is an EXECUTION check against the call's effective
|
||||
// mode — session override ?? executor default, the same fold ordinary
|
||||
// calls are stamped with — deliberately not a schema constraint (the
|
||||
// enum is the closed target vocabulary; the effective mode is per-call
|
||||
// truth). A non-widening request fails closed here and never prompts a
|
||||
// human.
|
||||
// Reject sandbox widening against the call's effective mode before requesting approval.
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
@@ -539,8 +355,7 @@ export function apply(ctx: Context): void {
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The SchemaSpec enum already pinned `mode` to the closed target
|
||||
// vocabulary; the per-call check above proved it is strictly wider.
|
||||
// Schema validation pins the vocabulary; the per-call check proves widening.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
@@ -580,14 +395,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
async execute(args: BashToolArgs, exec) {
|
||||
validateBashArgs(args)
|
||||
// `description` is display/logging metadata only (surfaced to UIs via
|
||||
// the tool/call session event); it is intentionally NOT forwarded to
|
||||
// ctx.bash and has no effect on execution.
|
||||
// An escalating call resolves approval BEFORE anything executes; every
|
||||
// non-grant outcome throws its distinct error text and runs nothing.
|
||||
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
|
||||
// An ordinary call carries the session's standing override instead —
|
||||
// grant > session override > executor default (see sessionOverride).
|
||||
// `description` is display/logging metadata only. Escalation approval
|
||||
// completes before execution; grant > session override > executor default.
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
@@ -603,10 +412,7 @@ export function apply(ctx: Context): void {
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
// executor stores it on the task — the isolation fence for bash_output/
|
||||
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
|
||||
// to fence).
|
||||
// Store the session owner on the task for bash_output/bash_kill isolation.
|
||||
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
|
||||
return [{ type: 'text', text: `started background task ${task.id}` }]
|
||||
}
|
||||
@@ -640,15 +446,11 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
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.
|
||||
// Background settlement carries the runner-failure fact that a
|
||||
// foreground call exposes as SANDBOX_UNAVAILABLE.
|
||||
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 (and its same-turn escalation
|
||||
// hint). Background denials are only classifiable once the task
|
||||
// settles (the classifier needs the whole stderr), so the marker
|
||||
// rides every read that sees the settled task.
|
||||
// Mirrors the foreground result marker (and its same-turn escalation hint).
|
||||
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
|
||||
if (escalationModes.length > 0) {
|
||||
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
|
||||
|
||||
@@ -56,12 +56,8 @@ async function setup() {
|
||||
*/
|
||||
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
|
||||
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
|
||||
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
|
||||
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
|
||||
// owner token IS the session id, so the notice path must find the agent by
|
||||
// `session.header.id`, NOT the registry key. Using distinct values here makes
|
||||
// the test fail if a regression matched on the wrong field (a same-value fake
|
||||
// would pass either way — the "hits the line but not the scenario" trap).
|
||||
// A config agent has distinct registry (`agent.id`) and owner (`session.header.id`) tokens.
|
||||
// Keeping them unequal makes notice lookup by the wrong field fail instead of passing by chance.
|
||||
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
const dispose = ctx.agents.register(agent)
|
||||
const list = fakeAgentDisposers.get(ctx) ?? []
|
||||
@@ -426,9 +422,8 @@ describe('background tools', () => {
|
||||
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
|
||||
const ctx = await setup()
|
||||
const inject = vi.fn()
|
||||
// The notice path looks the agent up in ctx.agents by its session token, so
|
||||
// the agent must be REGISTERED (not merely passed to execute). Mount a
|
||||
// registry and register a fake whose session.header.id IS the owner token.
|
||||
// Notices look up the agent in ctx.agents by session token, so passing it to execute is not
|
||||
// enough: the fake must be registered with a matching `session.header.id`.
|
||||
const agent = registerFakeAgent(ctx, 'bg', inject)
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
@@ -491,11 +486,8 @@ describe('background tools', () => {
|
||||
})
|
||||
|
||||
it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
|
||||
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
|
||||
// per-session agent — e.g. the ACP session disconnects and its AgentHandle
|
||||
// disposes while the background task is still running. The owner token is
|
||||
// still on the task, but no live agent carries it anymore, so the registry
|
||||
// lookup finds nothing and the notice is dropped (no throw).
|
||||
// Host-scoped bash tasks can outlive a per-session agent after an ACP disconnect. The task
|
||||
// retains its owner token, but with no matching live agent the notice is dropped without error.
|
||||
const ctx = await setup()
|
||||
const inject = vi.fn()
|
||||
const agent = registerFakeAgent(ctx, 'bg', inject)
|
||||
@@ -525,11 +517,8 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
|
||||
// each agent needs a DISTINCT session id, else every fake yields the same
|
||||
// token and the isolation tests pass for the wrong reason (all tasks owned by
|
||||
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
|
||||
// it.
|
||||
// Ownership uses `session.header.id`, not object identity. Distinct ids keep the isolation tests
|
||||
// from passing accidentally because every fake produced the same owner token.
|
||||
const fakeAgent = (sessionId: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
|
||||
@@ -609,11 +598,8 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
})
|
||||
|
||||
it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
|
||||
// The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
|
||||
// in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
|
||||
// task survive) preserves ownership. This is the regression guard: a
|
||||
// plugin-local map would make B accessible after reload, and this test would
|
||||
// catch it.
|
||||
// The executor task owns the token, so reloading only tool-bash preserves ownership. A
|
||||
// plugin-local map would lose it and incorrectly expose the task to agent B.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -832,11 +818,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.
|
||||
@@ -899,26 +882,18 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
|
||||
/**
|
||||
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
|
||||
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
|
||||
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
|
||||
* model that power), so it must build its request from named args only and
|
||||
* never spread unknown tool-call keys into it. This guard's job is to catch a
|
||||
* future refactor that blindly forwards `...args` — which would silently thread
|
||||
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
|
||||
* (the credential scrub in dsh-bash-local is the security control; see the
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
|
||||
* unused here.
|
||||
* Records requests passed to `resolve()` so tests can prove the model-facing tool forwards only
|
||||
* named arguments. It intentionally exposes neither `stdin` nor `env`; this catches a future
|
||||
* `...args` spread into the post-scrub env merge. The credential scrub remains the security
|
||||
* boundary; see the bash stdin/env RFC. Foreground `run()` is canned and `start()` is unused.
|
||||
*/
|
||||
class RecordingBashExecutor extends BashExecutor {
|
||||
readonly requests: BashExecRequest[] = []
|
||||
@@ -961,12 +936,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',
|
||||
@@ -1446,11 +1418,9 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
})
|
||||
|
||||
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {
|
||||
// The blocker scenario: a workspace-write default with a read-only
|
||||
// override — the sensible escalation is workspace-write, which a
|
||||
// default-relative ladder could not even express. The static target
|
||||
// vocabulary advertises it and the execution check accepts it as
|
||||
// strictly wider than the CALL's effective (overridden) mode.
|
||||
// With a workspace-write default and read-only override, escalation must return to
|
||||
// workspace-write. The static target vocabulary exposes it, and validation compares it with
|
||||
// the call's effective override rather than a default-relative ladder.
|
||||
const ctx = await setupModal('workspace-write', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const seen: (string | undefined)[] = []
|
||||
|
||||
Reference in New Issue
Block a user