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

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/bash.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	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/README.md
#	packages/bash/README.md
#	packages/bash/bash-local/src/index.ts
#	packages/bash/bash/README.md
#	packages/bash/bash/package.json
#	packages/bash/bash/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/bash/tests/service.spec.ts
#	packages/bash/bash/tsconfig.json
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/bash/tool-bash/tsconfig.json
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-core/README.md
#	packages/core/agent-core/package.json
#	packages/core/agent-core/src/index.ts
#	packages/core/agent-core/tests/agent-core.spec.ts
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	packages/hooks/hook-protocol/tests/runner.spec.ts
#	packages/ui/acp-agent/tests/acp-agent.spec.ts
#	packages/ui/stdio-agent/tests/stdio-agent.spec.ts
#	pnpm-lock.yaml
#	scripts/doc-budgets.manifest.json
#	scripts/gen-tool-catalog.ts
This commit is contained in:
Yichen Jiang
2026-07-11 23:04:27 +08:00
193 changed files with 11980 additions and 803 deletions

View File

@@ -1,18 +1,14 @@
# @deepseek-ai/dsh-tool-bash
The model-facing `bash` tool, registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. Background runs are generic tasks: the tool registers the started process with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and the model collects/stops them through the shared `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`) — this package registers no companion tools of its own.
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
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 `ctx.tasks` runtime is looked up at call time: a background call without it fails loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`).
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): check the `[exit code: N]` marker on every result and investigate failures before moving on.
## Config
## Tools
| key | default | meaning |
|---|---|---|
| `enableRunInBackground` | `true` | Expose `run_in_background` in the schema. Disabled, the parameter is absent entirely (schema and capability never disagree), the description says background execution is unavailable, and a caller that forces the key anyway is refused at execution time (the arg validator allows undeclared keys, so the schema omission alone is not enforcement). |
## The `bash` tool
### `bash`
| Arg | Type | Notes |
|---|---|---|
@@ -20,24 +16,30 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
| `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. Present only when `enableRunInBackground` allows. |
| `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()`.
Foreground 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.
## Background runs as tasks
A `run_in_background` call refuses an already-aborted `exec.signal`, starts the process through the seam, registers `{ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput }` with `ctx.tasks`, and returns `started background task <id>`. The tool-call signal is deliberately NOT wired to the process after that — the parent step may end while the command runs; cancellation belongs to `task_kill` and the runtime's owner-disposal cleanup. The producer mapping is exported for tests: `processOutcome` (a killed process → `killed` with the signal as detail; everything else → `completed` with `exit code: N` — a nonzero exit is reported, not failed) and `renderProcessRead` (the incremental delta, plus a `[some output was dropped from memory; full output: …]` notice with spill paths on lossy reads). Ownership, isolation, listing, polling, waiting, kill semantics, and completion notices are all the task runtime's — see [`packages/tasks`](../../tasks/README.md).
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
## UI presentation
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — its output is read via `task_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
## The tool builds its request from named args only
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control.
## 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.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-bash",
"description": "Model-facing bash tools (bash, bash_output, bash_kill) over the DeepSeek Harness bash executor seam",
"description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -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-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -36,9 +38,11 @@
"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-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",

View File

@@ -30,11 +30,15 @@ import type { Context } from 'cordis'
import z from 'schemastery'
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 type {} from '@deepseek-ai/dsh-tasks'
import type { BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashProcess, BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
@@ -62,13 +66,17 @@ export const Config: z<Config> = z.object({
* the DSL has no vocabulary for: non-empty strings and a positive, finite
* timeout.
*/
function validateBashArgs(args: {
interface BashToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
}): void {
sandbox_permissions?: string
justification?: string
}
function validateBashArgs(args: BashToolArgs): void {
if (args.command.trim().length === 0) {
throw new Error('invalid command: expected a non-empty string')
}
@@ -78,6 +86,36 @@ 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')
}
}
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
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. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ background
if (escalationModes.length === 0) return base
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. The approval prompt raised by that retry is how the user consents. Never escalate speculatively, and treat a rejected escalation as final for that command.'
}
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
@@ -92,9 +130,10 @@ 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.
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the model-facing text: output body (or `(no output)`), then any sandbox/timeout/signal/exit markers, each on its own line.
*/
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)
@@ -107,6 +146,12 @@ export function renderResult(result: BashRunResult): string {
if (body.length === 0) body = '(no output)'
const markers: string[] = []
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
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 /
@@ -130,14 +175,30 @@ export function renderResult(result: BashRunResult): string {
* rendering (`(no new output)`) is the control surface's job, not this
* producer's. Exported for tests.
* @param read - one incremental read from the process handle.
* @returns the delta text with any loss notice appended.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderProcessRead(read: BashProcessRead): string {
if (!read.lossy) return read.delta
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
const notice = `[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`
if (read.delta.length === 0) return notice
return `${read.delta}${read.delta.endsWith('\n') ? '' : '\n'}${notice}`
export function renderProcessRead(
read: BashProcessRead,
sandbox?: BashSandboxInfo,
escalationModes: readonly SandboxMode[] = [],
): string {
const notices: string[] = []
if (read.lossy) {
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
}
if (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`)
if (escalationModes.length > 0) {
notices.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]')
}
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}
/**
@@ -300,6 +361,42 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
export function apply(ctx: Context, config: Config): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
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,
reason: `escalate sandbox to ${mode}: ${justification}`,
...exec.signal ? { signal: exec.signal } : {},
})
switch (outcome) {
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')
}
}
// The bash tool's cross-call HABIT, which the per-tool description cannot
// carry (it describes one call): the exit-code marker is only useful
@@ -312,14 +409,7 @@ export function apply(ctx: Context, config: Config): void {
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. '
+ (backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
+ 'read its output with `task_output` and stop it with `task_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'),
description: bashDescription(backgroundEnabled, escalationModes),
parameters: {
command: { type: 'string', required: true, description: 'The bash command to execute.' },
description: {
@@ -334,8 +424,19 @@ export function apply(ctx: Context, config: Config): void {
...backgroundEnabled ? {
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). 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
@@ -343,11 +444,15 @@ export function apply(ctx: Context, config: Config): void {
// 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.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
...sandboxMode !== undefined ? { sandboxMode } : {},
}
if (args.run_in_background === true) {
// The schema omission is advertising, not enforcement — the arg
@@ -380,7 +485,7 @@ export function apply(ctx: Context, config: Config): void {
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderProcessRead(proc.readOutput()),
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
@@ -391,7 +496,7 @@ export function apply(ctx: Context, config: Config): void {
...exec.signal ? { signal: exec.signal } : {},
}))
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,

View File

@@ -1,7 +1,7 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
@@ -12,6 +12,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome, renderProcessRead, renderResult } from '@deepseek-ai/dsh-tool-bash'
@@ -83,6 +85,82 @@ async function callUntilText(
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
}
class RecordingSandboxExecutor extends BashExecutor {
readonly modes: Array<string | undefined> = []
override get sandboxMode() {
return 'read-only' as const
}
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode ?? 'read-only',
}
}
run(spec: BashExecSpec): Promise<BashRunResult> {
this.modes.push(spec.sandboxMode)
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
})
}
start(spec: BashExecSpec): BashProcess {
this.modes.push(spec.sandboxMode)
return {
command: spec.command,
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
readOutput: () => ({ delta: '', lossy: false }),
kill: () => false,
}
}
}
async function setupSandboxed(withApproval = false) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingSandboxExecutor)
if (withApproval) await ctx.plugin(ApprovalService)
await ctx.plugin(ToolBash)
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
}
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access'): Agent {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } })
return {
id: 'sandbox-agent',
session: {
header: { version: 0, id: 'sandbox-session', createdAt: 0 },
events,
append: (type: string, data: Record<string, unknown>) => {
const event = { type, data }
events.push(event)
return event
},
},
} as unknown as Agent
}
describe('bash tool', () => {
it('returns stdout for a successful command', async () => {
const ctx = await setup()
@@ -324,7 +402,7 @@ describe('background execution through the task runtime', () => {
class CountingStartExecutor extends BashExecutor {
starts = 0
resolve(request: BashExecRequest): BashExecSpec {
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
start(spec: BashExecSpec): BashProcess {
@@ -366,7 +444,7 @@ describe('background execution through the task runtime', () => {
class LeakProbeExecutor extends BashExecutor {
starts = 0
resolve(request: BashExecRequest): BashExecSpec {
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
@@ -428,6 +506,104 @@ describe('background execution through the task runtime', () => {
})
})
describe('sandbox escalation through the generic task producer', () => {
const escalate = {
command: 'true',
description: 'test escalation',
sandbox_permissions: 'workspace-write',
justification: 'the command needs workspace writes',
}
it('advertises the sandbox fields and validates their pairing', async () => {
const { ctx } = await setupSandboxed()
const schema = ctx.tools.schemas().find(item => item.name === 'bash')!
const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
expect(schema.description).toContain('approval prompt')
for (const args of [
{ command: 'true', description: 'd', sandbox_permissions: 'workspace-write' },
{ command: 'true', description: 'd', justification: 'why' },
{ command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
]) {
expect((await call(ctx, 'bash', args)).isError).toBe(true)
}
})
it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
const plain = await setup()
expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition')
const { ctx } = await setupSandboxed(true)
const prompted = vi.fn()
ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
expect(text(result)).toContain('not strictly wider')
expect(prompted).not.toHaveBeenCalled()
const malformed = sandboxAgent()
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
type: 'bash/sandbox-mode',
data: { mode: 'unknown-mode' },
})
expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
})
it('fails closed when approval cannot be routed', async () => {
const withoutService = await setupSandboxed()
expect(text(await call(withoutService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval service')
const withService = await setupSandboxed(true)
expect(text(await call(withService.ctx, 'bash', escalate))).toContain('no agent to route')
expect(text(await call(withService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval channel')
})
it.each([
['rejected', 'user rejected'],
['cancelled', 'was cancelled'],
] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
const { ctx, bash } = await setupSandboxed(true)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
const result = await call(ctx, 'bash', escalate, sandboxAgent())
expect(text(result)).toContain(message)
expect(bash.modes).toEqual([])
})
it('runs a granted foreground or background call under the approved mode', async () => {
const { ctx, bash } = await setupSandboxed(true)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const agent = sandboxAgent()
ctx.agents.register(agent)
const foreground = await ctx.tools.execute({
callId: CallId('sandbox-signal'),
name: 'bash',
arguments: escalate,
agent,
signal: new AbortController().signal,
})
expect(foreground.isError).toBe(false)
const background = await call(ctx, 'bash', { ...escalate, run_in_background: true }, agent)
expect(text(background)).toBe('started background task bash-1')
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
})
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
const { ctx, bash } = await setupSandboxed(true)
const agent = sandboxAgent('workspace-write')
await call(ctx, 'bash', { command: 'true', description: 'ordinary' }, agent)
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
})
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
const result = await call(ctx, 'bash', escalate, sandboxAgent())
expect(text(result)).toContain('unreachable variant in ApprovalOutcome')
})
})
describe('renderProcessRead', () => {
const base: BashProcessRead = { delta: 'out\n', lossy: false }
@@ -459,6 +635,20 @@ describe('renderProcessRead', () => {
expect(renderProcessRead({ delta: 'tail\n', lossy: true }))
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
})
it('appends settled sandbox denial and runner-failure facts', () => {
expect(renderProcessRead(base, { mode: 'read-only', denied: true }, ['workspace-write']))
.toContain('[sandbox: escalation available')
expect(renderProcessRead({ delta: 'tail', lossy: false }, { mode: 'read-only', denied: true }))
.toBe('tail\n[sandbox: file access denied under read-only mode]')
const runner = renderProcessRead(
{ delta: '', lossy: false },
{ mode: 'workspace-write', denied: true, runnerFailed: true },
['danger-full-access'],
)
expect(runner).toContain('sandbox runner itself failed under workspace-write mode')
expect(runner).not.toContain('file access denied')
})
})
describe('processOutcome', () => {
@@ -585,6 +775,23 @@ describe('renderResult', () => {
expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
.toBe('tail\n[output truncated; full output: (unavailable)]')
})
it('reports sandbox denials before exit status and hints only when escalation is advertised', () => {
const result: BashRunResult = {
exitCode: 1,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: '', truncated: false },
stderr: { text: 'denied', truncated: false },
sandbox: { mode: 'read-only', denied: true },
}
expect(renderResult(result)).toMatch(/denied under read-only mode\]\n\[exit code: 1\]$/)
expect(renderResult(result, ['workspace-write'])).toContain('[sandbox: escalation available')
expect(renderResult({ ...result, sandbox: { mode: 'read-only', denied: false } }, ['workspace-write']))
.not.toContain('[sandbox:')
})
})
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
@@ -746,6 +953,7 @@ describe('the model-facing bash tool builds its request from named args only (no
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
sandboxMode: request.sandboxMode,
}
}
run(): Promise<BashRunResult> {

View File

@@ -31,6 +31,15 @@
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../sandbox/sandbox"
}
]
}