mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location
# Conflicts: # docs/module-graph.md # docs/tool-catalog.md # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # packages/bash/tool-bash/README.md # packages/bash/tool-bash/package.json # packages/bash/tool-bash/src/index.ts # packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
# bash/ — bash capability family
|
||||
|
||||
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, a concrete local implementation, and the model-facing tool that consumes it. All **product** packages.
|
||||
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible.
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/)).
|
||||
|
||||
@@ -27,4 +27,4 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
|
||||
|
||||
## Sandboxing
|
||||
|
||||
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § Extending The Harness. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
Execution policy does NOT belong in this package: this executor always runs commands unconfined. Confinement is [`dsh-bash-sandbox`](../bash-sandbox/README.md), which extends this executor verbatim and confines commands under the `ctx.sandbox` seam's bwrap/Landlock/Seatbelt backends ([sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); per-call allow/deny/ask policy belongs on the `tools/pre-execute` gate.
|
||||
|
||||
@@ -133,6 +133,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
// Carry a sandbox-mode override through verbatim: this executor never
|
||||
// confines, so the field is inert here (the seam contract) — a
|
||||
// sandboxing subclass overrides resolve() to stamp its default instead.
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +216,18 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full collected stderr of a tracked task from stream start (bounded by the
|
||||
* in-memory cap; bytes only in the spill file are not re-read). A protected
|
||||
* seam for subclasses that classify a settled task's outcome — reading here
|
||||
* does NOT advance the consumer's {@link readOutput} cursor. An unknown id
|
||||
* (a task already dropped by disposal) reads as empty.
|
||||
*/
|
||||
protected collectedStderr(id: BashTaskId): string {
|
||||
const task = this.tasks.get(id)
|
||||
return task === undefined ? '' : task.running.stderr.readFrom(0).text
|
||||
}
|
||||
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
|
||||
33
packages/bash/bash-sandbox/README.md
Normal file
33
packages/bash/bash-sandbox/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — the model-facing tool layer (`dsh-tool-bash`) is untouched; that swap is exactly what the seams exist for.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
|
||||
| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
|
||||
| `danger-full-access` | No confinement; the provider is never consulted. Execution is `dsh-bash-local`'s verbatim — foreground results still carry `sandbox: { mode, denied: false }` (no `enforcement`: nothing was confined), background tasks carry no sandbox facts |
|
||||
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker.
|
||||
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/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.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
|
||||
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo.
|
||||
41
packages/bash/bash-sandbox/package.json
Normal file
41
packages/bash/bash-sandbox/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
302
packages/bash/bash-sandbox/src/index.ts
Normal file
302
packages/bash/bash-sandbox/src/index.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* `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).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
|
||||
* fail-safe default; an example that wants a workspace-writable agent opts in
|
||||
* explicitly). The runner choice is NOT configured here: which platform
|
||||
* backend confines the command is the `ctx.sandbox` provider's config.
|
||||
*/
|
||||
export interface Config extends LocalConfig {
|
||||
/** File-sandbox mode commands run under (default: `read-only`). */
|
||||
mode?: SandboxMode
|
||||
/**
|
||||
* Root directory `workspace-write` mode may write under (default: the
|
||||
* executor's default working directory — `cwd`, else `process.cwd()`).
|
||||
*/
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word (embedded single
|
||||
* quotes become `'\''`), so a wrapped argv element survives the outer
|
||||
* `bash -c` re-parse byte-for-byte.
|
||||
* @param text - the raw argv element to quote.
|
||||
* @returns the single-quoted shell word.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative sandbox-denial classifier: a run counts as denied only when it
|
||||
* FAILED (nonzero exit — a signal kill is not a denial) and its stderr
|
||||
* carries one of the SELECTED BACKEND's own denial signatures — the dialect
|
||||
* the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
|
||||
* `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
|
||||
* under Landlock's EACCES, `Operation not permitted` under Seatbelt's
|
||||
* EPERM). Matching the backend's dialect rather than a cross-backend union
|
||||
* keeps the classifier from claiming denials the active backend never
|
||||
* produces (bare EPERM text under a Linux runner names non-file boundaries —
|
||||
* mount, kill, ptrace — that fail the same way unsandboxed). Text inference
|
||||
* is the fallback signal until a runner provides a structured one (which
|
||||
* wins once it exists); it errs toward NOT claiming a denial, and its known
|
||||
* residual imprecision is non-sandbox text in the active dialect (an ssh
|
||||
* auth failure reads as a denial under Landlock, a refused `kill` under
|
||||
* Seatbelt).
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's denial dialect, case-insensitive
|
||||
* stderr substrings.
|
||||
* @returns whether the run's failure reads as a sandbox denial.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner-failure classifier: a failed run whose stderr carries the SELECTED
|
||||
* BACKEND's own runner-failure signature (`ConfinedArgv.
|
||||
* runnerFailureSignatures`: the runner's error prefix, which also matches
|
||||
* the shell's runner-not-found message) means the SANDBOX itself failed and
|
||||
* the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
|
||||
* error text can contain denial words (an unopenable grant root reports
|
||||
* `Permission denied`) — and surfaced as the fail-closed
|
||||
* `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
|
||||
* on a settled background task. Same conservative-text-inference stance and
|
||||
* residual imprecision as the denial classifier (a failing task that itself
|
||||
* prints the runner's prefix reads as a runner failure).
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's runner-failure signatures,
|
||||
* case-insensitive stderr substrings.
|
||||
* @returns whether the run's failure reads as the runner itself failing.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier core shared by foreground results and settled background
|
||||
* tasks: failed AND signature present. Lowercases BOTH sides — the seam
|
||||
* declares its signatures case-insensitive, and producers compose them from
|
||||
* runtime data of any case (an `argv0` path, `No such file or directory`).
|
||||
*/
|
||||
function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
|
||||
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
|
||||
* the whole swap — the tool layer is untouched). Its configured mode is the
|
||||
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
|
||||
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
|
||||
* request, while an approved escalation may stamp a strictly wider mode for
|
||||
* one call. The tool's per-agent prompt section states that same effective
|
||||
* mode, and each run's `result.sandbox` reports what actually executed plus
|
||||
* enforcement completeness.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox']
|
||||
|
||||
// The sandbox-specific fields intersect the local executor's Config as an
|
||||
// inline schema call: the config catalog walks `static Config` statically.
|
||||
static override Config: z<Config> = z.intersect([
|
||||
LocalBashExecutor.Config,
|
||||
z.object({
|
||||
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
|
||||
workspaceRoot: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
/**
|
||||
* Per-task facts, keyed by task id from `start()` until the settle stamp
|
||||
* consumes them: the mode the task runs under (per-call — an escalated task
|
||||
* differs from its neighbors) plus its wrap facts. The seam returns facts
|
||||
* PER WRAP — a provider may legally vary enforcement or dialect between
|
||||
* calls — so overlapping background tasks must each classify against their
|
||||
* OWN wrap; a single latest-wrap field would let a later `start()` clobber
|
||||
* an earlier task's facts before it settles. A `danger-full-access` task
|
||||
* has NO entry (nothing confined it), which is what the settle stamp keys
|
||||
* off.
|
||||
*/
|
||||
private readonly taskFacts = new Map<BashTaskId, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// schemastery (static Config) already filled the defaulted fields — the
|
||||
// cast records that runtime fact (mirrors LocalBashExecutor's config
|
||||
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
|
||||
// fallback chain is real branching.
|
||||
this.mode = config.mode as SandboxMode
|
||||
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the effective mode onto the spec — the request's explicit override
|
||||
* (an approved escalation), else this executor's configured default — so
|
||||
* defaulting stays an explicit resolve step and `run()`/`start()` read the
|
||||
* spec, never the config.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// resolve() always stamps the mode; the cast records that invariant
|
||||
// (mirrors the constructor's config casts).
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial: the sandbox itself failed and the
|
||||
// command NEVER RAN — surface the same structured fail-closed error a
|
||||
// confine-time discovery throws (late detection, same outcome), with
|
||||
// the runner's own first stderr line as the cause. Returning it as a
|
||||
// task result would let a broken sandbox read as a failing command.
|
||||
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
|
||||
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashTask {
|
||||
// Same stamped-by-resolve invariant as run().
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Sandbox facts are stamped at settle time by {@link notifyTaskDone}
|
||||
// (denial classification runs against the settled task's collected
|
||||
// stderr). The map entry lands synchronously after spawn, strictly
|
||||
// before the earliest possible settle (a process exit reaches us no
|
||||
// sooner than the next tick).
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const task = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.taskFacts.set(task.id, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the sandbox facts BEFORE completion listeners run: the base
|
||||
* executor notifies from inside the task's settle path, so overriding the
|
||||
* notification point is what makes `task.sandbox` visible to `onTaskDone`
|
||||
* consumers and `done` awaiters alike. Each task classifies against the
|
||||
* facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
|
||||
* per-task map here — settle is the entry's end of life): with per-call
|
||||
* escalation, tasks under different modes settle side by side, so keying
|
||||
* anything off the configured default would misreport them. A
|
||||
* `danger-full-access` task has no map entry and carries no facts (nothing
|
||||
* confined it); a signal-killed task (null exit code) is never a denial,
|
||||
* mirroring the foreground classifier.
|
||||
*/
|
||||
protected override notifyTaskDone(task: BashTask): void {
|
||||
const facts = this.taskFacts.get(task.id)
|
||||
if (facts !== undefined) {
|
||||
this.taskFacts.delete(task.id)
|
||||
const stderr = this.collectedStderr(task.id)
|
||||
// Runner failure outranks denial (the command never ran; the runner's
|
||||
// own error text can contain denial words). A settled task has no
|
||||
// error channel left, so the fact IS the surface here — the foreground
|
||||
// path throws instead.
|
||||
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
task.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(task.exitCode, stderr, facts.denialSignatures),
|
||||
enforcement: facts.enforcement,
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.notifyTaskDone(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
*/
|
||||
private confine(command: string, mode: ConfinedSandboxMode): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureSignatures: confined.runnerFailureSignatures,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxBashExecutor
|
||||
101
packages/bash/bash-sandbox/tests/bwrap.e2e.ts
Normal file
101
packages/bash/bash-sandbox/tests/bwrap.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof under bwrap: the REAL
|
||||
* `LocalSandboxProvider` (nothing forced — bwrap is the ladder's first rung,
|
||||
* so a passing probe selects it) underneath the REAL `SandboxBashExecutor`,
|
||||
* driven through the executor's public run/start paths. Verifies the WORLD
|
||||
* (files exist or don't) plus the stamped result facts — in particular that
|
||||
* bwrap's EROFS denial text classifies as `denied: true` through the
|
||||
* wrap-carried dialect; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
|
||||
* host that denies unprivileged user namespaces.
|
||||
*
|
||||
* HOME-based dirs on purpose: bwrap's `/tmp` is an ephemeral mount, so only
|
||||
* paths outside it prove the workspace-root boundary.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const bwrapUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and EROFS text classifies as a denial', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
})
|
||||
})
|
||||
100
packages/bash/bash-sandbox/tests/landlock.e2e.ts
Normal file
100
packages/bash/bash-sandbox/tests/landlock.e2e.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
|
||||
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
|
||||
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
|
||||
* facts; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock; the
|
||||
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
const landlockUsable = probe.status === 0
|
||||
/** The kernel's enforcement level from the probe report — stamped facts below must match it. */
|
||||
const enforcement = /partially enforced/.test(probe.stdout ?? '') ? 'partial' : 'full'
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, the result carries denial + enforcement facts', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
})
|
||||
})
|
||||
327
packages/bash/bash-sandbox/tests/sandbox.spec.ts
Normal file
327
packages/bash/bash-sandbox/tests/sandbox.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
|
||||
* `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
|
||||
* policy hand-off, fail-closed propagation, classification, and fact
|
||||
* stamping all deterministic without any real runner; the real-provider
|
||||
* integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
|
||||
* with plain unix permissions (a 0555 directory), which exercises the same
|
||||
* stderr signature the classifier keys on.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
|
||||
/** One recorded provider call: the argv handed over and the policy it rode with. */
|
||||
interface ConfineCall {
|
||||
argv: string[]
|
||||
policy: SandboxPolicy
|
||||
}
|
||||
|
||||
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
|
||||
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
|
||||
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = ['fake-runner: '] as const
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
* per test) and the executor under test on top of it.
|
||||
*/
|
||||
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
calls.push({ argv: [...argv], policy })
|
||||
return behavior(argv, policy)
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
}
|
||||
|
||||
function output(text: string): CollectedOutput {
|
||||
return { text, truncated: false }
|
||||
}
|
||||
|
||||
function runResult(exitCode: number | null, stderr: string): BashRunResult {
|
||||
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
|
||||
}
|
||||
|
||||
describe('the provider hand-off', () => {
|
||||
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
|
||||
expect(result.stdout.text).toBe('a b c\'d\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(calls).toEqual([{
|
||||
argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
|
||||
policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
|
||||
}])
|
||||
})
|
||||
|
||||
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
|
||||
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
|
||||
// the sentinel only prints if the executor spawned the WRAPPED argv.
|
||||
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
|
||||
})
|
||||
|
||||
it('an explicit workspaceRoot wins over cwd', async () => {
|
||||
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
|
||||
})
|
||||
|
||||
it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
|
||||
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail closed', () => {
|
||||
it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
|
||||
const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
|
||||
const spec = bash.resolve({ command: 'echo hi' })
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'danger-full-access' })
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free' }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('start() passes through unwrapped and stamps nothing at settle', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'danger-full-access' })
|
||||
const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(bash.readOutput(task.id).delta).toContain('free-bg')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(bash.sandboxMode).toBe('read-only')
|
||||
expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
|
||||
})
|
||||
|
||||
it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
|
||||
await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
|
||||
})
|
||||
|
||||
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('overlapping background tasks settle with their OWN modes (an escalated task next to a default one)', async () => {
|
||||
// With per-call policy, tasks under different modes are in flight at
|
||||
// once — anything keyed off the configured default would misreport the
|
||||
// escalated one at its settle stamp.
|
||||
const { bash } = await setup()
|
||||
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
|
||||
const plain = bash.start(bash.resolve({ command: 'true' }))
|
||||
await plain.done
|
||||
await escalated.done
|
||||
expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(bash.readOutput(task.id).delta).toContain('bg-free')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDenial', () => {
|
||||
it('never classifies a clean exit or a signal kill as a denial', () => {
|
||||
expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
|
||||
expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
|
||||
expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
|
||||
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
|
||||
// Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
|
||||
// it unsandboxed too, and the mode vocabulary governs file effects only —
|
||||
// claiming a file denial here would tell the model the sandbox blocked
|
||||
// something it never governed.
|
||||
expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
|
||||
expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
|
||||
// The same stderr flips meaning with the backend: under Seatbelt, EPERM
|
||||
// text IS how the kernel refuses a governed file write; under bwrap's
|
||||
// EROFS-only dialect, `Permission denied` is ordinary DAC, not the
|
||||
// sandbox — per-wrap signatures are what keep both classifications honest.
|
||||
expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
|
||||
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
|
||||
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
|
||||
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
|
||||
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
|
||||
// The wrap's runner prefix on a failed run means the SANDBOX broke and
|
||||
// the command never ran — the late twin of the confine-time throw, with
|
||||
// the runner's own first stderr line carried as the cause.
|
||||
const { bash } = await setup()
|
||||
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
|
||||
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
|
||||
})
|
||||
|
||||
it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
|
||||
.rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
})
|
||||
|
||||
it('completion listeners already see the stamped facts (stamp precedes notify)', async () => {
|
||||
const { ctx, bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const seen: unknown[] = []
|
||||
ctx.bash.onTaskDone((task) => { seen.push(task.sandbox) })
|
||||
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(seen).toEqual([{ mode: 'read-only', denied: true, enforcement: 'partial' }])
|
||||
})
|
||||
|
||||
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
|
||||
// The seam returns facts PER WRAP — a legal provider may vary them
|
||||
// between calls. The slow task settles AFTER the quick one started, so a
|
||||
// latest-wrap field would classify its denial against the quick task's
|
||||
// dialect (missing it) and stamp the wrong enforcement.
|
||||
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
|
||||
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
|
||||
{ enforcement: 'full', denialSignatures: ['read-only file system'] },
|
||||
]
|
||||
let call = 0
|
||||
const { bash } = await setup({}, (argv) => {
|
||||
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
|
||||
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
|
||||
})
|
||||
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
|
||||
const quick = bash.start(bash.resolve({ command: 'true' }))
|
||||
await quick.done
|
||||
await slow.done
|
||||
expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('a signal-killed task is never a denial (null exit code)', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
|
||||
// Let the stderr land before the kill so the classifier sees the
|
||||
// signature and must still refuse it on the null exit code alone.
|
||||
await vi.waitFor(() => { expect(bash.readOutput(task.id).delta).toContain('Permission denied') })
|
||||
bash.kill(task.id)
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('disposal kills wrapped background tasks (inherited HMR safety)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 30' }))
|
||||
await ctx.fiber.dispose()
|
||||
expect(task.status).toBe('killed')
|
||||
})
|
||||
})
|
||||
101
packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
Normal file
101
packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof on macOS: the REAL `LocalSandboxProvider`
|
||||
* (Linux rungs forced off, so `sandbox-exec`/Seatbelt confines) underneath
|
||||
* the REAL `SandboxBashExecutor`, driven through the executor's public
|
||||
* run/start paths. Verifies the WORLD (files exist or don't) plus the
|
||||
* stamped result facts — in particular that Seatbelt's EPERM denial text
|
||||
* classifies as `denied: true` through the wrap-carried dialect; the
|
||||
* backend-only confinement proofs live with `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — every non-macOS host, or
|
||||
* a macOS whose `sandbox-exec` refuses the profile.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const seatbeltUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and EPERM text classifies as a denial', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
// HOME-based dirs on purpose: workspace-write grants /tmp and the
|
||||
// per-user temp dir wholesale, so only paths outside both prove the
|
||||
// workspace-root boundary.
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
})
|
||||
})
|
||||
36
packages/bash/bash-sandbox/tsconfig.json
Normal file
36
packages/bash/bash-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
|
||||
|
||||
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
|
||||
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
|
||||
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface, tool schemas untouched; a containerized or remote executor slots in the same way.
|
||||
|
||||
## Service API (`ctx.bash`)
|
||||
|
||||
@@ -19,6 +20,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
|
||||
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
|
||||
| `get(id)` / `list()` | Task lookup. |
|
||||
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
|
||||
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
|
||||
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
|
||||
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
|
||||
@@ -28,6 +30,8 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: 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.
|
||||
|
||||
`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).
|
||||
|
||||
@@ -23,10 +23,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
BashRunResult,
|
||||
BashSandboxInfo,
|
||||
BashTask,
|
||||
BashTaskListener,
|
||||
BashTaskRead,
|
||||
@@ -70,6 +73,22 @@ export abstract class BashExecutor extends Service {
|
||||
}, 'bash listener teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox mode this executor confines commands under BY DEFAULT, or
|
||||
* `undefined` when it does not sandbox at all — the capability fact the
|
||||
* tool 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.
|
||||
* @returns the configured default mode of a sandboxing executor;
|
||||
* `undefined` for an executor that never confines.
|
||||
*/
|
||||
get sandboxMode(): SandboxMode | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a caller's {@link BashExecRequest} into a fully-specified
|
||||
* {@link BashExecSpec}, applying this implementation's config defaults and
|
||||
|
||||
65
packages/bash/bash/src/session-mode.ts
Normal file
65
packages/bash/bash/src/session-mode.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Per-session sandbox-mode override: the session log as the store. A runtime
|
||||
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
|
||||
* one `bash/sandbox-mode` event on the session it applies to;
|
||||
* `effective = fold(events) ?? the executor's configured default`, so an
|
||||
* override survives restart by replay, two sessions can never see each
|
||||
* other's state, and there is no external config store. The event is
|
||||
* log-only (the `approval/*` precedent): the model learns the mode from the
|
||||
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
|
||||
* never from the event itself. EXECUTION honors the fold in the tool layer —
|
||||
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
|
||||
* (weakest-precedence: an escalation grant for the call outranks it) — the
|
||||
* executor itself stays a config-fixed default plus per-call overrides.
|
||||
*
|
||||
* @module dsh-bash/session-mode
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's sandbox mode was switched — log-only (like `approval/*`;
|
||||
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
|
||||
* never in the model transcript. The LAST such event is the session's
|
||||
* override ({@link effectiveSandboxMode}); who asked for it is derivable
|
||||
* from position (an event after the log's last `request/header*` was a
|
||||
* runtime switch by the user; see the tool layer's narrator).
|
||||
*/
|
||||
'bash/sandbox-mode': { mode: SandboxMode }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the mode of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'bash/sandbox-mode') return event.data.mode
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's sandbox-mode override: appends exactly one
|
||||
* `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode
|
||||
* state out of band. Takes effect on the session's next bash call and next
|
||||
* prompt assembly (the consumers fold on every read).
|
||||
* @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).
|
||||
*/
|
||||
export function setSandboxMode(session: Session, mode: SandboxMode): void {
|
||||
session.append('bash/sandbox-mode', { mode })
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Identifies one background task within an executor (generated `bash-N`). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
@@ -40,6 +41,48 @@ export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox facts for one foreground run — present on {@link BashRunResult} iff
|
||||
* a sandboxing executor ran the command (an unsandboxed executor reports no
|
||||
* `sandbox` field at all). Reported independently of `exitCode`/`signal`
|
||||
* (orthogonal outcomes), so a caller can tell "the command failed on its own"
|
||||
* from "the sandbox blocked a file operation". The mode/enforcement
|
||||
* vocabulary lives on the `@deepseek-ai/dsh-sandbox` seam; this shape is the
|
||||
* bash seam's result-fact carrier for it.
|
||||
*/
|
||||
export interface BashSandboxInfo {
|
||||
/** The mode the command actually ran under. */
|
||||
mode: SandboxMode
|
||||
/**
|
||||
* True when the executor classifies this run's failure as the sandbox
|
||||
* denying a file operation. The classification is CONSERVATIVE (a failed
|
||||
* exit whose stderr carries a filesystem-permission signature) and reads
|
||||
* the COLLECTED stderr — the bounded in-memory tail per
|
||||
* {@link CollectedOutput} semantics, so a signature that survives only in a
|
||||
* spill file is missed toward `denied: false`. A plain command failure
|
||||
* keeps `denied: false` even under a sandboxed mode.
|
||||
*/
|
||||
denied: boolean
|
||||
/**
|
||||
* How completely the runner enforced `mode`'s file effects — see
|
||||
* {@link SandboxEnforcement}. Absent exactly when `mode` is
|
||||
* `danger-full-access`: nothing is confined, so there is no enforcement to
|
||||
* report.
|
||||
*/
|
||||
enforcement?: SandboxEnforcement
|
||||
/**
|
||||
* True when the executor classifies this failure as the SANDBOX RUNNER
|
||||
* itself failing (missing binary, refused profile, fail-closed refusal
|
||||
* before exec) — the command NEVER RAN; this is a sandbox failure, not a
|
||||
* task failure, and it outranks `denied` (a runner's own error text can
|
||||
* contain denial words). Only ever stamped on settled BACKGROUND tasks: a
|
||||
* foreground run surfaces the same condition as the thrown
|
||||
* `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error
|
||||
* channel; a settled task's facts are its only channel).
|
||||
*/
|
||||
runnerFailed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
|
||||
* filled by {@link BashExecutor.resolve} from the implementation's config.
|
||||
@@ -81,6 +124,20 @@ export interface BashExecRequest {
|
||||
* ownerless background start (a non-agent caller).
|
||||
*/
|
||||
owner?: OwnerToken | undefined
|
||||
/**
|
||||
* Explicit per-call sandbox-policy input, overriding the executor's
|
||||
* configured default mode for THIS call. Never a silent default: a
|
||||
* consumer sets it only from an explicit policy source — an
|
||||
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
|
||||
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
|
||||
* session's standing override folded from its own `bash/sandbox-mode`
|
||||
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
|
||||
* choice). A sandboxing executor confines THIS call under the given mode;
|
||||
* a non-sandboxing executor carries the field and confines nothing (the
|
||||
* tool layer stamps neither escalation nor overrides without a sandboxing
|
||||
* executor — see {@link BashExecutor.sandboxMode}).
|
||||
*/
|
||||
sandboxMode?: SandboxMode | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +179,16 @@ export interface BashExecSpec {
|
||||
* task. `start()` stores it; `run()` (foreground) ignores it.
|
||||
*/
|
||||
owner: OwnerToken | undefined
|
||||
/**
|
||||
* The sandbox mode this call executes under, REQUIRED-but-nullable for the
|
||||
* same visibility reason as `owner`. A sandboxing executor's `resolve()`
|
||||
* stamps the effective mode (the request's explicit override, else its
|
||||
* configured default) so `run()`/`start()` read the spec, never the config;
|
||||
* a non-sandboxing executor carries the request value through verbatim and
|
||||
* ignores it (`undefined` under such an executor means what its README says:
|
||||
* unconfined execution).
|
||||
*/
|
||||
sandboxMode: SandboxMode | undefined
|
||||
}
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
@@ -148,6 +215,12 @@ export interface BashRunResult {
|
||||
timeoutMs: number
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
/**
|
||||
* Sandbox facts, present iff a sandboxing executor ran the command — an
|
||||
* unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
|
||||
* {@link BashSandboxInfo} for the `denied` classification semantics.
|
||||
*/
|
||||
sandbox?: BashSandboxInfo
|
||||
}
|
||||
|
||||
/** Lifecycle of a background task. */
|
||||
@@ -164,6 +237,16 @@ export interface BashTask {
|
||||
signal: NodeJS.Signals | null
|
||||
/** Resolves when the underlying process closes (never rejects). */
|
||||
readonly done: Promise<void>
|
||||
/**
|
||||
* Sandbox facts for this task's execution, stamped by a sandboxing executor
|
||||
* once the task settles and BEFORE completion listeners are notified — an
|
||||
* `onTaskDone` consumer and a `done` awaiter both see it. Denial
|
||||
* classification runs against the settled task's collected stderr, so the
|
||||
* field cannot exist earlier: absent while the task is running and under an
|
||||
* executor that does not sandbox. See {@link BashSandboxInfo} for the
|
||||
* `denied` semantics.
|
||||
*/
|
||||
sandbox?: BashSandboxInfo
|
||||
}
|
||||
|
||||
/** One incremental {@link BashExecutor.readOutput} read. */
|
||||
|
||||
@@ -15,6 +15,7 @@ class StubExecutor extends BashExecutor {
|
||||
timeoutMs: request.timeoutMs ?? 1000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +97,11 @@ describe('BashExecutor service seam', () => {
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('reports no default sandbox mode (composition truth: the base never confines)', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(bash.sandboxMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onTaskDone delivers completions to registered listeners', async () => {
|
||||
const { bash } = await setup()
|
||||
const seen: string[] = []
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Under a sandboxing executor it additionally contributes the per-agent `env:bash-sandbox` section (order 110) stating each session's EFFECTIVE mode, and the pre-step narrator — see [Per-session mode](#per-session-mode-switching-and-visibility).
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -17,6 +17,8 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
|
||||
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
@@ -26,11 +28,11 @@ Every foreground and background call made for an agent receives `DSH_SESSION_ID=
|
||||
|
||||
The overlay is computed from `ToolExecution.agent` for each call and passed through `BashExecRequest.env`; `process.env` is never modified, so concurrent parent/child agents keep separate values. The tool description names both variables so the model can inspect them without a permanent system-prompt section.
|
||||
|
||||
Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <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: 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.
|
||||
|
||||
### `bash_output`
|
||||
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). A settled task classified as a sandbox denial carries the same `[sandbox: file access denied under <mode> mode]` marker on every read that sees it (denials are only classifiable once the whole stderr has been collected). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
|
||||
### `bash_kill`
|
||||
|
||||
@@ -52,6 +54,12 @@ When a background task finishes, a short notice is injected into the owning agen
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted consumers. This tool does **not** expose them as model parameters: it builds the request from named schema fields and adds only the session overlay above, so model-supplied `env`/`stdin` keys are ignored and cannot replace the trusted values. 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 ambient secrets is `dsh-bash-local`'s credential scrub. Regression guards assert extra model fields never enter the request while the trusted overlay still does. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions
|
||||
## Permissions and escalation
|
||||
|
||||
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -23,9 +23,11 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
@@ -33,9 +35,13 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
|
||||
@@ -30,10 +30,27 @@
|
||||
* completion landing during the reload gap still drops its one notice — the
|
||||
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
*
|
||||
* TODO(permissions): commands run with the executor's full authority. The
|
||||
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
|
||||
* sandboxing `BashExecutor` implementations — see docs/architecture.md
|
||||
* § Extending The Harness.
|
||||
* Commands run with the executor's full authority unless a sandboxing
|
||||
* executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call
|
||||
* allow/deny/ask policy is the `tools/pre-execute` waterfall — see
|
||||
* docs/architecture.md § Extension And Composition. 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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
@@ -41,11 +58,17 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
// Side-effect type import: declaration-merges `ctx.approval`, consumed
|
||||
// opportunistically by the escalation gate (`ctx.get('approval')` — the seam
|
||||
// stays optional at runtime, same pattern as dsh-tools' ask routing).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
@@ -56,16 +79,12 @@ export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
* arg-validation RFC), so type/required/enum checks are already done and `args`
|
||||
* is the validated `InferArgs` shape here. What remains are value constraints
|
||||
* the DSL has no vocabulary for: non-empty strings and a positive, finite
|
||||
* timeout.
|
||||
* the DSL has no vocabulary for: non-empty strings, a positive finite timeout,
|
||||
* and the escalation pairing (`sandbox_permissions` and `justification` travel
|
||||
* together — an approval prompt without a reason, or a reason driving nothing,
|
||||
* is a malformed ask).
|
||||
*/
|
||||
function validateBashArgs(args: {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
}): void {
|
||||
function validateBashArgs(args: BashToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
}
|
||||
@@ -75,6 +94,15 @@ function validateBashArgs(args: {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
if (args.sandbox_permissions !== undefined && args.justification === undefined) {
|
||||
throw new Error('invalid escalation: sandbox_permissions requires a justification')
|
||||
}
|
||||
if (args.justification !== undefined && args.sandbox_permissions === undefined) {
|
||||
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
|
||||
}
|
||||
if (args.justification !== undefined && args.justification.trim().length === 0) {
|
||||
throw new Error('invalid justification: expected a non-empty sentence')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +117,77 @@ function validateTaskId(value: string): BashTaskId {
|
||||
return BashTaskId(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bash tool's validated argument shape — the base parameters plus the two
|
||||
* escalation fields, which are ADVERTISED only when the mounted executor
|
||||
* reports a confining default mode (absent from the schema otherwise, so the
|
||||
* SchemaSpec validator rejects them before `execute` ever sees one).
|
||||
*/
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into the schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
*/
|
||||
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
'workspace-write': ['danger-full-access'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed escalation-target vocabulary — every mode a call could ever
|
||||
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
|
||||
* whenever the mounted executor confines: cutting the enum down to the modes
|
||||
* wider than the executor's DEFAULT would strand a session whose effective
|
||||
* mode sits below it (a `danger-full-access` default would advertise nothing
|
||||
* while a narrower-switched session stays confined with no lever).
|
||||
*/
|
||||
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The bash tool's static description. The base text is byte-stable regardless
|
||||
* of composition (it is part of the pinned snapshot header); the escalation
|
||||
* teaching rides only when the mounted executor actually honors the fields —
|
||||
* it names the ONE sanctioned exception to the base text's "do not retry
|
||||
* another way" rule. Its deference clause ("If the session states approval
|
||||
* prompts are disabled…") points at the approval plugin's never-policy prompt
|
||||
* sentence by meaning, not by parsed wording — a rendezvous kept working by
|
||||
* that sentence continuing to open with the approvals-disabled claim.
|
||||
*/
|
||||
function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, '
|
||||
+ '`$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. '
|
||||
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.'
|
||||
if (escalationModes.length === 0) return base
|
||||
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
||||
+ 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it '
|
||||
+ 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry '
|
||||
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
|
||||
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
|
||||
+ 'approval prompt raised by that retry IS how the user consents. If the session states approval '
|
||||
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
|
||||
+ 'Never escalate speculatively: ground the request in a real denial — normally the one THIS command '
|
||||
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
|
||||
+ 'A rejected escalation is final for THAT command — stop and explain, never work around '
|
||||
+ 'it — but it does not forbid attempting or escalating other commands later.'
|
||||
}
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
if (!output.truncated) return output.text
|
||||
@@ -101,9 +200,15 @@ function streamText(output: CollectedOutput): string {
|
||||
* errored — the model decides how to react; only infrastructure failures
|
||||
* (spawn errors, aborts) surface as isError results.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @param escalationModes - the escalation targets this composition advertises;
|
||||
* non-empty adds the same-turn escalation hint after a denial marker
|
||||
* (default `[]`: no hint).
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderResult(result: BashRunResult): string {
|
||||
export function renderResult(
|
||||
result: BashRunResult,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const out = streamText(result.stdout)
|
||||
const err = streamText(result.stderr)
|
||||
|
||||
@@ -116,6 +221,19 @@ export function renderResult(result: BashRunResult): string {
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// The sandbox marker precedes the exit-status markers so `[exit code: N]`
|
||||
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
|
||||
// reported fact like timeout: the model decides how to react.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
||||
// The same-turn nudge lives at the decision point: only when this
|
||||
// composition advertises the fields (a lever is never hinted that the
|
||||
// schema does not offer), and inside the sandbox marker family so the
|
||||
// exit-code marker stays the last line.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
}
|
||||
}
|
||||
// Timeout is reported independently of how the process actually ended: a
|
||||
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
|
||||
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
|
||||
@@ -372,16 +490,87 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
})
|
||||
|
||||
// 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.
|
||||
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 same effective mode the prompt
|
||||
* section states. 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).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
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.
|
||||
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`)
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: 'bash',
|
||||
callId: exec.callId,
|
||||
// Self-contained for the audit trail: approval/asked stores this
|
||||
// reason, and the target mode is part of the grant's identity.
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The SchemaSpec enum already pinned `mode` to the closed target
|
||||
// vocabulary; the per-call check above proved it is strictly wider.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
|
||||
default: return assertNever(outcome, 'ApprovalOutcome')
|
||||
}
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, '
|
||||
+ '`$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.',
|
||||
description: bashDescription(escalationModes),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
||||
description: {
|
||||
@@ -394,12 +583,33 @@ export function apply(ctx: Context): void {
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
||||
...escalationModes.length > 0 ? {
|
||||
sandbox_permissions: {
|
||||
type: 'string' as const,
|
||||
enum: [...escalationModes],
|
||||
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry '
|
||||
+ 'of a command the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string' as const,
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
||||
+ 'why this exact command needs the wider access.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
async execute(args: BashToolArgs, exec) {
|
||||
validateBashArgs(args)
|
||||
// `description` is display/logging metadata only (surfaced to UIs via
|
||||
// the tool/call session event); it is intentionally NOT forwarded to
|
||||
// ctx.bash and has no effect on execution.
|
||||
// An escalating call resolves approval BEFORE anything executes; every
|
||||
// non-grant outcome throws its distinct error text and runs nothing.
|
||||
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
|
||||
// An ordinary call carries the session's standing override instead —
|
||||
// grant > session override > executor default (see sessionOverride).
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
// Default the workdir to the calling agent's session cwd so each ACP
|
||||
// session runs in its own workspace (see resolveWorkdir); an explicit
|
||||
// model workdir still wins.
|
||||
@@ -411,6 +621,7 @@ export function apply(ctx: Context): void {
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...env !== undefined ? { env } : {},
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
@@ -422,7 +633,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve(request))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result) }]
|
||||
return [{ type: 'text', text: renderResult(result, escalationModes) }]
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
@@ -449,6 +660,21 @@ export function apply(ctx: Context): void {
|
||||
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
|
||||
}
|
||||
text += `\n${statusLine(read.task)}`
|
||||
if (read.task.sandbox?.runnerFailed) {
|
||||
// The sandbox RUNNER itself failed — the command never ran. The
|
||||
// foreground path surfaces this as the structured SANDBOX_UNAVAILABLE
|
||||
// error; a settled task's read carries the marker instead.
|
||||
text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`
|
||||
} else if (read.task.sandbox?.denied) {
|
||||
// Mirrors the foreground result marker (and its same-turn escalation
|
||||
// hint). Background denials are only classifiable once the task
|
||||
// settles (the classifier needs the whole stderr), so the marker
|
||||
// rides every read that sees the settled task.
|
||||
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
|
||||
if (escalationModes.length > 0) {
|
||||
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
|
||||
}
|
||||
}
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Read output from', args),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -13,11 +14,29 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
|
||||
|
||||
// Pure-config passthrough runner (same knob the snapshot tier uses): skips the
|
||||
// profile args up to `--` and execs the command unconfined — deterministic
|
||||
// without a host bwrap.
|
||||
const PASSTHROUGH_RUNNER = ['bash', '-c', 'while [ "$1" != "--" ]; do shift; done; shift; exec "$@"', 'passthrough-runner']
|
||||
const PASSTHROUGH_RUNNER_CONFIG = {
|
||||
runnerCommand: PASSTHROUGH_RUNNER,
|
||||
// The script has no pre-exec failure path; the provider still requires an
|
||||
// explicit dialect so a future script change cannot silently turn runner
|
||||
// failure into an ordinary command result.
|
||||
runnerFailureSignatures: ['passthrough-runner: profile rejected'],
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -102,6 +121,7 @@ class LossyReadBashExecutor extends BashExecutor {
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,6 +916,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
run(): Promise<BashRunResult> {
|
||||
@@ -1067,3 +1088,512 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
expect('owner' in request).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox rendering', () => {
|
||||
const sandboxResult = (denied: boolean, exitCode: number): BashRunResult => ({
|
||||
exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: denied ? 'bash: /x: Read-only file system' : 'boom', truncated: false },
|
||||
sandbox: { mode: 'read-only', denied },
|
||||
})
|
||||
|
||||
it('renders a denial marker BEFORE the exit-code marker (the $-anchored parse survives)', () => {
|
||||
const text = renderResult(sandboxResult(true, 1))
|
||||
expect(text).toMatch(/\[sandbox: file access denied under read-only mode\]\n\[exit code: 1\]$/)
|
||||
})
|
||||
|
||||
it('appends the same-turn escalation hint to a denial exactly when the fields are advertised', () => {
|
||||
const hinted = renderResult(sandboxResult(true, 1), ['workspace-write', 'danger-full-access'])
|
||||
expect(hinted).toMatch(
|
||||
/denied under read-only mode\]\n\[sandbox: escalation available — retry this exact command once with sandbox_permissions [^\n]+\]\n\[exit code: 1\]$/, // eslint-disable-line @stylistic/max-len -- the hint sentence is pinned verbatim
|
||||
)
|
||||
// Default (no advertisement): no hint — a lever the schema does not offer is never suggested.
|
||||
expect(renderResult(sandboxResult(true, 1))).not.toContain('escalation available')
|
||||
// A non-denied result never hints, advertised or not.
|
||||
expect(renderResult(sandboxResult(false, 2), ['danger-full-access'])).not.toContain('escalation available')
|
||||
})
|
||||
|
||||
it('renders no sandbox marker for a plain failure under a sandboxed mode', () => {
|
||||
expect(renderResult(sandboxResult(false, 2))).not.toContain('[sandbox:')
|
||||
})
|
||||
|
||||
it('bash_output reports a settled background denial with the same marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const started = await call(ctx, 'bash', { command: 'echo "x: Permission denied" >&2; exit 1', description: 'test command', run_in_background: true })
|
||||
const id = text(started).match(/started background task (bash-\d+)/)![1]
|
||||
await bash.list().find(task => task.id === id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toMatch(
|
||||
/\[status: completed, exit code: 1\]\n\[sandbox: file access denied under read-only mode\]\n\[sandbox: escalation available[^\n]+\]$/,
|
||||
)
|
||||
})
|
||||
|
||||
it('a settled background denial renders no escalation hint without a confining executor (defensive arm)', async () => {
|
||||
// Structurally near-unreachable through the real stack — every confining
|
||||
// default advertises the static target set — but the read path guards
|
||||
// it anyway: an executor that reports no sandboxMode (fields never
|
||||
// advertised) whose task nonetheless carries denial facts must render
|
||||
// the marker without suggesting a lever the schema does not offer.
|
||||
class FactsOnlyExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: BashTaskId('bash-facts'),
|
||||
command: 'fake',
|
||||
status: 'completed',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
sandbox: { mode: 'read-only', denied: true },
|
||||
}
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('not used')) }
|
||||
start(): BashTask { return this.task }
|
||||
get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined }
|
||||
list(): BashTask[] { return [this.task] }
|
||||
kill(): boolean { return false }
|
||||
ownerOf(): OwnerToken | undefined { return undefined }
|
||||
readOutput(): BashTaskRead {
|
||||
return { task: this.task, delta: '', lossy: false }
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FactsOnlyExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
const read = await call(ctx, 'bash_output', { task_id: 'bash-facts' })
|
||||
expect(text(read)).toMatch(/\[sandbox: file access denied under read-only mode\]$/)
|
||||
expect(text(read)).not.toContain('escalation available')
|
||||
})
|
||||
|
||||
it('bash_output reports a settled background RUNNER failure as a sandbox problem, outranking the denial marker', async () => {
|
||||
// A provider whose wrap carries a runner-failure signature: the settled
|
||||
// task's stderr matching it means the sandbox itself broke and the
|
||||
// command never ran — even though the same stderr also carries denial
|
||||
// words (a runner's error text may contain them).
|
||||
class FakeProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[]): ConfinedArgv {
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: ['permission denied'], runnerFailureSignatures: ['fake-runner: '] }
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FakeProvider)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const started = await call(ctx, 'bash', { command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125', description: 'test command', run_in_background: true })
|
||||
const id = text(started).match(/started background task (bash-\d+)/)![1]
|
||||
await bash.list().find(task => task.id === id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toMatch(/\[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; /)
|
||||
expect(text(read)).toMatch(/this is a sandbox problem, not a command failure\]$/)
|
||||
expect(text(read)).not.toContain('file access denied')
|
||||
})
|
||||
|
||||
it('classifies an executable configured runner that refuses its profile before the command runs', async () => {
|
||||
const signature = 'custom-runner-rejected'
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: ['bash', '-c', `printf '${signature}\\n' >&2; exit 125`, 'custom-runner'],
|
||||
runnerFailureSignatures: [signature],
|
||||
})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
await expect(bash.run(bash.resolve({ command: 'echo command-must-not-run' })))
|
||||
.rejects.toMatchObject({ code: 'SANDBOX_UNAVAILABLE' })
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'echo command-must-not-run' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
})
|
||||
|
||||
it('reports a real denial end-to-end through the shipping sandbox executor', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-tool-bash-denied-')), 'locked')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await call(ctx, 'bash', { command: `echo x > ${lockedDir}/f`, description: 'Write into a locked directory' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toMatch(
|
||||
/denied under read-only mode\]\n\[sandbox: escalation available[^\n]+\]\n\[exit code: \d+\]$/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox escalation (sandbox_permissions / justification)', () => {
|
||||
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
|
||||
async function setupSandboxed(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', opts: { approval?: boolean; policy?: 'ask' | 'never' } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...mode !== undefined ? { mode } : {} })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService, opts.policy !== undefined ? { policy: opts.policy } : {})
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** The registered bash tool's wire schema (what the model actually sees). */
|
||||
function bashSchema(ctx: Context) {
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'bash')
|
||||
if (!schema) throw new Error('bash tool not registered')
|
||||
return schema as unknown as { description: string; parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake agent whose session records appends — the approval audit surface.
|
||||
* Seeded mid-turn: an escalating call always runs inside one, and request()
|
||||
* enforces the enclosure.
|
||||
*/
|
||||
function escalationAgent(events: Array<{ type: string; data: Record<string, unknown> }>): Agent {
|
||||
return {
|
||||
id: 'agent-esc',
|
||||
session: {
|
||||
header: { version: 0, id: 'sess-esc', createdAt: 0 },
|
||||
events: [{ type: 'turn/start' }],
|
||||
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
let escCall = 0
|
||||
function callAs(ctx: Context, agent: Agent | undefined, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-esc-${++escCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
const ESCALATE = { command: 'true', description: 'test escalation', sandbox_permissions: 'workspace-write', justification: 'the test needs it' }
|
||||
|
||||
it('advertises no escalation surface under a non-sandboxing executor', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.bash.sandboxMode).toBeUndefined()
|
||||
const schema = bashSchema(ctx)
|
||||
expect(schema.parameters.properties['sandbox_permissions']).toBeUndefined()
|
||||
expect(schema.parameters.properties['justification']).toBeUndefined()
|
||||
expect(schema.description).not.toContain('sanctioned exception')
|
||||
})
|
||||
|
||||
it('advertises the full closed target vocabulary under any confining default', async () => {
|
||||
// The enum is deliberately NOT default-relative: a session's effective
|
||||
// mode is per-session and switchable, so every confining composition
|
||||
// advertises every possible target — strict widening is checked at
|
||||
// execution against the call's effective mode instead.
|
||||
for (const mode of [undefined, 'workspace-write', 'danger-full-access'] as const) {
|
||||
const { ctx } = await setupSandboxed(mode)
|
||||
const schema = bashSchema(ctx)
|
||||
expect(schema.parameters.properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(schema.parameters.properties['justification']).toBeDefined()
|
||||
expect(schema.description).toContain('sanctioned exception')
|
||||
}
|
||||
})
|
||||
|
||||
it('a non-widening request fails at execution with its own text and prompts no one', async () => {
|
||||
const { ctx } = await setupSandboxed('danger-full-access', { approval: true })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const result = await callAs(ctx, escalationAgent([]), { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'already wider' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not strictly wider than this call\'s current "danger-full-access" mode')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects sandbox_permissions without a justification, and vice versa, and a blank justification', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const missing = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write' })
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(text(missing)).toContain('sandbox_permissions requires a justification')
|
||||
const orphan = await callAs(ctx, undefined, { command: 'true', description: 'd', justification: 'why not' })
|
||||
expect(orphan.isError).toBe(true)
|
||||
expect(text(orphan)).toContain('only valid together with sandbox_permissions')
|
||||
const blank = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' })
|
||||
expect(blank.isError).toBe(true)
|
||||
expect(text(blank)).toContain('expected a non-empty sentence')
|
||||
})
|
||||
|
||||
it('the schema enum rejects a mode outside the target vocabulary before execute (registry-level, any caller)', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'read-only', justification: 'narrow' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must be one of')
|
||||
})
|
||||
|
||||
it('rejects an unadvertised sandbox_permissions injection under a non-sandboxing executor', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'sneaky' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not available in this composition')
|
||||
})
|
||||
|
||||
it('fails closed with its own text when no approval service is composed', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no approval service is composed')
|
||||
})
|
||||
|
||||
it('fails closed with its own text for an agent-less escalating call', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
const result = await callAs(ctx, undefined, ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no agent to route it through')
|
||||
})
|
||||
|
||||
it('fails closed with its own text when the service has no answerer', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no approval channel is available')
|
||||
})
|
||||
|
||||
it('a grant runs THAT call under the wider mode — the denial marker names it — and lands the audit pair', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
// A real unix denial under the passthrough runner: the marker's mode can
|
||||
// only say workspace-write if the override actually rode the spec.
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-esc-denied-')), 'locked')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await callAs(ctx, escalationAgent(events), {
|
||||
command: `echo x > ${lockedDir}/f`,
|
||||
description: 'write into a locked directory',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'must write outside the workspace',
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toMatch(/\[sandbox: file access denied under workspace-write mode\]/)
|
||||
expect(events.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(events[0]?.data['toolName']).toBe('bash')
|
||||
expect(events[0]?.data['reason']).toBe('escalate sandbox to workspace-write: must write outside the workspace')
|
||||
expect(events[1]?.data['outcome']).toBe('allowed-once')
|
||||
})
|
||||
|
||||
it('a granted background start settles with the wider mode\'s facts', async () => {
|
||||
const { ctx, bash } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const started = await callAs(ctx, escalationAgent([]), { ...ESCALATE, run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
const id = text(started).match(/started background task (bash-\d+)/)?.[1]
|
||||
const task = bash.list().find(t => t.id === id)
|
||||
if (!task) throw new Error('escalated task not tracked')
|
||||
await task.done
|
||||
expect(task.sandbox).toMatchObject({ mode: 'workspace-write', denied: false })
|
||||
})
|
||||
|
||||
it('a rejection denies with the user-said-no text and runs nothing', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
// A live (non-aborted) signal rides the execution: the gate threads it
|
||||
// into the approval request so a turn cancellation can withdraw the ask.
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId(`call-esc-${++escCall}`),
|
||||
name: 'bash',
|
||||
arguments: ESCALATE,
|
||||
agent: escalationAgent([]),
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('the user rejected escalating this command to "workspace-write"')
|
||||
})
|
||||
|
||||
it('a cancellation denies with the cancelled text', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('approval for escalating to "workspace-write" was cancelled')
|
||||
})
|
||||
|
||||
it('a rogue approval stand-in returning a non-vocabulary outcome hits the exhaustiveness backstop', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as InstanceType<typeof ApprovalService>)
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('unreachable')
|
||||
})
|
||||
|
||||
it('a never policy rejects an escalation deterministically without consulting any answerer', async () => {
|
||||
// The live-session e.md case: the model requests escalation against a
|
||||
// 'never' session — the prepend gate answers rejected before any
|
||||
// interactive answerer, the fail-closed text is the ordinary rejection
|
||||
// wording, and the audit pair still lands.
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true, policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
const result = await callAs(ctx, escalationAgent(events), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('the user rejected escalating this command to "workspace-write"')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
expect(events.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(events[1]?.data).toMatchObject({ outcome: 'rejected' })
|
||||
})
|
||||
|
||||
it('a plain call under a sandboxing executor never consults approval', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
const asked = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { asked(); return next() })
|
||||
const result = await callAs(ctx, escalationAgent([]), { command: 'echo plain', description: 'plain run' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plain')
|
||||
expect(asked).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
|
||||
async function setupModal(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'read-only', opts: { approval?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, mode })
|
||||
;(ctx.bash as SandboxBashExecutor).internals = { spillDir }
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent stand-in over a REAL Session — the stamping folds real events;
|
||||
* the opened turn satisfies approval's enclosure precondition on escalating
|
||||
* calls.
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
|
||||
const session = new Session(SessionId(id))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const injected: string[] = []
|
||||
const agent = {
|
||||
id,
|
||||
session,
|
||||
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
|
||||
} as unknown as Agent
|
||||
return { agent, session, injected }
|
||||
}
|
||||
|
||||
let modeCall = 0
|
||||
const callAs = (ctx: Context, agent: Agent | undefined, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`call-mode-${++modeCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
|
||||
|
||||
it('stamps calls with grant > session override > nothing (executor default)', async () => {
|
||||
const ctx = await setupModal('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-stamp-1')
|
||||
const run = { command: 'true', description: 'stamp probe' }
|
||||
await callAs(ctx, agent, run) // no override yet
|
||||
setSandboxMode(session, 'workspace-write')
|
||||
await callAs(ctx, agent, run) // standing override
|
||||
await callAs(ctx, undefined, run) // agent-less caller: no session to fold
|
||||
await callAs(ctx, agent, { ...run, sandbox_permissions: 'danger-full-access', justification: 'grant outranks override' })
|
||||
expect(seen).toEqual([undefined, 'workspace-write', undefined, 'danger-full-access'])
|
||||
})
|
||||
|
||||
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.
|
||||
const ctx = await setupModal('workspace-write', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-esc-narrow')
|
||||
setSandboxMode(session, 'read-only')
|
||||
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'the override is narrower than the default' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(seen).toEqual(['workspace-write'])
|
||||
})
|
||||
|
||||
it('a danger-full-access default still offers the lever to a narrower-switched session', async () => {
|
||||
// Under the default-relative ladder these fields VANISHED (nothing is
|
||||
// wider than the default), stranding a read-only-overridden session
|
||||
// with no escalation path at all.
|
||||
const ctx = await setupModal('danger-full-access', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const schema = ctx.tools.schemas().find(t => t.name === 'bash') as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
expect(schema.parameters.properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
const { agent, session } = sessionAgent('sess-esc-dfa')
|
||||
setSandboxMode(session, 'read-only')
|
||||
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'confined by override under a wide default' })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-widening request against the OVERRIDDEN effective mode without prompting', async () => {
|
||||
const ctx = await setupModal('read-only', { approval: true })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const { agent, session } = sessionAgent('sess-esc-nonwide')
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'already wider via override' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not strictly wider than this call\'s current "danger-full-access" mode')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never stamps an override under a non-sandboxing executor (nothing honors it)', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-stamp-2')
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
await callAs(ctx, agent, { command: 'true', description: 'plain probe' })
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -28,6 +28,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user