From 3f663c91549b94e66994c1ed9e3a93ab85758375 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 9 Jul 2026 16:41:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(modes):=20per-session=20sandbox/approval?= =?UTF-8?q?=20switching=20=E2=80=94=20the=20session=20log=20as=20the=20sto?= =?UTF-8?q?re,=20ACP=20config=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit effective(session) = findLast(the session own knob events)?.value ?? the composition-config default. One log-only event per knob, owned by its domain (bash/sandbox-mode in dsh-bash, approval/policy in dsh-approval), each exporting the same three-piece kit: the event declaration, a pure fold, and THE write path — a switch IS its event; no owner service, no facts map. Restart immunity and multi-session isolation fall out of the log replay by construction. Execution follows the fold on both sides: the bash tool stamps escalation grant > session override > executor default, and the approval seam prepends the never-gate that auto-rejects before any interactive answerer. Visibility is two layers per knob: a per-agent prompt section states the effective value on every request (logged through request/header*, so what-the-model-was-told replays from the log), and an agent/pre-step narrator injects at most one coalesced delta notice with positional attribution (user switch vs operator/config drift). The ACP bridge advertises one capability-gated select per composable knob with currentValue folded per session, validates set_config_option against the closed vocabularies, and anchors idle switches at the next turn prompt-submit under the turn-enclosure contract. --- docs/config-catalog.md | 34 ++- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 6 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 10 +- docs/persistence-catalog.md | 26 +- packages/approval/README.md | 4 +- packages/approval/approval/README.md | 6 +- packages/approval/approval/package.json | 5 + packages/approval/approval/src/index.ts | 187 ++++++++++++- .../approval/approval/tests/approval.spec.ts | 172 +++++++++++- packages/bash/bash/README.md | 4 +- packages/bash/bash/src/index.ts | 1 + packages/bash/bash/src/session-mode.ts | 65 +++++ packages/bash/tool-bash/README.md | 7 +- packages/bash/tool-bash/src/index.ts | 41 ++- packages/bash/tool-bash/tests/tools.spec.ts | 143 +++++++++- packages/ui/acp/README.md | 7 + packages/ui/acp/acp-feature-support.md | 10 +- packages/ui/acp/package.json | 3 + packages/ui/acp/src/index.ts | 180 +++++++++++- packages/ui/acp/tests/config-options.spec.ts | 260 ++++++++++++++++++ packages/ui/acp/tsconfig.json | 3 + pnpm-lock.yaml | 10 + 24 files changed, 1149 insertions(+), 43 deletions(-) create mode 100644 packages/bash/bash/src/session-mode.ts create mode 100644 packages/ui/acp/tests/config-options.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index baae8c65e0..94c4ef8851 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:241`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -130,6 +130,37 @@ Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`] Source: [`packages/core/agent-loop/src/index.ts:36`](../packages/core/agent-loop/src/index.ts) +## `@deepseek-ai/dsh-approval` + +```ts config-catalog +/** Plugin config. All optional — `static Config` supplies the defaults. */ +export interface Config { + /** + * The deployment's default {@link ApprovalPolicy} for sessions without an + * `approval/policy` override — `'ask'` delegates to the composed answerers + * (fail-closed with none); `'never'` auto-rejects every ask without + * prompting (the deterministic CI/unattended stance). + */ + policy?: ApprovalPolicy +} + +/** + * A session's approval policy — what happens to an {@link ApprovalService} + * ask BEFORE any interactive answerer sees it: + * + * - `'ask'` (the default) — delegate to the composed answerers; with none + * composed the chain falls through to the fail-closed `'unavailable'` + * (exactly today's behavior). + * - `'never'` — never prompt anyone: every ask resolves `'rejected'` + * deterministically. The strict headless stance (CI, unattended runs) and + * the only policy value stated in the system prompt — unlike `'ask'`, its + * outcome is knowable without asking, so stating it cannot overclaim. + */ +export type ApprovalPolicy = 'ask' | 'never' +``` + +Source: [`packages/approval/approval/src/index.ts:244`](../packages/approval/approval/src/index.ts) + ## `@deepseek-ai/dsh-bash-local` ```ts config-catalog @@ -1029,7 +1060,6 @@ Source: [`packages/workflow/workflow-workerthread/src/index.ts:69`](../packages/ These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-approval` ([`packages/approval/approval/src/index.ts`](../packages/approval/approval/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ee35a7a586..2f89dd481b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -173,7 +173,7 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch 'approval/request'(this: ApprovalService, req: ApprovalRequest, next: () => Promise): Promise ``` -Source: [`packages/approval/approval/src/index.ts:52`](../../packages/approval/approval/src/index.ts) +Source: [`packages/approval/approval/src/index.ts:64`](../../packages/approval/approval/src/index.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2206adfde6..0793f77a2a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,11 +44,13 @@ Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/i The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless between requests — grants are returned to the caller, never stored here. +Owns the policy tier too (`effective = fold(the session's 'approval/policy' events) ?? config.policy`): a PREPENDED decide-or-delegate gate resolves `'never'` sessions to `'rejected'` before any interactive answerer is prompted, a per-agent prompt section states a `'never'` policy (and only that one — an `'ask'` promise could overclaim an answerer that headless compositions do not have), and an `agent/pre-step` narrator injects at most one coalesced notice when a session's effective policy moved past what the model was last told. + ```ts cordis-catalog async request(req: ApprovalRequest): Promise ``` -Source: [`packages/approval/approval/src/index.ts:168`](../../packages/approval/approval/src/index.ts) +Source: [`packages/approval/approval/src/index.ts:269`](../../packages/approval/approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -75,7 +77,7 @@ onTaskDone(listener: BashTaskListener): () => void Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:61`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:62`](../../packages/bash/bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 57b1f108ca..a5185aead5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,8 +10,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:476`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`approval`](../packages/approval/approval), [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/approval/approval/src/index.ts:52`](../packages/approval/approval/src/index.ts) | [`approval`](../packages/approval/approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `approval/request` | `waterfall` | [`packages/approval/approval/src/index.ts:63`](../packages/approval/approval/src/index.ts) | [`approval`](../packages/approval/approval) (`waterfall`) | [`acp`](../packages/ui/acp), [`approval`](../packages/approval/approval) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 4553e4f421..015f22677a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -161,6 +161,7 @@ flowchart TD pkg_approval --> pkg_brand pkg_approval --> pkg_llm pkg_approval --> pkg_session + pkg_approval --> pkg_system_prompt pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm @@ -180,6 +181,7 @@ flowchart TD pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_approval pkg_tool_bash --> pkg_bash pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox @@ -211,7 +213,9 @@ flowchart TD pkg_hooks_codex --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_approval + pkg_acp --> pkg_bash pkg_acp --> pkg_llm + pkg_acp --> pkg_sandbox pkg_acp --> pkg_session pkg_acp --> pkg_session_persistence pkg_acp --> pkg_tools @@ -320,12 +324,12 @@ flowchart TD | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | -| [`approval`](../packages/approval/approval) | `approval` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`approval`](../packages/approval/approval) | `approval` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | @@ -333,7 +337,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`approval`](../packages/approval/approval), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5d8555e752..557c3d669c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo Types: [CallId](core-data-structures/core.md) -Source: [`packages/approval/approval/src/index.ts:66`](../packages/approval/approval/src/index.ts) +Source: [`packages/approval/approval/src/index.ts:77`](../packages/approval/approval/src/index.ts) #### `approval/decided` — log-only @@ -33,7 +33,17 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly 'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } ``` -Source: [`packages/approval/approval/src/index.ts:77`](../packages/approval/approval/src/index.ts) +Source: [`packages/approval/approval/src/index.ts:88`](../packages/approval/approval/src/index.ts) + +#### `approval/policy` — log-only + +The session's approval policy was switched — log-only, durable, replayable, never in the model transcript (the model learns the policy from the prompt section and the narrator's notices). The LAST such event is the session's override (effectiveApprovalPolicy); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user). + +```ts persistence-catalog +'approval/policy': { policy: ApprovalPolicy } +``` + +Source: [`packages/approval/approval/src/index.ts:100`](../packages/approval/approval/src/index.ts) ### `assistant/*` @@ -61,6 +71,18 @@ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-st Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) +### `bash/*` + +#### `bash/sandbox-mode` — log-only + +The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user; see the tool layer's narrator). + +```ts persistence-catalog +'bash/sandbox-mode': { mode: SandboxMode } +``` + +Source: [`packages/bash/bash/src/session-mode.ts:31`](../packages/bash/bash/src/session-mode.ts) + ### `compact/*` #### `compact/end` — log-only diff --git a/packages/approval/README.md b/packages/approval/README.md index 3778e2012e..9876646d23 100644 --- a/packages/approval/README.md +++ b/packages/approval/README.md @@ -4,6 +4,6 @@ The asking half of permission handling: one seam through which the harness puts | Package | Role | ctx key | |---|---|---| -| `approval/` | The `ApprovalService` mechanism (waterfall dispatch, cancellation, audit events) + the vocabulary (`ApprovalRequest`, `ApprovalOutcome`, `ApprovalRequestId`) | `ctx.approval` | +| `approval/` | The `ApprovalService` mechanism (waterfall dispatch, cancellation, audit events) + the vocabulary (`ApprovalRequest`, `ApprovalOutcome`, `ApprovalRequestId`) + the per-session policy tier (`ApprovalPolicy` `'ask'`/`'never'`, the `'approval/policy'` event fold, the prepend gate — [sandbox RFC § Per-session mode switching](../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)) | `ctx.approval` | -Answerers live with their owners, not here: tests answer with inline scripted listeners, and the ACP bridge answerer is the staged first real one. Consumer today: [`core/tools`](../core/tools/) routes `tools/pre-execute`'s `ask` through the seam (degrading to deny when it is not mounted). +Answerers live with their owners, not here: the ACP bridge ([`ui/acp`](../ui/acp/)) answers for the editor sessions it owns (and switches each session's policy over ACP config options); tests answer with inline scripted listeners. Consumers today: [`core/tools`](../core/tools/) routes `tools/pre-execute`'s `ask` through the seam (degrading to deny when it is not mounted), and the bash tool's sandbox escalation gate ([`bash/tool-bash`](../bash/tool-bash/), [sandbox RFC § Escalation](../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)). diff --git a/packages/approval/approval/README.md b/packages/approval/approval/README.md index 0aaf245f2b..4a652b4464 100644 --- a/packages/approval/approval/README.md +++ b/packages/approval/approval/README.md @@ -6,6 +6,8 @@ The contract in one line: `ctx.approval.request(req)` puts exactly one question The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. -One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry that RFC stages (the asker will live in the bash tool layer). The full design: [the approval-seam RFC](../../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md). +The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers — the prior behavior exactly) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` — in a per-agent prompt section (a "you will be prompted" promise under `'ask'` would overclaim what a headless composition can do; the section scope activates only when `systemPrompt` is composed), and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice, attributed positionally (an override event after the log's last `request/header*` reads `changed by the user`; a config drift reads `changed by the operator/config`). -No answerer ships in this change — every ask fails closed to `unavailable` until one is composed (the ACP bridge answerer is the staged first one). The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome. +One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md). + +Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome. diff --git a/packages/approval/approval/package.json b/packages/approval/approval/package.json index 6120e4d4e9..38ce9533b1 100644 --- a/packages/approval/approval/package.json +++ b/packages/approval/approval/package.json @@ -26,13 +26,18 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/approval/approval/src/index.ts b/packages/approval/approval/src/index.ts index 92b2a0b763..2f819e83e2 100644 --- a/packages/approval/approval/src/index.ts +++ b/packages/approval/approval/src/index.ts @@ -20,15 +20,27 @@ * model-visible transcript: the model only ever sees the tool result the * caller derives from the outcome. * + * The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching): + * `effective = fold(the session's 'approval/policy' events, last one wins) + * ?? config.policy` — the session log is the store, so an override survives + * restart by replay. The service resolves `'never'` sessions to + * `'rejected'` inside `request()` before dispatching any answerer (no + * registration order, including a later `prepend`, can precede it); a prompt section states `'never'` + * (and only `'never'` — an availability promise is unknowable without + * asking); an `agent/pre-step` narrator explains a switch to the model in at + * most one coalesced notice per step. + * * @module @deepseek-ai/dsh-approval */ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' +import z from 'schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' import type { Agent } from '@deepseek-ai/dsh-agent' import type { CallId } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-system-prompt' declare module 'cordis' { interface Context { @@ -78,6 +90,15 @@ declare module '@deepseek-ai/dsh-session' { id: ApprovalRequestId outcome: ApprovalOutcome } + /** + * The session's approval policy was switched — log-only, durable, + * replayable, never in the model transcript (the model learns the policy + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}); + * who asked for it is derivable from position (an event after the log's + * last `request/header*` was a runtime switch by the user). + */ + 'approval/policy': { policy: ApprovalPolicy } } } @@ -113,6 +134,53 @@ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unava /** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */ const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] +/** + * A session's approval policy — what happens to an {@link ApprovalService} + * ask BEFORE any interactive answerer sees it: + * + * - `'ask'` (the default) — delegate to the composed answerers; with none + * composed the chain falls through to the fail-closed `'unavailable'` + * (exactly today's behavior). + * - `'never'` — never prompt anyone: every ask resolves `'rejected'` + * deterministically. The strict headless stance (CI, unattended runs) and + * the only policy value stated in the system prompt — unlike `'ask'`, its + * outcome is knowable without asking, so stating it cannot overclaim. + */ +export type ApprovalPolicy = 'ask' | 'never' + +/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */ +export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never'] + +/** + * The prompt sentence stating a `'never'` policy — visibility for the one + * deterministic policy (see {@link ApprovalPolicy}), and the narrator's parse + * candidate for "what was the model last told": a folded `request/header*` + * system text containing it was assembled under `'never'`; one without it + * (but with any header at all) was assembled under `'ask'`, which states + * nothing. The exact-wording compatibility surface (writer and parser) lives + * entirely in this module; the bash tool description's escalation teaching + * additionally defers to the sentence's opening claim by meaning (see + * `dsh-tool-bash`), so keep the sentence opening with the approvals-disabled + * statement. + */ +const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' + +/** + * The session's approval-policy override: the last `approval/policy` event in + * the log, or undefined when the session never switched (callers apply the + * plugin's configured default). The pure fold — resume needs no catch-up + * machinery because replaying the log IS the state. + * @param events - session events in log order (other event types are skipped). + * @returns the policy of the last switch event, or undefined without one. + */ +export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'approval/policy') return event.data.policy + } + return undefined +} + /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -129,6 +197,19 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean { return false } +/** + * THE write path for a session's approval-policy override: appends exactly + * one `approval/policy` event — the switch IS its event; nothing mutates + * policy state out of band. Takes effect on the session's next ask and next + * prompt assembly (the consumers fold on every read). + * @param session - the session the override belongs to. + * @param policy - the policy every subsequent ask for this session resolves + * under (until the next switch). + */ +export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void { + session.append('approval/policy', { policy }) +} + /** * One concrete permission question. Identifies the action precisely enough * for an answerer to present it and for the audit events to reconstruct what @@ -159,15 +240,100 @@ export interface ApprovalRequest { signal?: AbortSignal } +/** Plugin config. All optional — `static Config` supplies the defaults. */ +export interface Config { + /** + * The deployment's default {@link ApprovalPolicy} for sessions without an + * `approval/policy` override — `'ask'` delegates to the composed answerers + * (fail-closed with none); `'never'` auto-rejects every ask without + * prompting (the deterministic CI/unattended stance). + */ + policy?: ApprovalPolicy +} + /** * The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the * `approval/request` waterfall and audits every ask/outcome pair to the * requesting agent's session log. Stateless between requests — grants are * returned to the caller, never stored here. + * + * Owns the policy tier too (`effective = fold(the session's 'approval/policy' + * events) ?? config.policy`): a PREPENDED decide-or-delegate gate resolves + * `'never'` sessions to `'rejected'` before any interactive answerer is + * prompted, a per-agent prompt section states a `'never'` policy (and only + * that one — an `'ask'` promise could overclaim an answerer that headless + * compositions do not have), and an `agent/pre-step` narrator injects at most + * one coalesced notice when a session's effective policy moved past what the + * model was last told. */ export class ApprovalService extends Service { - constructor(ctx: Context) { + static Config: z = z.object({ + policy: z.union(['ask', 'never'] as const).default('ask'), + }) + + constructor(ctx: Context, public config: Config) { super(ctx, 'approval') + + const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent) + + // Visibility layer 1, scoped on the prompt registry so headless + // compositions mount the seam without it: state the one deterministic + // policy per session. 'ask' renders nothing — stating "you will be + // asked" would overclaim in a composition with no answerer, and absence + // under any logged header is exactly how the narrator reads 'ask' back. + ctx.inject(['systemPrompt'], (scope: Context) => { + scope.systemPrompt.section({ + name: 'approval:policy', + order: 115, + text: (context) => { + const agent = context.agent + // A bare assemble() (tests, diagnostics) has no session to state. + if (agent === undefined) return '' + return effective(agent) === 'never' ? NEVER_SENTENCE : '' + }, + }) + }) + + // Visibility layer 2: the boundary narrator. pre-step runs after prompt + // assembly but before the request history is derived, so the notice is + // seen by THIS step's request: idle-time flip-flops coalesce at the + // turn's first step (net-zero → nothing), and a mid-turn switch is + // narrated no later than the next step. What each session was last told + // is in-memory with a log-derived fallback (the folded header's system + // text), so restarts lose nothing. Attribution is positional: an + // override event after the log's last `request/header*` was a runtime + // switch by the user; otherwise the configured default moved under the + // session (operator/config). + const narrated = new WeakMap() + ctx.on('agent/pre-step', (agent) => { + const session = agent.session + const events = session.events + let overrideIndex = -1 + let headerIndex = -1 + for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) { + const event = events[index] as (typeof events)[number] + if (overrideIndex < 0 && event.type === 'approval/policy') { + overrideIndex = index + } else if (headerIndex < 0 && (event.type === 'request/header' || event.type === 'request/header-delta')) { + headerIndex = index + } + } + // Same fold effectivePolicy performs — override is scanned here anyway + // for POSITIONAL attribution; the default lives once, in the method. + const current = this.effectivePolicy(agent) + const header = session.requestHeader() + const told = narrated.get(session) + ?? (header === undefined ? undefined : header.system?.includes(NEVER_SENTENCE) === true ? 'never' : 'ask') + narrated.set(session, current) + // Cold start (nothing ever told) narrates nothing — the section about + // to go out states the truth, and there is no delta to explain. + if (told === undefined || told === current) return + const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config' + agent.inject( + [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }], + { source: { kind: 'plugin', plugin: 'approval' } }, + ) + }) } /** @@ -205,9 +371,26 @@ export class ApprovalService extends Service { return outcome } + /** + * The session's effective policy: its own `approval/policy` fold, else the + * configured default (the schema already defaulted an omitted policy to + * `'ask'`; the `??` only narrows the optional-input TYPE). + * @param agent - the agent whose session's policy applies. + * @returns the policy every ask for this agent resolves under right now. + */ + private effectivePolicy(agent: Agent): ApprovalPolicy { + return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask' + } + /** Dispatch the waterfall, contained and raced against `req.signal`. */ private async decide(req: ApprovalRequest): Promise { if (req.signal?.aborted) return 'cancelled' + // The 'never' policy is decided HERE, before any dispatch: a listener + // registered with `prepend: true` after this service mounts would sit + // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the + // documented promise that 'never' rejects deterministically regardless + // of registration order — only the service's own request path can. + if (this.effectivePolicy(req.agent) === 'never') return 'rejected' // Enter the promise chain BEFORE dispatching: a listener that throws // SYNCHRONOUSLY (before its first await) must land in the same rejection // path as an async one — `Promise.resolve(call())` would let it escape diff --git a/packages/approval/approval/tests/approval.spec.ts b/packages/approval/approval/tests/approval.spec.ts index c356723438..a732a3161c 100644 --- a/packages/approval/approval/tests/approval.spec.ts +++ b/packages/approval/approval/tests/approval.spec.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import ApprovalService, { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-approval' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-approval' /** * A minimal Agent stand-in — the service only reaches `agent.session.append` @@ -201,3 +203,169 @@ describe('ApprovalService.request', () => { }) }) +describe('approval policy (the approval/policy fold)', () => { + const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' + + /** + * An agent stand-in over a REAL Session — gate, section, and narrator fold + * real events; the opened turn satisfies request()'s enclosure precondition. + */ + 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 } + } + + const preStep = (ctx: Context, agent: Agent): Promise => + ctx.serial('agent/pre-step', agent, 1, 1, '', [], new AbortController().signal) + + /** Append a `request/header` snapshot whose system text is exactly `system`. */ + function appendHeader(session: Session, system: string): void { + session.append('request/header', { header: { config: { model: 'mock' }, system }, reason: 'initial' }) + } + + it('folds to the last event, or undefined without one', () => { + const { session } = sessionAgent('sess-fold') + expect(effectiveApprovalPolicy(session.events)).toBeUndefined() + setApprovalPolicy(session, 'never') + setApprovalPolicy(session, 'ask') + expect(effectiveApprovalPolicy(session.events)).toBe('ask') + expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) + }) + + it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => { + // Direct construction bypasses the plugin schema (the SystemPrompt-test + // precedent for covering a defaulted Config field's type-narrowing ??). + const ctx = new Context() + const service = new ApprovalService(ctx, {}) + const { agent } = sessionAgent('sess-bare-config') + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + await expect(service.request({ agent, toolName: 'echo' })).resolves.toBe('allowed-once') + }) + + it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent } = sessionAgent('sess-syncthrow') + ctx.on('approval/request', () => { throw new Error('sync bug') }) + await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable') + }) + + it('a never config rejects deterministically without consulting any answerer', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService, { policy: 'never' }) + const consulted = vi.fn() + ctx.on('approval/request', (_req, next) => { consulted(); return next() }) + const { agent, session } = sessionAgent('sess-gate-1') + await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') + expect(consulted).not.toHaveBeenCalled() + // The audit pair still lands on the session log. + expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1) + expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1) + }) + + it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { + const ctx = new Context() + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + await ctx.plugin(ApprovalService, { policy: 'never' }) + const { agent } = sessionAgent('sess-gate-2') + await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') + }) + + it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => { + // Cordis prepend unshifts ahead of every existing listener, including + // any gate LISTENER the service could register — which is exactly why + // the 'never' decision lives inside request() instead. The eager grant + // below must never be consulted. + const ctx = new Context() + await ctx.plugin(ApprovalService, { policy: 'never' }) + const consulted = vi.fn() + ctx.on('approval/request', () => { consulted(); return Promise.resolve('allowed-once') }, { prepend: true }) + const { agent, appended } = fakeAgent() + await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') + expect(consulted).not.toHaveBeenCalled() + expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided']) + }) + + it('a session override outranks the configured default, in both directions', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService, { policy: 'never' }) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const { agent, session } = sessionAgent('sess-gate-3') + setApprovalPolicy(session, 'ask') + await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once') + setApprovalPolicy(session, 'never') + await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') + }) + + it('states never (and only never) in the prompt, per session', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ApprovalService) + const askAgent = sessionAgent('sess-sect-ask').agent + const { agent: neverAgent, session } = sessionAgent('sess-sect-never') + setApprovalPolicy(session, 'never') + const sectionFor = async (context: object) => + (await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text + expect(await sectionFor({ agent: askAgent })).toBe('') + expect(await sectionFor({ agent: neverAgent })).toBe(NEVER_SENTENCE) + // A bare assemble (no agent) has no session to state. + expect(await sectionFor({})).toBe('') + }) + + it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent, session, injected } = sessionAgent('sess-narr-1') + await preStep(ctx, agent) + expect(injected).toEqual([]) + setApprovalPolicy(session, 'never') + setApprovalPolicy(session, 'ask') + setApprovalPolicy(session, 'never') + await preStep(ctx, agent) + expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).']) + await preStep(ctx, agent) + expect(injected).toHaveLength(1) + setApprovalPolicy(session, 'ask') + setApprovalPolicy(session, 'never') + await preStep(ctx, agent) + expect(injected).toHaveLength(1) + }) + + it('reads what the model was told back from the folded header text after a restart', async () => { + // A session whose last request carried the never sentence resumes under + // an ask default: the narrator attributes the change to the operator. + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent, session, injected } = sessionAgent('sess-narr-2') + appendHeader(session, `persona\n\n${NEVER_SENTENCE}`) + await preStep(ctx, agent) + expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).']) + }) + + it('narrates a config default drift over a sentence-less header (told = ask by absence)', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService, { policy: 'never' }) + const { agent, session, injected } = sessionAgent('sess-narr-3') + appendHeader(session, 'persona only') + await preStep(ctx, agent) + expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).']) + }) + + it('a pinned override survives a default change silently', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService, { policy: 'never' }) + const { agent, session, injected } = sessionAgent('sess-narr-4') + appendHeader(session, 'persona only') + setApprovalPolicy(session, 'ask') + appendHeader(session, 'persona only') + await preStep(ctx, agent) + expect(injected).toEqual([]) + }) +}) diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index e245148e93..5a1aff3588 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -30,8 +30,8 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input (the staged escalation and per-session overrides of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md) ride it); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing. +`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing. -The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts. +The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts. `stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 396249d3d7..3b28756078 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -19,6 +19,7 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts' export { BashTaskId, OwnerToken } from './types.ts' +export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' export type { BashExecRequest, BashExecSpec, diff --git a/packages/bash/bash/src/session-mode.ts b/packages/bash/bash/src/session-mode.ts new file mode 100644 index 0000000000..03ad6e3d7c --- /dev/null +++ b/packages/bash/bash/src/session-mode.ts @@ -0,0 +1,65 @@ +/** + * Per-session sandbox-mode override: the session log as the store. A runtime + * switch (an ACP `session/set_config_option`, a test scenario) is recorded as + * one `bash/sandbox-mode` event on the session it applies to; + * `effective = fold(events) ?? the executor's configured default`, so an + * override survives restart by replay, two sessions can never see each + * other's state, and there is no external config store. The event is + * log-only (the `approval/*` precedent): the model learns the mode from the + * prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`, + * never from the event itself. EXECUTION honors the fold in the tool layer — + * it stamps the effective mode onto each call's `BashExecRequest.sandboxMode` + * (weakest-precedence: an escalation grant for the call outranks it) — the + * executor itself stays a config-fixed default plus per-call overrides. + * + * @module dsh-bash/session-mode + */ + +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * The session's sandbox mode was switched — log-only (like `approval/*`; + * NOT a surface event, carries no `surfaceOp`): durable and replayable, + * never in the model transcript. The LAST such event is the session's + * override ({@link effectiveSandboxMode}); who asked for it is derivable + * from position (an event after the log's last `request/header*` was a + * runtime switch by the user; see the tool layer's narrator). + */ + 'bash/sandbox-mode': { mode: SandboxMode } + } +} + +/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */ +export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] + +/** + * The session's sandbox-mode override: the last `bash/sandbox-mode` event in + * the log, or undefined when the session never switched (callers apply the + * executor's configured default). The pure fold — resume needs no catch-up + * machinery because replaying the log IS the state. + * @param events - session events in log order (other event types are skipped). + * @returns the mode of the last switch event, or undefined without one. + */ +export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'bash/sandbox-mode') return event.data.mode + } + return undefined +} + +/** + * THE write path for a session's sandbox-mode override: appends exactly one + * `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode + * state out of band. Takes effect on the session's next bash call and next + * prompt assembly (the consumers fold on every read). + * @param session - the session the override belongs to. + * @param mode - the mode every subsequent bash call in this session runs + * under (until the next switch). + */ +export function setSandboxMode(session: Session, mode: SandboxMode): void { + session.append('bash/sandbox-mode', { mode }) +} diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index a45962e29c..1d2b646add 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -17,7 +17,7 @@ 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 strictly wider mode a denied command needs (`read-only` offers `workspace-write`/`danger-full-access`; `workspace-write` offers `danger-full-access`; nothing above `danger-full-access`, so the fields vanish). | +| `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()`. @@ -52,5 +52,8 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks 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/proposed/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../approval/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 executor's default), 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. +On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../approval/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/proposed/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. diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index da6c5c3325..25775a26b7 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -41,8 +41,16 @@ * 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 is the - * sandbox RFC's staged follow-up. + * 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 */ @@ -59,7 +67,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' // stays optional at runtime, same pattern as dsh-tools' ask routing). import type {} from '@deepseek-ai/dsh-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' +import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' export const name = 'tool-bash' @@ -472,6 +480,19 @@ export function apply(ctx: Context): void { 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 @@ -493,10 +514,12 @@ export function apply(ctx: Context): void { 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, 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 = defaultMode as SandboxMode + // 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`) } @@ -564,9 +587,11 @@ export function apply(ctx: Context): void { // 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) - : undefined + : 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. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9ae80f28fb..4988713425 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -4,8 +4,9 @@ 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' @@ -1135,7 +1136,7 @@ describe('sandbox rendering', () => { 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 } = {}) { + 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) @@ -1144,7 +1145,7 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => { 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) + if (opts.approval === true) await ctx.plugin(ApprovalService, opts.policy !== undefined ? { policy: opts.policy } : {}) await ctx.plugin(ToolBash) return { ctx, bash } } @@ -1327,6 +1328,23 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => { 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 }> = [] + 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() @@ -1337,3 +1355,122 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => { 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, { runnerCommand: PASSTHROUGH_RUNNER }) + 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('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('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('allowed-once')) + const schema = ctx.tools.schemas().find(t => t.name === 'bash') as unknown as { parameters: { properties: Record } } + 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]) + }) + +}) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 0820db0cb5..8facac9e0d 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -32,11 +32,18 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../../approval/approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" | +| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | ## Multi-session The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there. +## Session config options + +The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6. + +Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload. + ## Per-session cwd Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.) diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index ff27c3f2d5..0be43b4db6 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -26,7 +26,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | | `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | | `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). | -| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled yet — the sandbox RFC's per-session mode switching stages them ([sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)). | +| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)). | | model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. | | `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. | | `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. | @@ -86,7 +86,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | | `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | -| `config_option_update` | S | ❌ | ✅ | ✅ | No config options yet. | +| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md). | | `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). | | `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. | @@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult ## 6. Session modes / config options / models -Session modes and config options are not modeled yet: the sandbox RFC's per-session mode switching ([sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)) stages config options as the surface (modes are slated for removal in ACP v2, and one mode list cannot carry two orthogonal knobs). Runtime model selection is also not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector). +Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector). ## 7. Content blocks @@ -141,7 +141,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them Ranked by how commonly the reference adapters ship them and how much UX they unlock: 1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. -2. **Modes / config options / model selection** — the permission round-trip landed with the approval seam; the config surface (`sandbox-mode`/`approval-policy` options) is the sandbox RFC's staged config phase. +2. **Modes / config options / model selection** — the permission round-trip landed with the approval seam; the config surface (`sandbox_mode`/`approval_policy` options) is the sandbox RFC's config phase. 3. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. 4. **Slash commands** (`available_commands_update`). 5. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 8c9f731363..0f3efc7110 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -29,7 +29,9 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-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-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -46,6 +48,7 @@ "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5959f0f9c4..a7b13ac13b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -62,7 +62,10 @@ import { type PlanEntry, type PromptRequest, type PromptResponse, + type SessionConfigOption, type SessionNotification, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, type Stream, type StopReason, } from '@agentclientprotocol/sdk' @@ -71,6 +74,10 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' +import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-approval' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-approval' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto @@ -313,6 +320,19 @@ interface SessionRecord { turn: number | undefined logWatermark: number } | undefined + /** + * Config switches accepted while the session was IDLE, not yet anchored in + * its log. The turn-enclosure contract makes a bare between-turns append + * invalid (the JSONL backend treats a post-`turn/end` tail as crash + * garbage, and dev invariants throw), so an idle switch waits here and is + * anchored at the next turn's prompt-submit — before anything in that + * turn assembles a prompt or runs a call, and last write + * per knob wins (an idle flip-flop anchors as one event). Until anchored, + * the switch lives only in bridge memory: the set/new/load responses + * overlay it truthfully, and a restart before the next turn reverts it — + * which `session/load` then reports honestly from the log's fold. + */ + pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy } } /** @@ -593,6 +613,103 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- The ACP Agent method surface ----------------------------------------- + /** + * The session config options this composition can honor, with current + * values folded from the AGENT'S OWN session log (`effectiveSandboxMode` / + * `effectiveApprovalPolicy` — the log is the per-session store, so a + * `session/load` reports a resumed session's overrides with no catch-up + * machinery), overlaid with the record's not-yet-anchored pending switches + * (see {@link SessionRecord.pendingSwitches}). Capability-gated like every + * advertised lever: the sandbox option exists only when the mounted + * executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval + * option only when the approval seam is composed — both read + * opportunistically so this bridge keeps working in compositions without + * them. + */ + const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => { + const options: SessionConfigOption[] = [] + const defaultMode = ctx.get('bash')?.sandboxMode + if (defaultMode !== undefined) { + options.push({ + id: 'sandbox-mode', + name: 'Sandbox', + description: 'The file sandbox mode bash commands in this session run under.', + category: 'mode', + type: 'select', + currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode, + options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })), + }) + } + const approval = ctx.get('approval') + if (approval !== undefined) { + options.push({ + id: 'approval-policy', + name: 'Approvals', + description: 'ask: permission prompts reach you; never: they are rejected automatically.', + type: 'select', + // `?? 'ask'` also shields against a provided stand-in whose config + // never went through the plugin schema (tests do this). + currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask', + options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })), + }) + } + return options + } + + /** + * Whether the session's log currently has an open turn — the last boundary + * event is a `turn/start`. Decides whether a config switch may append NOW + * (enclosed) or must wait for the next turn (see + * {@link SessionRecord.pendingSwitches}). Read from the LOG, not + * `agent.status`: status stays `running` across the gap between two queued + * turns, where a bare append would still land outside any turn. + */ + const isTurnOpen = (agent: Agent): boolean => { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + const type = (events[index] as SessionEvent).type + if (type === 'turn/start') return true + if (type === 'turn/end') return false + } + return false + } + + /** + * Anchor a record's pending switches into its (just-opened) turn, last + * write per knob — skipping a value the session already effectively has, + * so a net-zero idle flip-flop anchors NOTHING (the log records switches, + * not select clicks). + */ + const flushPendingSwitches = (rec: SessionRecord): void => { + const pending = rec.pendingSwitches + rec.pendingSwitches = {} + const events = rec.agent.session.events + if (pending.sandboxMode !== undefined + && pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) { + setSandboxMode(rec.agent.session, pending.sandboxMode) + } + if (pending.approvalPolicy !== undefined + && pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) { + setApprovalPolicy(rec.agent.session, pending.approvalPolicy) + } + } + + // Idle-accepted switches anchor at the next turn's prompt-submit: the turn + // is open (the seam fires inside it, per drained message — the first flush + // empties the slot, later ones no-op), the loop has not yet assembled + // anything for it, and — unlike appending from inside a `session/event` + // listener — this seam fires OUTSIDE any log emit, so peer listeners + // (the dev invariants, persistence) observe the anchored events in strict + // log order. A turn with no prompt (an idle inject's one-shot injection + // turn) leaves the switch pending — it runs no step, so nothing executes + // or assembles under a stale value. + ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { + const sessionId = bySession.get(agent) + const rec = sessionId === undefined ? undefined : sessions.get(sessionId) + if (rec !== undefined) flushPendingSwitches(rec) + return next() + }) + const makeAgent = (connection: AgentSideConnection): AcpAgent => { conn = connection return { @@ -646,8 +763,10 @@ export function apply(ctx: Context, config: AcpConfig): void { presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined, + pendingSwitches: {}, }) - return Promise.resolve({ sessionId }) + const configOptions = configOptionsFor(handle.agent) + return Promise.resolve({ sessionId, ...configOptions.length > 0 ? { configOptions } : {} }) }, async loadSession(params: LoadSessionRequest): Promise { @@ -723,6 +842,7 @@ export function apply(ctx: Context, config: AcpConfig): void { presenter: makePresenter(), terminalEnabled, inflight: undefined, + pendingSwitches: {}, } sessions.set(sessionId, record) // Replay the persisted event log to the client as session/update. Use @@ -746,7 +866,8 @@ export function apply(ctx: Context, config: AcpConfig): void { for (const event of agent.session.events) { streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal) } - return {} + const configOptions = configOptionsFor(agent) + return configOptions.length > 0 ? { configOptions } : {} } finally { loadingIds.delete(sessionId) } @@ -802,6 +923,61 @@ export function apply(ctx: Context, config: AcpConfig): void { return Promise.resolve() }, + setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + assertOpen() + const rec = requireSession(SessionId(params.sessionId)) + // Both advertised options are selects, so the boolean-shaped variant of + // the request is a protocol misuse regardless of configId. + if (typeof params.value !== 'string') { + throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`) + } + // The setters append ONE log-only event on this session's own log — + // the log is the store (the sandbox RFC § Per-session mode switching): execution, the + // prompt section, and the narrator all fold it from there, and a + // resumed session reports the override back through + // configOptionsFor. A switch while a turn is OPEN anchors + // immediately (the next step sees it); an IDLE switch waits in + // pendingSwitches for the next `turn/start` (turn-enclosure: a bare + // between-turns append would be dropped as crash tail on reload). + // Values are validated against the same closed lists the options + // advertised; an id this composition never advertised (or an unknown + // one) rejects. + switch (params.configId) { + case 'sandbox-mode': { + const defaultMode = ctx.get('bash')?.sandboxMode + if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) { + throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`) + } + const value = params.value as SandboxMode + // A no-op switch (the value the session already shows — pending, + // else fold, else default) is acknowledged without recording + // anything: clients that re-push current selections on session + // start must not mint override events out of thin air. + const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode + if (value === current) break + if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value) + else rec.pendingSwitches.sandboxMode = value + break + } + case 'approval-policy': { + const approval = ctx.get('approval') + if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) { + throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`) + } + const value = params.value as ApprovalPolicy + const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask' + if (value === current) break + if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value) + else rec.pendingSwitches.approvalPolicy = value + break + } + default: + throw invalidParams(`unknown config option ${JSON.stringify(params.configId)}`) + } + // The spec requires the COMPLETE refreshed config state in the response + // (a change may cascade); ours are independent, but the contract holds. + return Promise.resolve({ configOptions: configOptionsFor(rec.agent, rec.pendingSwitches) }) + }, } } diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts new file mode 100644 index 0000000000..46ff7c13e5 --- /dev/null +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -0,0 +1,260 @@ +/** + * Session config options over the bridge: the two per-session knobs + * (`sandbox-mode`, `approval-policy`) advertised from composition capability, + * their current values folded from each session's own log, switching via + * `session/set_config_option` (one log-only event per switch — the log is the + * store), and a resumed session reporting its overrides back on + * `session/load` with no catch-up machinery. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import ApprovalService from '@deepseek-ai/dsh-approval' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-approval' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +/** + * The REAL local executor reporting a confining default — `sandboxMode` is + * the documented capability override point (`dsh-bash-sandbox` overrides it + * the same way), so the bridge sees exactly what a sandboxing composition + * advertises without this suite dragging in a kernel sandbox stack. + */ +class SandboxedLocalExecutor extends LocalBashExecutor { + override get sandboxMode(): SandboxMode { + return 'read-only' + } +} + +/** The exact option payloads the bridge advertises (pinned verbatim). */ +function sandboxOption(currentValue: SandboxMode): object { + return { + id: 'sandbox-mode', + name: 'Sandbox', + description: 'The file sandbox mode bash commands in this session run under.', + category: 'mode', + type: 'select', + currentValue, + options: [ + { value: 'read-only', name: 'read-only' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + } +} + +function approvalOption(currentValue: ApprovalPolicy): object { + return { + id: 'approval-policy', + name: 'Approvals', + description: 'ask: permission prompts reach you; never: they are rejected automatically.', + type: 'select', + currentValue, + options: [ + { value: 'ask', name: 'ask' }, + { value: 'never', name: 'never' }, + ], + } +} + +describe('acp bridge — session config options', () => { + let storageDir: string + let h: BridgeHarness | undefined + let loader: BridgeHarness | undefined + + beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-config-')) }) + afterEach(async () => { + if (h) await h.dispose() + if (loader) await loader.dispose() + h = loader = undefined + await rm(storageDir, { recursive: true, force: true }) + }) + + /** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */ + async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable[0]>['script'] } = {}): Promise { + const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} }) + // The dev invariants police turn-enclosure: an idle switch that appended + // outside a turn would throw right here in the suite, not in production. + await harness.ctx.plugin(Invariants) + await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 }) + await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {}) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + return harness + } + + it('advertises no configOptions in a composition with neither knob', async () => { + h = await makeBridgeHarness({ storageDir }) + await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + expect(res.configOptions).toBeUndefined() + }) + + it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => { + h = await makeBridgeHarness({ storageDir, withBash: true }) + await h.ctx.plugin(ApprovalService) + await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + expect(res.configOptions).toEqual([approvalOption('ask')]) + }) + + it('advertises both knobs with capability-derived currents (config default included)', async () => { + h = await bothKnobs({ policy: 'never' }) + const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')]) + }) + + it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => { + h = await bothKnobs({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) + expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')]) + const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) + expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')]) + + // Idle: nothing in the log yet — turn-enclosure forbids a bare append + // (the dev invariants in this suite would throw), so the switch lives on + // the record until a turn opens. + const session = h.ctx.agents.list()[0]?.session + expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) + + // The next turn anchors both switches inside itself, one event per knob. + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) + const events = session?.events ?? [] + expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }]) + expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }]) + const turnStart = events.findIndex(e => e.type === 'turn/start') + const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode') + expect(turnStart).toBeGreaterThanOrEqual(0) + expect(anchored).toBeGreaterThan(turnStart) + }) + + it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => { + h = await bothKnobs({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) + const events = h.ctx.agents.list()[0]?.session.events ?? [] + expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }]) + // Idle again AFTER a completed turn (the log now ends in turn/end): a new + // switch pends rather than appending outside the closed turn. + const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' }) + expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' }) + expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1) + }) + + it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => { + h = await bothKnobs({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + // Re-pushing the composition default (what clients that echo current + // selections on session start do) must not mint an override event. + const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' }) + expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' }) + // Re-sending a PENDING value keeps the pending switch alive (it is what + // the session shows), rather than cancelling it. + await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) + const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) + expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' }) + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) + const events = h.ctx.agents.list()[0]?.session.events ?? [] + expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0) + expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }]) + }) + + it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => { + h = await bothKnobs({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) + const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' }) + expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' }) + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) + const events = h.ctx.agents.list()[0]?.session.events ?? [] + expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0) + }) + + it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => { + h = await bothKnobs({ script: ['hang'] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + // Give the loop a tick to open the turn (the turns.spec hang idiom). + await new Promise(resolve => setTimeout(resolve, 30)) + await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) + const events = h.ctx.agents.list()[0]?.session.events ?? [] + const turnStart = events.findIndex(e => e.type === 'turn/start') + const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode') + expect(turnStart).toBeGreaterThanOrEqual(0) + expect(anchored).toBeGreaterThan(turnStart) + expect(events.some(e => e.type === 'approval/policy')).toBe(true) + await h.client.cancel({ sessionId }) + await hung + }) + + it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => { + h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) + h.ctx.provide('approval', { config: {} } as unknown as InstanceType) + await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + expect(res.configOptions).toEqual([approvalOption('ask')]) + const sessionId = res.sessionId + // The schema-less config also shields the no-op guard ('ask' by the ?? fallback)… + const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' }) + expect(echo.configOptions).toEqual([approvalOption('ask')]) + // …and the anchor-time comparison: a real switch under the stand-in still anchors. + await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) + const events = h.ctx.agents.list()[0]?.session.events ?? [] + expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }]) + }) + + it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => { + h = await makeBridgeHarness({ storageDir }) + await h.ctx.plugin(ApprovalService) + await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' })) + .rejects.toThrow(/unknown config option/) + // sandbox-mode exists as a concept but THIS composition never advertised it. + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })) + .rejects.toThrow(/unknown sandbox-mode value/) + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true })) + .rejects.toThrow(/select; boolean values are not accepted/) + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' })) + .rejects.toThrow(/unknown approval-policy value/) + }) + + it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => { + h = await bothKnobs() + const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) + // B sees its own composition defaults, not A's pending switch... + const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' }) + expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')]) + // ...and A keeps its own state, untouched by B's. + const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) + expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')]) + }) + + it('session/load reports a resumed session\'s overrides from its own log', async () => { + h = await bothKnobs({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) + // One turn checkpoints the log (the switch events flush with it). + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] }) + await h.dispose() + h = undefined + + loader = await bothKnobs() + const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) + expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')]) + }) +}) diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 7c80e7459c..9aea0732d6 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../approval/approval" }, + { + "path": "../../sandbox/sandbox" + }, { "path": "../../bash/bash" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 624ae3407d..06bda5af34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,10 @@ importers: version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/approval/approval: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -89,6 +93,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -1077,6 +1084,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session