mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'codex/simp-shared-acp-test-launcher' into codex/simp-trim-hook-snapshot-noise
This commit is contained in:
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Two gaps shared one root. First, provider KV caching (DeepSeek context caching) is prefix-based — a request pays full price only for the tokens after the longest stored prefix it matches — yet nothing in the request pipeline stated, checked, or measured prefix stability: every registered [`PromptSection`](../../../../packages/core/system-prompt/src/index.ts) happened to be static, the tool set happened not to change mid-session, no listener happened to rewrite requests. A single time-interpolating section would have silently multiplied context cost, and no test or metric would have moved. Second, and deeper: the session log — the system's single source of truth — could not actually answer *what the model saw*. It recorded every message but never the system prompt, the tool schemas, or even which model; the mutable `agent/request` waterfall handed listeners the whole `GenerateOptions` to rewrite per call; replay equivalence was therefore a property of the plugin population, not of the design.
|
||||
The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded.
|
||||
|
||||
The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this RFC answers is how to get that discipline without giving up event-sourcing.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text
|
||||
|
||||
### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam
|
||||
|
||||
Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed.
|
||||
Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change.
|
||||
|
||||
The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`):
|
||||
|
||||
@@ -62,7 +62,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed*
|
||||
|
||||
### Head-anchoring: one auto checkpoint, always at the head
|
||||
|
||||
`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.)
|
||||
Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. `shadowedRange` is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. `shadowedSeqs` records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints.
|
||||
|
||||
### Approximate convergence invariant
|
||||
|
||||
@@ -85,7 +85,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
|
||||
|
||||
### Checkpoint framing + incremental merge (backend-private)
|
||||
|
||||
The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `<compacted-summary>…</compacted-summary>` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all.
|
||||
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary.
|
||||
|
||||
### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy
|
||||
|
||||
@@ -121,7 +121,7 @@ Two failure paths, both documented:
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path.
|
||||
- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request.
|
||||
- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net.
|
||||
- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build.
|
||||
- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn.
|
||||
- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request.
|
||||
- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task.
|
||||
- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue.
|
||||
Model-facing tool order followed plugin registration order, which depends on concurrent module loading for otherwise independent plugins. That race produced different request headers in CI and snapshot recordings. Because order affects request bytes, caching, and the durable header, it needs an explicit deterministic policy.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -17,7 +17,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w
|
||||
- The list must contain the rest entry exactly once and no duplicate names.
|
||||
- When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration.
|
||||
|
||||
The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. The waterfall therefore starts from one deterministic list; when a listener leaves that order intact, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check inherit it with no new loop change.
|
||||
`assemble()` canonicalizes provider tools before the `system-prompt/assemble` waterfall, removing registration-order variance at its source. The waterfall starts from this deterministic list; unchanged order then flows into the request header, frozen request, and reconstruction checks without loop-specific ordering logic.
|
||||
|
||||
Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay).
|
||||
|
||||
@@ -46,4 +46,4 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it:
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`.
|
||||
System-prompt tests cover lexicographic default order, listed/rest placement, provider-order independence, shared names, invalid lists, unknown or reserved names, the canonical pre-waterfall list, and the rule that listener-added tools are not re-sorted. Loop tests pin identical logged and dispatched order across registration permutations, forwarding through agent-core and both apps, deep-frozen requests, and balanced turn failure with no step, header, or adapter call for an unknown configured name. Snapshot replay keeps the full canonical list only in the pinned `text-turn` header; other fixtures continue to use `{{tools}}`.
|
||||
|
||||
@@ -38,30 +38,13 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm
|
||||
|
||||
Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests.
|
||||
|
||||
What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under <mode> mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); 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: one `Permissions` config-option select per session (advertised when the `dsh-permission` preset layer is composed; each preset bundles a sandbox mode and an approval policy and writes through to both knob events — a knob state outside the table derives a switch-away-only `custom` current), 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):
|
||||
|
||||
```
|
||||
tool/result … [sandbox: file access denied under read-only mode] ← the write RAN; the kernel refused it
|
||||
tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
|
||||
"sandbox_permissions": "workspace-write",
|
||||
"justification": "the user asked to write escalated.txt in the workspace"}
|
||||
→ the editor is prompted on this very call (session/request_permission through the approval seam); Allow once
|
||||
tool/result "escalated" — THIS call ran under workspace-write and its result facts say so; the session stays read-only
|
||||
```
|
||||
|
||||
Reject instead and nothing executes: the result is the verbatim `the user rejected escalating this command to "workspace-write"`, and the teaching makes that final — no re-ask.
|
||||
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed, ACP exposes one `Permissions` select whose presets write both knob events; unmatched knobs appear as switch-away-only `custom`. Only a switch to the deterministic `'never'` approval policy is stated in the prompt and narrated.
|
||||
|
||||
### Design detail
|
||||
|
||||
#### Grounding — verified against the code
|
||||
#### Scope grounding
|
||||
|
||||
- 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](../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.
|
||||
- `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 acp example suite's `permission-switching` fixture.
|
||||
OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the human decision.
|
||||
|
||||
#### The seam: `ctx.sandbox`
|
||||
|
||||
@@ -75,33 +58,33 @@ Left open, for the phase that needs them: whether network restriction arrives as
|
||||
|
||||
#### Local backends and the shipped launcher
|
||||
|
||||
`dsh-sandbox-local` selects BY PLATFORM, once per lifetime, and caches the verdict: each platform names its runner chain, a chain of one is selected directly — probing arbitrates between candidates, and a sole candidate leaves nothing to arbitrate — and a chain of several is probed FUNCTIONALLY in preference order (build and enforce a real profile, never `--version` — a present-but-unusable `bwrap` must fail its probe). Linux: `bwrap` first (its mount profile is closest to the mode vocabulary: whole tree read-only, fresh `/dev`+`/proc`, `workspace-write` adds an ephemeral `/tmp` and rebinds the workspace root; deliberately no `--unshare-pid` and no network claim), else the npm-distributed `landlock-run` Landlock launcher. darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile — allow-default with `(deny file-write*)` plus write allow-lists, every granted root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`) — unprobed, the sole candidate. A platform with no chain fails closed at `confine()`; an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap carries `runnerFailureSignatures` (the runner's own error prefix, which also matches the shell's runner-not-found message) so the consumer classifies that as a SANDBOX failure, never a task failure: on either path the command neither runs unconfined nor slips through as a plain failure. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile — chain and probes skipped; it doubles as the deterministic fake-runner seam for keyless tests. It is not exempt from fail-closed execution: its wrap carries argv0-scoped outer-shell failure shapes (`exec: <argv0>: not found`, `<argv0>: No such file or directory`, `<argv0>: Permission denied`) as its runner-failure dialect, so a missing or unexecutable configured runner classifies as a sandbox failure like every other rung — never as a failing command, and never as a denial.
|
||||
`dsh-sandbox-local` selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes `bwrap` then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so `dsh-bash-sandbox` can distinguish a denied file effect from a broken sandbox. `runnerCommand` skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined.
|
||||
|
||||
The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro <path>` / `--rw <path>` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing.
|
||||
|
||||
The launcher lives in its own repository and reaches the harness as the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) (the per-platform-package pattern of `node-addon-require-builtin` and esbuild): an entry package — `dsh-sandbox-local`'s one runtime dependency — plus per-platform binary packages selected at install time by npm's `os`/`cpu` fields. The entry package owns the launcher's CLI contract end to end (`launcherPath()` resolution with a never-existing fallback, the functional `probe()`, `grantArgs()` flag spelling), versioned together with the binary so probe-report parsing can never drift against it; the harness keeps only the policy side, `landlockProfileArgs()` mapping the mode vocabulary to grants. Native-only per-architecture builds, pack gates (binary presence, executability, ELF architecture), and the byte-pinned publish rehearsal are that repository's release pipeline; this repo's Landlock CI legs install the published family from the registry — the true consumer path — and prove real-kernel confinement through it.
|
||||
The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned.
|
||||
|
||||
FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together.
|
||||
|
||||
Profile parity is honest rather than identical: under Landlock, `read-only` grants `--ro /` plus `--rw /dev/null` (the node, not `/dev` — the host's `/dev/shm` is a persistent shared tmpfs), and `workspace-write` grants the HOST `/tmp` where bwrap's is ephemeral; under Seatbelt, `read-only` likewise grants only the `/dev/null` literal, and `workspace-write` grants the host `/tmp` plus the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools; omitting it would deny what the mode promises). Every wrap carries the rung's denial dialect (`denialSignatures`: EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) so consumers match the active backend rather than a cross-runner union. Enforcement is honest per ABI level: an older kernel enforces the subset its ABI governs (path truncate is ungoverned before ABI v3), the probe's report line distinguishes the cases, and every confined result carries the structured `enforcement: 'full' | 'partial'` fact — refusing partial enforcement would deny the fallback to precisely the older-kernel hosts that need it. The bwrap and Seatbelt profiles govern every promised file effect by construction, so their passing probes always report `full`.
|
||||
Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement.
|
||||
|
||||
#### The bash consumer
|
||||
|
||||
`dsh-bash-sandbox` extends `LocalBashExecutor` (spawn mechanics, process-group kills, spill files, background tasks, credential scrub inherited verbatim) and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A sandbox denial is a RESULT FACT, not an error: the command RAN and the kernel refused a file operation, so `result.sandbox.denied` is orthogonal to `exitCode`/`signal`. Classification is conservative text inference over the collected stderr tail against the WRAP's own dialect, so a backend is never credited with a denial text its kernel does not speak (bare EPERM under a Linux runner names non-file boundaries the mode vocabulary does not govern); the known residual false positive is non-sandbox text in the active dialect (an ssh auth failure under Landlock, a refused `kill` under Seatbelt), and a structured runner signal wins once one exists. A RUNNER failure is the opposite of a denial and outranks it in classification (a runner's error text can itself contain denial words): the wrap's `runnerFailureSignatures` matching a failed run means the sandbox broke and the command NEVER RAN — the foreground path re-throws it as the structured `SANDBOX_UNAVAILABLE` error (the late twin of the confine-time throw, carrying the runner's first stderr line), a settled background task stamps `sandbox.runnerFailed` and `bash_output` renders its own marker — so a broken sandbox can never read as a failing command.
|
||||
`dsh-bash-sandbox` reuses local process execution and asks `ctx.sandbox` to wrap the exact bash argv. A kernel denial is a result fact independent of exit status and is inferred only from the selected wrap's stderr dialect. Runner failure outranks denial because it means the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background tasks set `sandbox.runnerFailed` for `bash_output`. This keeps broken confinement distinct from both task failure and an enforced denial.
|
||||
|
||||
The model's view is result facts only: the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). No prompt section states the sandbox mode (§ Per-session modes).
|
||||
|
||||
#### Escalation: one approved wider retry after a denial
|
||||
|
||||
The seam level is mechanism only. `BashExecRequest` carries `sandboxMode?: SandboxMode`, an explicit per-call policy input; `BashExecSpec` carries it required-but-nullable (the `owner` pattern: a forgotten field is a visible `undefined`, and `resolve()` is the one explicit defaulting step); `BashExecutor` exposes the capability fact `get sandboxMode(): SandboxMode | undefined` — `undefined` in the base class, the configured mode in `SandboxBashExecutor` — so the tool layer can advertise only what the mounted executor honors: composition truth, not configuration. The seam honors ANY explicit mode, including a narrower one; the wider-only ladder is escalation policy and lives in the tool. A non-sandboxing executor (`dsh-bash-local`) carries the field verbatim and confines nothing — the field reaching it means the caller bypassed the tool's gate, and its honest behavior stays unconfined execution, not a guess at enforcement it does not have.
|
||||
`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined.
|
||||
|
||||
`SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. The `danger-full-access` branch, the confine call, and the result facts all key off the spec's mode, and the per-task facts map carries each task's mode alongside its wrap facts (`notifyTaskDone()` stamps from the map entry): one escalated call — foreground or background — reports the mode it ACTUALLY ran under while every neighbor keeps its own.
|
||||
|
||||
The tool gate advertises two extra parameters exactly when `ctx.bash.sandboxMode` reports a confining mode at registration: `sandbox_permissions`, an enum of the closed escalation-target vocabulary — `workspace-write`/`danger-full-access`, every mode a session could ever escalate TO — and `justification`, required together with it. The enum is deliberately NOT cut down to the modes wider than the executor's DEFAULT: schemas are registry-global while the effective mode is per-session and switchable, so a default-relative ladder strands a session overridden NARROWER than the default (with a `danger-full-access` default and a `read-only` override it would advertise nothing at all — confined, but with no lever). Strict widening is instead enforced at EXECUTION against the call's effective mode (session override ?? executor default): a request that is not strictly wider fails closed with its own text and prompts no one. An escalating call resolves approval BEFORE anything executes — no `ctx.approval` composed, or no agent on the execution, fails closed with its own text; otherwise `ctx.approval.request({ agent, toolName: 'bash', callId, reason, signal })` with the audit-self-contained reason `escalate sandbox to ${mode}: ${justification}`, while the UI attaches the prompt to the already-streamed call (the command is visible there; the approval RFC's no-arguments rule holds). The four outcomes map to distinct results: `allowed-once` stamps `sandboxMode` onto the bash request and proceeds; `rejected`, `cancelled`, and `unavailable` each produce their own error text, so the model can tell a human "no" from a dismissed prompt from a missing channel. The grant is consumed by the very call that asked; nothing is stored.
|
||||
When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
|
||||
|
||||
The tool description teaches — and a denied result itself prompts — the SAME-TURN flow when the fields exist: on a denial a wider mode would cure, escalate immediately in that turn by retrying the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification`, without detouring through chat to ask first — the approval prompt raised by the retry IS how the user consents. Never speculatively: an escalation is grounded in a real denial — normally the one the command just hit, up front only when the session already denied the same access — and a prompt stating approvals are disabled turns the exception off entirely; a rejected escalation is final for that command. Denial-grounding is deliberately model discipline plus human judgment, not harness bookkeeping — the human sees the exact command and justification on the prompt (see Alternatives for why hard-matching is rejected). No new session events anywhere: the attempt is an ordinary `tool/call` whose logged arguments carry the two fields, the decision is the approval seam's `approval/asked`/`approval/decided` pair, the outcome is an ordinary `tool/result` whose sandbox facts name the mode it ran under. The asker lives in `dsh-tool-bash`, NOT the executor: a transport seam has no `agent`, no `callId`, and no business asking humans questions.
|
||||
Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor seam has neither the agent nor call id required for user interaction.
|
||||
|
||||
Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; how cancellation behaves while an approval prompt is pending; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`.
|
||||
Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question `allow_always` grant storage must answer before that option can be advertised; and how escalation is defined for `run_in_background` denials that arrive via `bash_output`.
|
||||
|
||||
#### Per-session modes: the session log as the store
|
||||
|
||||
@@ -198,15 +181,13 @@ Costs and accepted limits:
|
||||
- **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants.
|
||||
- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
|
||||
- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority.
|
||||
- **An idle switch lives in bridge memory until the next turn anchors it.** A crash in that window reverts it (reported honestly on `session/load`), and a session that never runs another turn never persists it — accepted, with the loop-owned idle commit turn named as future work if durability becomes a requirement.
|
||||
- **An idle switch lives in bridge memory until the next prompt submission anchors it.** A crash in that window reverts it (reported on `session/load`), and a session that never submits another prompt never persists it — accepted, with a loop-owned idle commit turn left as future work if durability becomes required.
|
||||
- **The approval narrator's restart baseline parses prompt prose.** The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice.
|
||||
- **The approval section is still a dynamic prompt surface** (a `'never'` switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale `'never'` is worse. The sandbox knob no longer touches the prompt at all.
|
||||
- **The model may hold a stale belief about the sandbox mode** (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry.
|
||||
|
||||
## FAQ
|
||||
|
||||
Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that.
|
||||
|
||||
- **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request.
|
||||
- **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined.
|
||||
- **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases).
|
||||
@@ -214,7 +195,7 @@ Behavioral and usage questions only — every "why not X?" design question lives
|
||||
- **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam.
|
||||
- **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively. fs/web/todo execute in-process, where an `execve` wrapper is mechanically meaningless; their `read-only` semantics arrive with the cross-family deferred phase, and until then the contract says bash-only honestly.
|
||||
- **Does a granted escalation persist, or cover background tasks?** Neither: the grant is consumed by the very call that asked (foreground or background), that one call reports the mode it actually ran under, and every neighbor keeps its own. How escalation should be DEFINED for a background denial that only surfaces later via `bash_output` is left open in § Escalation.
|
||||
- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next turn's `agent/prompt-submit`, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode.
|
||||
- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next `agent/prompt-submit` inside its open turn, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode.
|
||||
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution).
|
||||
- **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user