fix(review): reconcile sandbox and approval contracts
This commit is contained in:
64
docs/core-data-structures/approval.md
Normal file
64
docs/core-data-structures/approval.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# User Approval
|
||||
|
||||
The user-approval seam of [dsh-user-approval](../../packages/ui/user-approval) answers one question: may this specific action proceed? It owns the shared request/outcome vocabulary, the `ctx.approval` dispatch service, the `approval/request` answerer waterfall, the log-only audit pair, and the per-session `ask`/`never` policy. UI channels such as [dsh-acp](../../packages/ui/acp) provide answerers; callers such as [dsh-tools](../../packages/core/tools) and [dsh-tool-bash](../../packages/bash/tool-bash) consume the closed outcome and fail closed unless it is `allowed-once`.
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
## Identity and outcome
|
||||
|
||||
Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call, session, or agent ids.
|
||||
|
||||
```ts type-equiv
|
||||
type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
```
|
||||
|
||||
`ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate.
|
||||
|
||||
```ts type-equiv
|
||||
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
```
|
||||
|
||||
## Per-session policy
|
||||
|
||||
`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
|
||||
|
||||
```ts type-equiv
|
||||
type ApprovalPolicy = 'ask' | 'never'
|
||||
```
|
||||
|
||||
The prompt section states the deterministic `never` behavior and records either policy with a source-owned marker. The pre-step narrator reads that marker from the logged request header after restart; it does not infer state from deployment persona prose. An idle ACP switch is held in the bridge until the next `turn/start`, because approval audit and policy events must remain turn-enclosed for durable replay.
|
||||
|
||||
## Approval request
|
||||
|
||||
`ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift.
|
||||
|
||||
```ts type-equiv
|
||||
interface ApprovalRequest {
|
||||
/**
|
||||
* The agent on whose behalf the question is asked. Routes the question (a
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
* events on its session log.
|
||||
*/
|
||||
agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
toolName: string
|
||||
/**
|
||||
* The exact tool call being decided, when the asker has one — lets a UI
|
||||
* attach the prompt to the tool call it already streamed.
|
||||
*/
|
||||
callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
## Dispatch and audit
|
||||
|
||||
`ctx.approval.request(req)` requires the requesting session to be inside an open turn. It appends `approval/asked`, obtains one outcome, appends the matching `approval/decided`, and resolves with that outcome. The `never` policy is enforced inside the service before waterfall dispatch, so even an answerer registered later with `prepend` cannot bypass it. Answerers return an outcome when they own the request or call `next()` to delegate; the first answer occupies the single decision slot.
|
||||
|
||||
The audit events are log-only and do not enter the model transcript. Model-visible behavior is the caller's derived tool result, while the request header records the prompt policy that the model actually saw. Service disposal removes its prompt section and pre-step narrator together; answerer listeners are independently effect-bound to their owning plugins.
|
||||
@@ -152,13 +152,9 @@ interface CollectedOutput {
|
||||
}
|
||||
```
|
||||
|
||||
## File sandbox: `SandboxMode` / `BashSandboxInfo`
|
||||
## File sandbox: `BashSandboxInfo`
|
||||
|
||||
A sandbox-consuming executor (`dsh-bash-sandbox`) confines commands under its executor-configured mode — fixed at config time for the executor's lifetime; a runtime/per-session mode surface is the sandbox RFC's config phase, not current behavior; the mode/enforcement vocabulary is owned by the `@deepseek-ai/dsh-sandbox` seam (whose provider wraps the executor's argv), and the mode governs FILE effects only — network and process visibility are deliberately not restricted, because a backend that cannot honestly enforce them must not pretend to:
|
||||
|
||||
```ts type-equiv
|
||||
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
```
|
||||
A sandbox-consuming executor (`dsh-bash-sandbox`) exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each agent session's durable `bash/sandbox-mode` override, stamps the effective mode onto the request, states it in the per-agent prompt, and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned and cataloged by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md), whose provider wraps the executor's argv; modes govern FILE effects only, not network or process visibility.
|
||||
|
||||
A sandboxed run always reports the facts it executed under on `BashRunResult.sandbox`: `denied` is the executor's conservative classification of a failure as sandbox-caused (a failed exit whose stderr carries a filesystem-permission signature — never a clean exit or a signal kill), read from the collected stderr tail; `enforcement` reports how completely the selected backend governs the mode's file effects (`SandboxEnforcement = 'full' | 'partial'` — `partial` when an older Landlock ABI governs only a subset of the requested accesses; absent under `danger-full-access`, where nothing is confined); `runnerFailed` marks the opposite of a denial — the sandbox RUNNER itself failed and the command never ran (stamped only on settled background tasks; a foreground run surfaces the same condition as the thrown `SANDBOX_UNAVAILABLE` error):
|
||||
|
||||
@@ -197,7 +193,7 @@ interface BashSandboxInfo {
|
||||
}
|
||||
```
|
||||
|
||||
One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the sandbox seam) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend: sandboxed modes fail CLOSED instead of silently running unconfined. The model's view of the sandbox is result facts only: the static bash tool description explains the denial marker, and each run's `result.sandbox` carries the mode it executed under (no live-mode getter on the seam and no current-mode prompt statement — both arrive with the runtime-context phase of the RFC below). Denials are deny-only result facts today; the approval/escalated-retry flow on top of them is the [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend. A selected runner refusing its profile reaches the same fail-closed foreground error; a settled background task records `runnerFailed`. The model sees the current effective mode in the prompt, receives denial/runner facts in results, and can request a one-shot strictly wider retry through `sandbox_permissions` plus `justification`; `ctx.approval` must grant that exact call before anything executes. The complete policy and switching design is the [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Background tasks: `BashTask`
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
|
||||
| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors |
|
||||
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
|
||||
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
|
||||
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
|
||||
|
||||
82
docs/core-data-structures/sandbox.md
Normal file
82
docs/core-data-structures/sandbox.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Process Sandbox
|
||||
|
||||
The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies the Linux bwrap/Landlock and macOS Seatbelt backends; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) is the first consumer. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`.
|
||||
|
||||
Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts)
|
||||
|
||||
## Modes and enforcement
|
||||
|
||||
`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary.
|
||||
|
||||
```ts type-equiv
|
||||
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
```
|
||||
|
||||
Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`.
|
||||
|
||||
```ts type-equiv
|
||||
type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>
|
||||
```
|
||||
|
||||
Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction.
|
||||
|
||||
```ts type-equiv
|
||||
type SandboxEnforcement = 'full' | 'partial'
|
||||
```
|
||||
|
||||
## Per-call policy
|
||||
|
||||
The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state.
|
||||
|
||||
```ts type-equiv
|
||||
interface SandboxPolicy {
|
||||
/** The file-effect mode this execution runs under. */
|
||||
mode: ConfinedSandboxMode
|
||||
/** Absolute root directory `workspace-write` may write under. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
```
|
||||
|
||||
## Wrapped argv and classification dialects
|
||||
|
||||
`ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure.
|
||||
|
||||
```ts type-equiv
|
||||
interface ConfinedArgv {
|
||||
/** The wrapped argv (runner, profile, separator, then the caller's argv). */
|
||||
argv: string[]
|
||||
/** How completely the selected backend enforces the policy's file effects. */
|
||||
enforcement: SandboxEnforcement
|
||||
/**
|
||||
* The selected backend's denial DIALECT: the case-insensitive stderr
|
||||
* substrings a file effect denied by THIS backend produces (EROFS text
|
||||
* under bwrap's read-only binds, EACCES under Landlock, EPERM under
|
||||
* Seatbelt). A consumer that infers denials from a failed run's stderr
|
||||
* matches against exactly these rather than a cross-backend union — the
|
||||
* union claims denials a given backend never produces.
|
||||
*/
|
||||
denialSignatures: readonly string[]
|
||||
/**
|
||||
* How the RUNNER ITSELF failing identifies itself: case-insensitive stderr
|
||||
* substrings produced when the sandbox binary is missing, refuses its
|
||||
* profile, or fails closed before exec'ing the command (`bwrap: `,
|
||||
* `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own
|
||||
* error prefix and the shell's runner-not-found message). ORTHOGONAL to
|
||||
* {@link denialSignatures}: a denial is the confined COMMAND being blocked
|
||||
* (the sandbox working as designed); a runner failure means the command
|
||||
* NEVER RAN and must surface as a sandbox failure, not a task failure —
|
||||
* consumers check these signatures FIRST (a runner's own error text may
|
||||
* contain denial words, e.g. an unopenable grant root reporting
|
||||
* `Permission denied`).
|
||||
*/
|
||||
runnerFailureSignatures: readonly string[]
|
||||
}
|
||||
```
|
||||
|
||||
An operator-configured local runner must supply at least one `runnerFailureSignatures` entry for its own pre-exec refusal dialect; the provider adds outer-shell missing and unexecutable forms automatically. This makes an executable custom runner rejecting its profile distinguishable from the wrapped command exiting with the same status.
|
||||
|
||||
## Provider and fail-closed errors
|
||||
|
||||
`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. A selected runner can also fail closed at execution time, in which case its failure signature carries the same infrastructure meaning. Silent unconfined passthrough is never legal for a confined policy.
|
||||
|
||||
Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict.
|
||||
Reference in New Issue
Block a user