feat(mode): the access cap — plan mode composes with the sandbox instead of banning bash

A ModeDefinition may declare access: the widest sandbox access shell
commands run under while the mode holds, on the SANDBOX_MODES ladder.
The bash seam gains the resolution point to hang it on: BashExecutor.
resolveMode(session) folds override ?? default and dispatches the new
bash/resolve-mode waterfall; dsh-tool-bash consults it at both the
stamping site and the escalation baseline; dsh-mode's clamp listener
takes the ladder minimum per call. Two independent log folds compose at
read time — the mode never writes the sandbox knob, so the two switch
in any order and the knob re-emerges intact on exit.

The built-in plan definition ships access: read-only with the bash trio
allowlisted CONDITIONALLY: both policy layers admit bash/bash_output/
bash_kill only while a confining executor is mounted (an unconfinable
shell cannot honor the cap), and a bash call carrying sandbox_permissions
under a cap is denied at the gate — no widening mid-mode; the widened
step belongs in the plan.

examples/plan-acp-agent swaps bash-local for sandbox-local +
bash-sandbox (workspace-write default, clamped read-only inside plan)
plus the approval seam; the re-recorded plan-mode arc runs a real cat
inside plan under the clamped sandbox, and modes-advertise now pins the
sandbox-mode and approval config options. RFC amended to the landed
shape (access cap section, orthogonality FAQ, deferred item resolved
into effects self-declaration).
This commit is contained in:
kingwl
2026-07-12 22:51:09 +08:00
parent 88db403d9f
commit 99650a201b
31 changed files with 1844 additions and 1237 deletions

View File

@@ -4,7 +4,7 @@ The coding agent as an ACP server with **session modes** composed — the live c
## What it demonstrates
`session/new` advertises the mode picker (`default` / `plan`); the editor's `session/set_mode` switches the session, applied at the next turn boundary. The default mode carries the full composition — `bash`, `read`/`write`/`edit`, `todo_write`, `ask_user_question` — while plan mode narrows it to the read-only allowlist (`read`, `todo_write`, `ask_user_question`, `exit_plan_mode` here — this tree loads no web tools) plus the plan-mode guidance section; every call outside the allowlist is denied at `tools/pre-execute` with a reason that steers it back to planning, and a blocking decision goes to the user through `ask_user_question`. The model leaves by presenting its plan through `exit_plan_mode`: the plan markdown renders as the tool's call card, the review question arrives as an elicitation form (approve / keep planning, free text welcome), and a keep-planning answer returns the feedback to the model verbatim.
`session/new` advertises the mode picker (`default` / `plan`) plus the sandbox-mode and approval config options; the editor's `session/set_mode` switches the session, applied at the next turn boundary. The default mode carries the full composition — `bash` under a `workspace-write` sandbox, `read`/`write`/`edit`, `todo_write`, `ask_user_question` — while plan mode narrows it to the read-only allowlist plus the plan-mode guidance section. The bash tools STAY available in plan: plan's `access: read-only` cap clamps the sandbox resolution per call (a `bash/resolve-mode` waterfall listener), so exploration commands run for real while a write is denied by the sandbox itself — and the session's own sandbox-mode knob is never written, so it re-emerges intact on exit. Sandbox escalation (`sandbox_permissions`) is denied inside plan — the widened step belongs in the plan; in the default mode it raises a real `session/request_permission` prompt through the approval seam. Every call outside the allowlist is denied at `tools/pre-execute` with a reason that steers it back to planning, and a blocking decision goes to the user through `ask_user_question`. The model leaves by presenting its plan through `exit_plan_mode`: the plan markdown renders as the tool's call card, the review question arrives as an elicitation form (approve / keep planning, free text welcome), and a keep-planning answer returns the feedback to the model verbatim.
## Run
@@ -16,4 +16,4 @@ Drive it from Zed or any ACP client; the mode picker appears on the session. Swi
## Tests
`pnpm run test:snapshot` replays three scenarios keyless. `modes-advertise` (authored): the `modes` advertisement on `session/new`, both `session/set_mode` round-trips with their optimistic `current_mode_update`, and the loud rejection of an unknown mode id, as committed wire bytes. `plan-mode` (recorded, the header pin): the full arc — setMode(plan), the plan-shaped initial header, the plan presented via `exit_plan_mode`, a scripted elicitation approve, the boundary-flushed `mode/set` back and the widened fallback header, then a real edit under the restored toolset. `plan-mode-reject` (recorded): the keep-planning branch, whose corrective `isError` carries the reviewer's free-text feedback verbatim and leaves the session in plan mode. The gate's deny texts stay pinned at the unit tier (`packages/mode/mode/tests`).
`pnpm run test:snapshot` replays three scenarios keyless (the recorded bash re-executes for real under the host's sandbox runner — Seatbelt on macOS, bwrap on Linux CI). `modes-advertise` (authored): the `modes` advertisement and both config options on `session/new`, both `session/set_mode` round-trips with their optimistic `current_mode_update`, and the loud rejection of an unknown mode id, as committed wire bytes. `plan-mode` (recorded, the header pin): the full arc — setMode(plan), the plan-shaped initial header, a real `cat` run inside plan under the clamped read-only sandbox, the plan presented via `exit_plan_mode`, a scripted elicitation approve, the boundary-flushed `mode/set` back and the widened fallback header, then a real edit under the restored toolset. `plan-mode-reject` (recorded): the keep-planning branch, whose corrective `isError` carries the reviewer's free-text feedback verbatim and leaves the session in plan mode. The gate's deny texts and the sandbox-denial marker stay pinned at the unit tier (`packages/mode/mode/tests`, `packages/bash/tool-bash/tests` — a recorded denial's stderr would be the backend's dialect and replay only where it was recorded).