diff --git a/docs/rfc/proposed/feature/2026-07-06-approval-seam.md b/docs/rfc/proposed/feature/2026-07-06-approval-seam.md index 76defba74e..321ae55190 100644 --- a/docs/rfc/proposed/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/proposed/feature/2026-07-06-approval-seam.md @@ -23,11 +23,11 @@ One `cordis.yml` entry mounts the seam; not loading it is the opt-out — consum # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in the `examples/sandbox-acp-agent` composition this design lands with) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. +The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in `examples/sandbox-acp-agent`, the composition staged to land with this design's implementation) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. -One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: +One ask under this composition — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once. This is the arc the implementation's `escalation-approved` snapshot scenario is to record verbatim: ``` tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt", @@ -43,7 +43,7 @@ approval/decided {"outcome": "allowed-once"} tool/result "escalated" — this one call ran under the wider mode; the grant died with it ``` -The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothing executes, and the model's result carries the asker's verbatim fail-closed text (`the user rejected escalating this command to "workspace-write"`). A hook's `permissionDecision: ask` rides the identical wire; only the asker and its deny texts differ (§ Ask routing in dsh-tools). Headless, the same request skips the prompt entirely and settles `unavailable`. +Its `escalation-rejected` twin is to end in `{"outcome": "rejected"}` instead: nothing executes, and the model's result carries the asker's verbatim fail-closed text (`the user rejected escalating this command to "workspace-write"`). A hook's `permissionDecision: ask` rides the identical wire; only the asker and its deny texts differ (§ Ask routing in dsh-tools). Headless, the same request skips the prompt entirely and settles `unavailable`. ### Design detail @@ -79,9 +79,9 @@ One new package, no cycles: `dsh-approval` peers on `cordis`, `dsh-session` (eve ### Testing -Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation) and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. +Coverage named at plan time, per tier ([testing policy](../../../testing.md)). Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation) and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. -Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). A hook-driven recorded `ask` (extending the hook matrix's `hook-cc-pretool-ask` with a mounted ApprovalService) remains undone; its deny texts are pinned verbatim at the unit tier. +Snapshot tier, landing with the staged example: the harness gains scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed), and the seam's wire gets recorded end to end in that example's suite — both escalation branches driving `session/request_permission` through this seam over scripted answers (grant and rejection), and a recorded `mode-switching` scenario pinning the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). A hook-driven recorded `ask` (extending the hook matrix's `hook-cc-pretool-ask` with a mounted ApprovalService) remains undone; its deny texts are pinned verbatim at the unit tier. ## Alternatives considered diff --git a/docs/rfc/proposed/feature/2026-07-06-sandbox.md b/docs/rfc/proposed/feature/2026-07-06-sandbox.md index 7a9f514fb3..1b024fee1a 100644 --- a/docs/rfc/proposed/feature/2026-07-06-sandbox.md +++ b/docs/rfc/proposed/feature/2026-07-06-sandbox.md @@ -16,7 +16,7 @@ One seam, one per-platform chain of local backends, one consumer, and two levers ### How a deployment uses it -Three `cordis.yml` entries turn an unconfined coding agent into the sandboxed product path; the `examples/sandbox-acp-agent` composition this design lands with is exactly this tree: +Three `cordis.yml` entries turn an unconfined coding agent into the sandboxed product path; `examples/sandbox-acp-agent`, the composition staged to land with this design's implementation, is exactly this tree: ```yaml - id: sandbox @@ -36,7 +36,7 @@ Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under mode]` marker plus standing instructions not to retry around them; when a wider mode exists, the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry; the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: a `sandbox-mode` and an `approval-policy` config-option select per session (each advertised only when its knob is composable), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated. -The product path, concretely (the escalation arc is verbatim from the recorded `escalation-approved` scenario; the denial leg is pinned on the real-kernel e2e tier): +The product path, concretely (the escalation arc is what the staged `escalation-approved` snapshot scenario is to record verbatim; the denial leg lands on the real-kernel e2e tier): ``` tool/result … [sandbox: file access denied under read-only mode] ← the write RAN; the kernel refused it @@ -55,7 +55,7 @@ Reject instead and nothing executes: the result is the verbatim `the user reject - Runtime OS subprocesses exist at exactly two sites: the `ctx.bash` seam's single spawn (`packages/bash/bash-local/src/run.ts`; hook commands flow through `ctx.bash`, so bash confinement covers them transitively) and `subagent-acp`'s child agents (`packages/subagent/subagent-acp/src/run.ts`) — the second consumer that makes a shared seam due rather than preemptive under the [capability seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md)'s "don't split preemptively" rule. - Everything else executes inside the harness process (fs is in-process `node:fs`, web is in-process `fetch`, every `ToolDefinition.execute()` closes over `ctx`): an OS sandbox wraps `execve` and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change. -- `tools/pre-execute` (`allow`/`deny`/`ask`) exists, with `ask` serviced by [the approval seam](2026-07-06-approval-seam.md); the fs intent gates are version guards with no mode input yet. +- `tools/pre-execute` (`allow`/`deny`/`ask`) exists; `ask` degrades to deny today, and [the approval seam proposal](2026-07-06-approval-seam.md) is staged to service it. The fs intent gates are version guards with no mode input yet. - `dsh-bash`'s request/spec split (`BashExecRequest` → `resolve()` → `BashExecSpec`) carries per-call fields the way escalation needs — `owner` is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak `SandboxMode`, so a per-call policy field adds no dependency edge. - The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the sandbox example suite's `mode-switching` fixture. @@ -138,10 +138,12 @@ Each phase gets its full design when picked up, validated against the code at th ### Testing +Coverage named at plan time, per tier ([testing policy](../../../testing.md)). + - Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. - Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (`examples/sandbox-acp-agent`): the real `cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. - With-key e2e (`examples/sandbox-acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/sandbox-acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. +- Snapshot tier (`examples/sandbox-acp-agent/tests/acp.snapshot.ts`, landing with the example): the keyless config-option wire; a recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both escalation branches recorded over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. ## Alternatives considered