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/pr224-rfc-rewrite
# Conflicts: # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/cookbook/extension-cookbook.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md # docs/tool-execution-pipeline.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/README.md # packages/core/agent-loop/README.md # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/tests/tools.spec.ts # packages/core/tools/tsconfig.json # packages/ui/acp/src/index.ts # scripts/doc-budgets.manifest.json # scripts/gen-cordis-catalog.ts # scripts/gen-doc-graphs.ts
This commit is contained in:
@@ -4,7 +4,7 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Under a sandboxing executor it additionally contributes the per-agent `env:bash-sandbox` section (order 110) stating each session's EFFECTIVE mode, and the pre-step narrator — see [Per-session mode](#per-session-mode-switching-and-visibility).
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -17,14 +17,16 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the 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()`.
|
||||
|
||||
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`
|
||||
|
||||
@@ -46,6 +48,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 the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions
|
||||
## Permissions and escalation
|
||||
|
||||
`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,8 +23,10 @@
|
||||
"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-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"
|
||||
@@ -32,9 +34,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-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "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,10 +58,16 @@
|
||||
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 { 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'
|
||||
@@ -55,16 +78,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')
|
||||
}
|
||||
@@ -74,6 +93,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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +116,75 @@ 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]`. '
|
||||
+ '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
|
||||
@@ -100,9 +197,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)
|
||||
|
||||
@@ -115,6 +218,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 /
|
||||
@@ -355,14 +471,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]`. '
|
||||
+ '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: {
|
||||
@@ -375,12 +564,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.
|
||||
@@ -390,6 +600,7 @@ export function apply(ctx: Context): void {
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
@@ -401,7 +612,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,
|
||||
@@ -428,6 +639,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,21 +1,40 @@
|
||||
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'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import 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)
|
||||
@@ -100,6 +119,7 @@ class LossyReadBashExecutor extends BashExecutor {
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,6 +922,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> {
|
||||
@@ -978,3 +999,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])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user