From ff21f91a394fc5c4256e548da91367d2e94dbb88 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:44:28 +0800 Subject: [PATCH] fix(sandbox): resolve workspace roots per session --- .../feature/2026-06-14-acp-multi-session.md | 8 + .../implemented/feature/2026-07-06-sandbox.md | 16 +- ...26-07-14-cross-family-fs-sandbox.i18n.yaml | 4 +- .../2026-07-14-cross-family-fs-sandbox.md | 18 ++- .../2026-07-14-cross-family-fs-sandbox.zh.md | 18 ++- docs/config-catalog.md | 19 ++- docs/cordis-catalog/services.md | 39 +++-- docs/core-data-structures/bash.md | 10 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/sandbox.md | 39 ++++- examples/acp-agent/README.md | 6 +- examples/acp-agent/cordis.yml | 4 +- knip.json | 4 + packages/bash/bash-local/src/index.ts | 4 +- packages/bash/bash-sandbox/README.md | 6 +- packages/bash/bash-sandbox/src/index.ts | 54 +++---- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 2 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 2 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 27 ++-- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 2 +- packages/bash/bash/README.md | 2 +- packages/bash/bash/src/types.ts | 10 +- packages/bash/bash/tests/service.spec.ts | 4 +- packages/bash/tool-bash/src/index.ts | 38 +++-- packages/bash/tool-bash/tests/tools.spec.ts | 24 ++- .../cordis/tool-cordis/src/api-catalog.ts | 29 +++- .../examples/agent-spine-demo/package.json | 7 + .../tests/multi-project-sandbox.e2e.ts | 149 ++++++++++++++++++ packages/fs/README.md | 2 +- packages/fs/fs-sandbox/README.md | 6 +- packages/fs/fs-sandbox/src/index.ts | 56 +++---- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 10 +- packages/fs/fs/src/index.ts | 18 +-- .../fs/tool-fs-search/tests/load-path.spec.ts | 2 +- .../fs/tool-fs-search/tests/tools.spec.ts | 2 +- packages/fs/tool-fs/src/edit.ts | 10 +- packages/fs/tool-fs/src/index.ts | 2 +- packages/fs/tool-fs/src/sandbox.ts | 66 ++++---- packages/fs/tool-fs/src/write.ts | 12 +- packages/fs/tool-fs/tests/tools.spec.ts | 34 ++-- .../hooks/hook-protocol/tests/runner.spec.ts | 2 +- packages/sandbox/README.md | 4 +- packages/sandbox/sandbox-policy/README.md | 15 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 66 +++++--- .../sandbox-policy/src/session-mode.ts | 6 +- .../sandbox-policy/tests/policy.spec.ts | 53 +++++++ packages/sandbox/sandbox/README.md | 2 +- packages/sandbox/sandbox/src/index.ts | 21 ++- packages/sandbox/sandbox/src/roots.ts | 4 +- pnpm-lock.yaml | 21 +++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-tool-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 2 + 54 files changed, 664 insertions(+), 305 deletions(-) create mode 100644 packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md index def0604df9..91d85aeded 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md @@ -18,6 +18,14 @@ Background bash tasks carry an opaque owner token equal to the owning session id Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal. +## Protocol and workspace scope + +[ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session. + +A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). [Zed sends the remaining project work directories only when the agent advertises that capability](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145), otherwise it [drops them from the session request](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472). The bridge does not advertise this capability and rejects non-empty values, as recorded in its [known limitations](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work), so a current Zed multi-root project reaches it with only the first work directory. + +[The standard transport is one editor-launched agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple editor connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path. + ## Alternatives considered **One live session per connection** — rejected. It adds process overhead and contradicts the target client's multi-session shape without removing multiplexing needs from the editor. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 2cc6516523..1c46f357e8 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -12,7 +12,7 @@ Confinement alone leaves two gaps. A denial with no escalation path is terminal ## Decision -One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. The scope is deliberately bounded: the phases this Agent Note names but does not design — per-session workspace root, cross-family fs enforcement, the `subagent-acp` consumer, more environments, a Windows chain — are listed under § Deferred phases, each a follow-up design, not a config knob. +One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. Cross-family fs enforcement and per-session workspace roots landed as follow-ups on the same policy carrier; the remaining phases — the `subagent-acp` consumer, more environments, and a Windows chain — stay under § Deferred phases. ### How a deployment uses it @@ -48,7 +48,7 @@ OS subprocess confinement applies to the bash executor, including hook commands, #### The seam: `ctx.sandbox` -`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxPolicy` (mode + workspace root). +`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset). Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode. @@ -74,9 +74,9 @@ The model's view is result facts only: the static tool description explains the #### Escalation: one approved wider retry after a denial -`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. +`BashExecRequest.sandboxPolicy` is an optional complete per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and 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. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. +`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. 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. @@ -115,8 +115,8 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s ### Testing -- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. -- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. +- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip. - **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip. - **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific. @@ -124,7 +124,6 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches. -- **Per-session workspace root** — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed. Centralizing the root on `ctx.sandboxPolicy` (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)) is the groundwork. - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). - **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. @@ -163,6 +162,7 @@ What shipped pins — the tiers in Testing hold each: - N idle-time flips produce at most one anchored event per knob (a net-zero sequence anchors none — a no-op push from a client echoing current selections records nothing); an approval-policy switch is narrated in at most one coalesced notice; a mid-turn sandbox switch is honored by the next call's stamp. - A resumed session's overrides apply and are reported to the editor with no special-casing; a default changed while the process was down is narrated before the session's first new request, attributed to the operator. - Two concurrent sessions never see each other's state, notices, or config options. +- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. - `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and the ACP handler surface. Costs and accepted limits: @@ -199,7 +199,7 @@ Costs and accepted limits: In-repo precedents this design copies or contrasts with: - [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied. -- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the per-call carrier template `sandboxMode` rides, and the explicit-`resolve()` defaulting convention. +- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention. - [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there. - [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [the turn-enclosure invariant](../architecture/2026-06-15-turn-enclosure-invariant.md) — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own). diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 8e44e3a6bc..9c5b24393d 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-cross-family-fs-sandbox.md: 9b6312e5994469606bd1645902fc798f70258580 -2026-07-14-cross-family-fs-sandbox.zh.md: d4816e03d94bdf12b2db875d71dccb7db3a2c0d7 +2026-07-14-cross-family-fs-sandbox.md: e0829144f6e8bfcbeb0ec5269674f29e96fcfa24 +2026-07-14-cross-family-fs-sandbox.zh.md: 5c9c814611a2dee217a14d470ff1f7542da539eb diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index 9b6312e599..e0829144f6 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -22,9 +22,10 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching - `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load. - The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent. -- `defaultMode` / `workspaceRoot` accessors the enforcing implementations read for their resolve fallback and boundary. +- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session fold > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback. +- `defaultMode` / `workspaceRoot` accessors retained as deployment fallbacks and the capability-advertisement fact. -`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and reads the default from it; its `resolve()` precedence is unchanged (escalation grant > per-call stamp > default). `dsh-tool-bash` and `dsh-tool-fs` fold the session's `sandbox/mode` with `effectiveSandboxMode` to stamp each call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seam that owns bash execution no longer depends on `dsh-session` at all — the session dependency moved to the policy package with the fold. +`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and uses its deployment fallback only for direct calls. `dsh-tool-bash` and `dsh-tool-fs` pass the active session to `ctx.sandboxPolicy.resolve()`, so both receive the same effective mode and cwd root on every call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seams that own bash and fs execution remain session-free — the session dependency lives in the policy package and tool consumers. ### `dsh-fs-sandbox` — enforcement inside the provider @@ -34,13 +35,13 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching - `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Containment is prefix-inclusion on real paths; the target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. - `danger-full-access` delegates unfenced. -A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. +A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `SandboxExecutionPolicy` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxPolicy`); the seam stays session-free, and the bare local backend ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. The threat model is stated in the package README: a policy fence in trusted code over model-controlled paths, not a kernel boundary — the operations are the seam's own, only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface (the `code-runtime` "containment, not a security boundary" precedent). Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job. The residual resolve-to-syscall race is narrowed by the in-place re-canonicalization and eliminated only by platform primitives (`openat2` `RESOLVE_BENEATH`) not worth their portability cost here. ### Tool parity — one denial marker, one escalation flow -`dsh-tool-fs` stamps the effective mode onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant consumed by the one call that asked; no new session events). +`dsh-tool-fs` resolves the active session's complete policy onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant changes only that call's mode and retains its session root; no new session events). The shared pieces live in `dsh-sandbox`, which owns the mode types: `WIDER_MODES`, the escalation-target enum, the argument-pairing validation, the denial/hint marker builders, and `approveEscalation` — the ordered fail-closed choreography. `approveEscalation` takes a minimal STRUCTURAL approver (`EscalationApprover`, generic over the agent and call-id types), not the approval service type, so `dsh-sandbox` gains no dependency on the approval or agent packages: each tool passes its own `ctx.approval`, agent, call id, and tool name as ingredients. `dsh-tool-bash` and `dsh-tool-fs` both use these; the cross-file duplication gate holds the single-sourcing honest. @@ -53,7 +54,8 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the ### Out of scope - **Network policy for `ctx.web`** — `SandboxMode` claims file effects only; a web-only network knob while bash `curl` runs free would be a false boundary. Revisit when a bash backend enforces network (bwrap `--unshare-net`, Landlock ABI v4+). -- **The `subagent-acp` consumer** and **per-session workspace root** — unchanged deferred phases of the sandbox RFC; centralizing the root in `ctx.sandboxPolicy` is groundwork for the latter, not its design. +- **The `subagent-acp` consumer** — unchanged deferred phase of the sandbox RFC. +- **Additional writable roots inside one session** — the resolved policy carries one primary `SessionHeader.cwd`; ACP `additionalDirectories` remains a separate bridge and policy design. - **A uniform per-tool sandbox runtime** — remains rejected for the reasons in the sandbox RFC. ## Alternatives considered @@ -66,7 +68,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the - **Per-family policy config with a load-time consistency check** — rejected: two homes for one fact, patched by a check that must enumerate every future enforcing family; the policy service makes drift inexpressible instead of detected. - **Keep the override event in `dsh-bash` as `bash/sandbox-mode`** — rejected: the event is policy state consumed by two families; leaving it bash-named forces `dsh-fs-sandbox` to depend on bash vocabulary. Pre-release, the rename is a same-change move with snapshot re-records, no shims. - **Escalation choreography imported from the approval/agent packages into `dsh-sandbox`** — rejected: it would invert the layering (a base vocabulary package depending on UI/agent packages). The structural approver keeps the logic single-sourced in `dsh-sandbox` while the dependencies stay in the tool layer that already holds them. -- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it churns every `writeText`/`editText` caller and splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `sandboxMode` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. +- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `SandboxExecutionPolicy` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. - **Extra writable-root grants on `SandboxPolicy` now** — deferred unchanged: `writableRoots()` derives from the mode meaning today; ad-hoc grants are an escalation-scope question the sandbox RFC left open. ## Consequences @@ -77,6 +79,7 @@ What shipped — the tiers in § Testing hold each: - Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, and a new file created under such a symlink — denies every escape on real disks. - A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. - One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. +- Concurrent sessions with different cwd roots carry different policies through the same service instances; neither family caches one session's root for the next call. - A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. - The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`. - `agent-loop` is untouched — everything rides `ctx.sandboxPolicy`, the `ctx.fs` seam, `SessionEventMap` merging, and the tool-execution pipeline. @@ -90,5 +93,6 @@ Costs and accepted limits: ## Testing -- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, root-ending-in-separator) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the fold/setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, root-ending-in-separator) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` consume the same policy kit. +- Keyless e2e: one real Cordis context creates two agents with different session cwd roots, runs the shipped bash and fs tools concurrently, and world-verifies that own-project writes land while both cross-project writes are denied. - Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index d4816e03d9..5c9c814611 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -22,9 +22,10 @@ Status: implemented - `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误在加载时高声失败。 - per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它住在这里,而不在任一能力的 seam 里。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。 -- `defaultMode` / `workspaceRoot` 访问器,供执行实现读取其 resolve 回退值与边界。 +- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。 +- 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。 -`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy` 并从中读取默认值;其 `resolve()` 优先级不变(升级授权 > per-call 盖章 > 默认)。`dsh-tool-bash` 与 `dsh-tool-fs` 用 `effectiveSandboxMode` 折叠会话的 `sandbox/mode` 以对每次调用盖章;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 执行的那个 seam 不再依赖 `dsh-session`——会话依赖随折叠一起迁到了策略包。 +`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。 ### `dsh-fs-sandbox`——在提供方内部执行 @@ -34,13 +35,13 @@ Status: implemented - `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。包含判定是对真实路径的前缀包含;目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 - `danger-full-access` 不加围栏地委托。 -拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 +拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `SandboxExecutionPolicy`(文件系统侧对应 `BashExecRequest.sandboxPolicy`);该 seam 保持无会话依赖,而裸的本地后端会忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 威胁模型写在包 README 里:一道位于可信代码中、针对模型可控路径的策略围栏,而非内核边界——操作是 seam 自身的,只有目标路径不可信,所以「先规范化再判包含」是对这个面的完整答案(`code-runtime` 的「containment, not a security boundary」先例)。对不可信代码的内核级隔离仍是 `ctx.bash` 的职责。resolve 到系统调用之间残留的竞态被就地重新规范化收窄,只有平台原语(`openat2` `RESOLVE_BENEATH`)能彻底消除它,而那在此不值其可移植性代价。 ### 工具对等——一个拒绝标记、一条升级流程 -`dsh-tool-fs` 把生效模式盖章到每次变更上,并将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(严格加宽在执行时针对调用的生效模式检查;授权由发起它的那一次调用消费;无任何新会话事件)。 +`dsh-tool-fs` 把当前会话解析成完整策略,并传给每次变更,同时将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(执行时根据调用的生效模式检查是否严格加宽;授权只改变当前调用的模式,并保留其会话根目录;不产生任何新会话事件)。 共享部分住在 `dsh-sandbox`,它拥有模式类型:`WIDER_MODES`、升级目标枚举、参数配对校验、拒绝/提示标记构造器,以及 `approveEscalation`——有序的 fail-closed 编排。`approveEscalation` 接收一个最小的结构化 approver(`EscalationApprover`,对 agent 与 call-id 类型泛型化),而非审批服务类型,所以 `dsh-sandbox` 不获得对 approval 或 agent 包的依赖:每个工具把自己的 `ctx.approval`、agent、call id 与工具名作为原料传入。`dsh-tool-bash` 与 `dsh-tool-fs` 都使用它们;跨文件重复检测门禁确保单一来源不走样。 @@ -53,7 +54,8 @@ Status: implemented ### 范围之外 - **`ctx.web` 的网络策略**——`SandboxMode` 只声明文件效果;在 bash `curl` 畅通时给一个仅限 web 的网络旋钮会是一道假边界。待某个 bash 后端能执行网络(bwrap `--unshare-net`、Landlock ABI v4+)时再议。 -- **`subagent-acp` 消费者** 与 **per-session 工作区根**——沙箱 RFC 未变的延后阶段;把根集中到 `ctx.sandboxPolicy` 是后者的铺垫,而非其设计。 +- **`subagent-acp` 消费者**——沙箱 RFC 中未变的延后阶段。 +- **单个会话中的额外可写根目录**——解析后的策略携带一个主要 `SessionHeader.cwd`;ACP `additionalDirectories` 仍是独立的 bridge 与策略设计问题。 - **统一的 per-tool 沙箱运行时**——因沙箱 RFC 中的理由继续否决。 ## Alternatives considered @@ -66,7 +68,7 @@ Status: implemented - **带加载期一致性校验的 per-family 策略配置**——否决:一个事实两个归属,靠一个必须枚举每个未来执行家族的校验来打补丁;策略服务让漂移不可表达,而非被检测到。 - **把覆盖事件留在 `dsh-bash` 里作 `bash/sandbox-mode`**——否决:该事件是被两个家族消费的策略状态;保留 bash 命名会迫使 `dsh-fs-sandbox` 依赖 bash 词汇。预发布阶段,该改名是同一变更内的迁移,附带快照重录,无任何 shim。 - **把升级编排从 approval/agent 包导入 `dsh-sandbox`**——否决:那会倒置分层(一个基础词汇包依赖 UI/agent 包)。结构化 approver 让逻辑单一来源于 `dsh-sandbox`,而依赖留在本就持有它们的工具层。 -- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会搅动每一个 `writeText`/`editText` 调用方,并把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `sandboxMode` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 +- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `SandboxExecutionPolicy` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 - **现在就在 `SandboxPolicy` 上加额外的可写根授权**——照旧延后:`writableRoots()` 如今由模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。 ## Consequences @@ -77,6 +79,7 @@ Status: implemented - 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录,以及在这样一个符号链接下新建的文件——在真实磁盘上拒绝每一种逃逸。 - 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。 - 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。 +- cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 - `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。 - `agent-loop` 未被触动——一切都骑在 `ctx.sandboxPolicy`、`ctx.fs` seam、`SessionEventMap` 合并,以及工具执行管线之上。 @@ -90,5 +93,6 @@ Status: implemented ## Testing -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、以分隔符结尾的根),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、以分隔符结尾的根),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 使用同一套策略工具集。 +- 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7b177b9c3d..9394cf6e77 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -196,11 +196,10 @@ Requires: `sandbox` · `sandboxPolicy` ```ts config-catalog /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — - * the default mode and the `workspace-write` boundary root — is NOT here: it - * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one - * home both enforcing families read, so bash and fs can never confine to - * different roots. The runner choice is likewise the `ctx.sandbox` provider's - * config, not this executor's. + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for both enforcing families. The runner + * choice is likewise the `ctx.sandbox` provider's config, not this executor's. */ export type Config = LocalConfig ``` @@ -349,8 +348,8 @@ Requires: `sandboxPolicy` /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve * base for relative paths). The sandbox default (mode + `workspace-write` - * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home - * both enforcing families share. + * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling + * session for both enforcing families. */ export type Config = LocalConfig ``` @@ -746,8 +745,8 @@ export interface Config { /** File-sandbox mode a session starts from (default: `read-only`). */ mode?: SandboxMode /** - * Absolute root directory `workspace-write` may write under (default: - * `process.cwd()`). Both enforcing families fence against this SAME root. + * Fallback root for agentless calls and sessions without a cwd (default: + * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string } @@ -755,7 +754,7 @@ export interface Config { Depends on: [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:39`](../packages/sandbox/sandbox-policy/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 426de24c3a..a550b11caa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -458,12 +458,12 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this write runs under; a - * sandboxing backend fences the write by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this write + * runs under; a sandboxing backend fences the write by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the write produced. */ -abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise /** * Atomically edit literal text. When supplied, the version guard is checked @@ -473,15 +473,15 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this edit runs under; a - * sandboxing backend fences the edit by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this edit runs + * under; a sandboxing backend fences the edit by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the edit produced. */ -abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise ``` -Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxMode](../core-data-structures/sandbox.md) +Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxExecutionPolicy](../core-data-structures/sandbox.md) Source: [`packages/fs/fs/src/index.ts:81`](../../packages/fs/fs/src/index.ts) @@ -598,13 +598,28 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:122`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and workspace root; enforcing implementations read defaultMode and workspaceRoot, and the tool layers fold each session's `sandbox/mode` override with effectiveSandboxMode on top. +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and fallback workspace root. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. -Source: [`packages/sandbox/sandbox-policy/src/index.ts:60`](../../packages/sandbox/sandbox-policy/src/index.ts) +```ts cordis-catalog +/** + * Resolve the complete policy for one capability call. An approved explicit + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. + * @param request - optional session and approved mode override. + * @returns the fully resolved per-call mode and absolute workspace root. + */ +resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy +``` + +Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) + +Source: [`packages/sandbox/sandbox-policy/src/index.ts:63`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 2070c464cf..b639e55927 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -67,8 +67,8 @@ interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ dshEnv?: DshEnvironment | undefined - /** Explicit per-call sandbox mode override. */ - sandboxMode?: SandboxMode | undefined + /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ + sandboxPolicy?: SandboxExecutionPolicy | undefined } ``` @@ -100,8 +100,8 @@ interface BashExecSpec { env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** Resolved sandbox mode; ignored by executors that do not confine. */ - sandboxMode: SandboxMode | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } ``` @@ -159,7 +159,7 @@ interface CollectedOutput { ## File sandbox: `BashSandboxInfo` -A sandbox-consuming executor exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `sandbox/mode` override (owned by [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md)) and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. +A sandbox-consuming executor exposes its configured mode fallback through `BashExecutor.sandboxMode`. The tool layer asks [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md) to resolve each calling session's durable `sandbox/mode` override and immutable cwd into `BashExecRequest.sandboxPolicy`; a user-approved strictly wider call replaces only the mode. The mode/root/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 71a593da7a..7bde39e920 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -26,7 +26,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [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 `BashProcess` handles | -| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors | +| [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `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` | | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 5b20febc3e..c6f1fbae3f 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -38,7 +38,35 @@ 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. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. + +```ts type-equiv +/** + * The complete file-effect policy resolved for one capability call. The root + * is carried even under modes that do not consume it so callers can resolve + * policy once before choosing the enforcement path. + */ +interface SandboxExecutionPolicy { + /** The file-effect mode this execution runs under. */ + mode: SandboxMode + /** Absolute root directory `workspace-write` may write under. */ + workspaceRoot: string +} +``` + +`ctx.sandboxPolicy.resolve()` accepts the active session and, for an approved retry, an explicit mode. The service owns precedence and root fallback so bash and fs do not repeat it. + +```ts type-equiv +/** Inputs that select the sandbox policy for one capability call. */ +interface SandboxPolicyRequest { + /** Calling session; its immutable cwd becomes the workspace boundary. */ + session?: Session + /** Explicit approved mode override, which outranks session policy. */ + mode?: SandboxMode +} +``` + +Only a confined execution reaches `ctx.sandbox`; its provider policy narrows the mode while retaining the same root. This permits concurrent sessions, consumers, and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. ```ts type-equiv /** @@ -46,15 +74,12 @@ The policy is fully resolved and carried per call. This permits concurrent consu * fixed on the provider: two consumers may confine under different policies * at the same instant (bash under `read-only` while a confined child agent * needs its state directory writable), and an approved escalated retry is a - * new call with a wider policy. Defaulting/resolution is the consumer's - * explicit step (its config owns the fallback chain); the provider treats - * the policy as fully specified. + * new call with a wider policy. Defaulting/resolution is an explicit step at + * the consumer boundary; the provider treats the policy as fully specified. */ -interface SandboxPolicy { +interface SandboxPolicy extends SandboxExecutionPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode - /** Absolute root directory `workspace-write` may write under. */ - workspaceRoot: string } ``` diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 6e97204963..b2fe82dd7c 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -29,7 +29,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). The filesystem tools now ride the same sandbox policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` are available under every mode and confined to the same `workspaceRoot`. +The editor sets each session's `cwd` to the project it opens. That directory is both bash's default workdir and the session's `workspace-write` boundary: every bash or filesystem mutation carries one policy resolved from the calling session, so a single server process may serve concurrent projects without granting either session writes into the other. The configured `workspaceRoot: process.cwd()` remains the fallback for calls without a session cwd. The filesystem tools ride the same policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` are available under every mode and confined to the same session root. ## Snapshot tests (record-once / replay-deterministic) @@ -41,9 +41,9 @@ The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sand - **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined bash plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. - **Every approval is one-shot**: the choices are `Allow once` and `Reject`; a dismissal, rejection, missing editor, or unavailable runner fails closed. -- **The boundary spans bash and the filesystem tools, and is config-fixed today**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)), both keyed to the same `workspaceRoot` — which remains the server's launch directory (a per-session root is deferred). +- **The boundary spans bash and the filesystem tools per session**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)); both receive the calling session's cwd as `workspaceRoot`. -`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. Most snapshots use that tree and start at `danger-full-access` so bash fixtures remain runner-independent; scenarios that call `read`, `write`, or `edit` use the fixed full-access fs overlay and a separate request-header pin. The permission-switching and escalation inputs select `workspace-write` before exercising the bash policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. +`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. The agent-spine e2e independently boots one context with two project sessions and world-verifies concurrent own-root success plus sibling-root denial through both shipped tool families. Most snapshots use the ACP tree and start at `danger-full-access` so bash fixtures remain runner-independent; scenarios that call `read`, `write`, or `edit` use the full-access fs overlay and a separate request-header pin. The permission-switching and escalation inputs select `workspace-write` before exercising the bash policy path. No fixture pins real runner denial text because its dialect is platform-specific. ## MVP limitations diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 7f4ef21a59..ed1e915b85 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -14,8 +14,8 @@ # workspace and asks before a wider retry. Snapshot runs select # danger-full-access so the established scenarios remain runner-independent; # DSH_PERMISSION_MODE provides the same explicit deployment/test override -# outside the snapshot harness. The sandbox mode + workspace root live on -# ctx.sandboxPolicy — the one home both enforcing families (bash, fs) read. +# outside the snapshot harness. The sandbox default + fallback root live on +# ctx.sandboxPolicy; agent calls resolve both families against the session cwd. - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/knip.json b/knip.json index 77964077f2..6d0798031a 100644 --- a/knip.json +++ b/knip.json @@ -118,6 +118,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/examples/agent-spine-demo": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/jsonrpc": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6428cd7ac8..2c25701fb9 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -109,10 +109,10 @@ export class LocalBashExecutor extends BashExecutor { ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, - // Carry a sandbox-mode override through verbatim: this executor never + // Carry a sandbox policy through verbatim: this executor never // confines, so the field is inert here (the seam contract) — a // sandboxing subclass overrides resolve() to stamp its default instead. - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 97419b45d4..93e0c9e6f2 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -16,7 +16,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. -- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. +- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -29,12 +29,12 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego name: '@deepseek-ai/dsh-sandbox-policy' config: mode: read-only - workspaceRoot: !!js process.cwd() + workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd - id: bash name: '@deepseek-ai/dsh-bash-sandbox' ``` -The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. +The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. ## Model Experience diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 67b850916c..b889692f3c 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -3,14 +3,15 @@ * `ctx.sandbox`, inherits local process mechanics, and reports the selected * mode, enforcement, and denial facts. Runner failure means the command never * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background - * processes carry `runnerFailed`. The tool owns approval and passes per-call modes. + * processes carry `runnerFailed`. The tool owns approval and passes a complete + * per-call policy. * @module @deepseek-ai/dsh-bash-sandbox */ import { Context } from 'cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' @@ -18,21 +19,19 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f /** * Plugin config: the local executor's knobs, verbatim. The sandbox policy — - * the default mode and the `workspace-write` boundary root — is NOT here: it - * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one - * home both enforcing families read, so bash and fs can never confine to - * different roots. The runner choice is likewise the `ctx.sandbox` provider's - * config, not this executor's. + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for both enforcing families. The runner + * choice is likewise the `ctx.sandbox` provider's config, not this executor's. */ export type Config = LocalConfig /** * Registers as `ctx.bash` in place of the local executor and requires a * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is - * unchanged. The policy default (mode + workspace root) is the fallback, - * while a session override or approved one-shot escalation may select each - * call's mode. The prompt does not state the standing mode; `result.sandbox` - * reports the mode and enforcement actually used. + * unchanged. Tool calls pass the calling session's resolved policy; direct + * calls fall back to deployment policy. The prompt does not state the standing + * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { static inject = ['sandbox', 'sandboxPolicy'] @@ -42,7 +41,6 @@ export class SandboxBashExecutor extends LocalBashExecutor { // verbatim (the config catalog walks the inherited static). private readonly mode: SandboxMode - private readonly workspaceRoot: string /** * Per-process confinement facts retained until settlement. Providers may * vary enforcement and diagnostic dialect between overlapping calls, so a @@ -58,11 +56,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { constructor(ctx: Context, config: Config) { super(ctx, config) - // The sandbox default (mode + workspaceRoot) is the one shared policy home - // both enforcing families read; injecting sandboxPolicy guarantees it is - // constructed first. workspaceRoot arrives already resolved absolute. + // The default mode is the capability fact used for schema advertisement; + // actual tool executions carry their resolved per-call policy. this.mode = ctx.sandboxPolicy.defaultMode - this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot } /** The configured default mode — the capability fact the tool layer reads. */ @@ -71,24 +67,22 @@ export class SandboxBashExecutor extends LocalBashExecutor { } /** - * Stamp the effective mode onto the spec — the request's explicit override - * (an approved escalation), else this executor's configured default — so - * defaulting stays an explicit resolve step and `run()`/`start()` read the - * spec, never the config. + * Stamp a complete per-call policy onto the spec. Tool calls supply the + * calling session's resolved mode and root; lower-level callers fall back to + * the deployment policy. */ override resolve(request: BashExecRequest): BashExecSpec { - return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode } + return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() } } override async run(spec: BashExecSpec): Promise { - // resolve() always stamps the mode; the cast records that invariant - // (mirrors the constructor's config casts). - const mode = spec.sandboxMode as SandboxMode + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy if (mode === 'danger-full-access') { const result = await super.run(spec) return { ...result, sandbox: { mode, denied: false } } } - const confined = this.confine(spec.command, mode) + const confined = this.confine(spec.command, { ...policy, mode }) const result = await super.run({ ...spec, command: confined.command }) // Runner failure outranks denial because the command did not run. Throw the // same fail-closed error as confine-time discovery with the first stderr line. @@ -99,11 +93,11 @@ export class SandboxBashExecutor extends LocalBashExecutor { } override start(spec: BashExecSpec): BashProcess { - // Same stamped-by-resolve invariant as run(). - const mode = spec.sandboxMode as SandboxMode + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy if (mode === 'danger-full-access') return super.start(spec) // Install facts synchronously; promise settlement cannot run before start() returns. - const confined = this.confine(spec.command, mode) + const confined = this.confine(spec.command, { ...policy, mode }) const proc = super.start({ ...spec, command: confined.command }) const { enforcement, denialSignatures, runnerFailureSignatures } = confined this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures }) @@ -138,13 +132,13 @@ export class SandboxBashExecutor extends LocalBashExecutor { * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ - private confine(command: string, mode: ConfinedSandboxMode): { + private confine(command: string, policy: SandboxPolicy): { command: string enforcement: SandboxEnforcement denialSignatures: readonly string[] runnerFailureSignatures: readonly string[] } { - const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot }) + const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy) return { command: `exec ${confined.argv.map(shellQuote).join(' ')}`, enforcement: confined.enforcement, diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 32a246e42e..87bcffe9df 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -89,7 +89,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx. expect(strict.exitCode).not.toBe(0) expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false) - const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' })) + const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } })) expect(retried.exitCode).toBe(0) expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated') diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index b8c86d95b2..3ce944b07c 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -94,7 +94,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug expect(strict.exitCode).not.toBe(0) expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement }) expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false) - const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' })) + const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } })) expect(retried.exitCode).toBe(0) expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement }) expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated') diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 5b2b3ba16a..6e8f2229a0 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' @@ -72,6 +72,10 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult { return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) } } +function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy { + return { mode, workspaceRoot } +} + describe('the provider hand-off', () => { it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => { const { bash, calls } = await setup() @@ -147,30 +151,31 @@ describe('danger-full-access', () => { }) }) -describe('per-call sandboxMode override (the escalation mechanism)', () => { +describe('per-call sandbox policy (the session and escalation carrier)', () => { it('exposes the configured default as the capability fact, and resolve() stamps it', async () => { const { bash } = await setup() expect(bash.sandboxMode).toBe('read-only') - expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only') + expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only')) }) - it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => { + it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => { const { bash, calls } = await setup() - expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write') - await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' })) + const explicit = executionPolicy('workspace-write', '/session/project') + expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit) + await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit })) await bash.run(bash.resolve({ command: 'true' })) - expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only']) + expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')]) }) it('an escalated run reports the mode it ACTUALLY ran under', async () => { const { bash } = await setup() - const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' })) + const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) }) it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => { const { bash, calls } = await setup() - const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' })) + const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') })) expect(result.stdout.text).toBe('free\n') expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) expect(calls).toHaveLength(0) @@ -181,7 +186,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => { // once — anything keyed off the configured default would misreport the // escalated one at its settle stamp. const { bash } = await setup() - const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' })) + const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') })) const plain = bash.start(bash.resolve({ command: 'true' })) await plain.done await escalated.done @@ -191,7 +196,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => { it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => { const { bash, calls } = await setup() - const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' })) + const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') })) await task.done expect(task.sandbox).toBeUndefined() expect(task.readOutput().delta).toContain('bg-free') diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 7e08ea0365..6c212ee546 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -91,7 +91,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug expect(strict.exitCode).not.toBe(0) expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false) - const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' })) + const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } })) expect(retried.exitCode).toBe(0) expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated') diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ec5005ec70..73fbb4fb3e 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,7 +27,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. +`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing. The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 55beccacea..a504513417 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -4,7 +4,7 @@ * @module dsh-bash/types */ -import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ export const DSH_ENV_PREFIX = 'DSH_' as const @@ -75,8 +75,8 @@ export interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ dshEnv?: DshEnvironment | undefined - /** Explicit per-call sandbox mode override. */ - sandboxMode?: SandboxMode | undefined + /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ + sandboxPolicy?: SandboxExecutionPolicy | undefined } /** @@ -106,8 +106,8 @@ export interface BashExecSpec { env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** Resolved sandbox mode; ignored by executors that do not confine. */ - sandboxMode: SandboxMode | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } /** One captured stream: the (possibly truncated) text plus recovery info. */ diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 63d9533410..cacfe85eca 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -17,7 +17,7 @@ class StubExecutor extends BashExecutor { timeoutMs: request.timeoutMs ?? 1000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, ...request.signal ? { signal: request.signal } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } @@ -55,7 +55,7 @@ describe('BashExecutor service seam', () => { const ctx = new Context() await ctx.plugin(StubExecutor) const spec = ctx.bash.resolve({ command: 'echo hi' }) - expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined }) + expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxPolicy: undefined }) const result = await ctx.bash.run(spec) expect(result.exitCode).toBe(0) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0feec1729a..5b552a224c 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -18,9 +18,9 @@ import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' @@ -330,9 +330,14 @@ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS + const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && sandboxPolicy === undefined) { + throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') + } - const sessionOverride = (exec: ToolExecution): SandboxMode | undefined => - defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events) + /** Resolve the complete standing policy for this call when a confining executor is mounted. */ + const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => + sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) /** * Resolve a sandbox-escalation request through `ctx.approval` BEFORE @@ -342,14 +347,19 @@ export function apply(ctx: Context, config: Config = {}): void { * guard (the fields are unadvertised without a sandboxing executor, yet * schema validation checks advertised keys only, so an unadvertised * `sandbox_permissions` still reaches execute) and the approval ingredients - * — the seam is consumed opportunistically (`ctx.get`) so a deployment - * without it degrades per call. + * The shared policy resolver is required whenever the executor advertises + * confinement, so a split composition fails at tool-plugin load. */ - const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise => { + const approveBashEscalation = ( + mode: string, + justification: string, + exec: ToolExecution, + standingPolicy: SandboxExecutionPolicy | undefined, + ): Promise => { if (escalationModes.length === 0) { throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') } - const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode + const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode return approveEscalation( { requestedMode: mode, justification, effectiveMode, subject: 'command' }, { @@ -401,9 +411,13 @@ export function apply(ctx: Context, config: Config = {}): void { async execute(args: BashToolArgs, exec) { validateBashArgs(args) // Description is display metadata; workdir defaults to the caller's session. - const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined - ? await approveBashEscalation(args.sandbox_permissions, args.justification, exec) - : sessionOverride(exec) + const standingPolicy = resolveSandboxPolicy(exec) + const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined + ? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) + : undefined + const policy = approvedMode === undefined + ? standingPolicy + : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec) const dshEnv = bashEnv.collect(exec) const request = { @@ -411,7 +425,7 @@ export function apply(ctx: Context, config: Config = {}): void { ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv, - ...sandboxMode !== undefined ? { sandboxMode } : {}, + ...policy !== undefined ? { sandboxPolicy: policy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 04c18264b1..41db93a1d6 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' @@ -105,12 +106,12 @@ class RecordingSandboxExecutor extends BashExecutor { stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, timeoutMs: request.timeoutMs ?? 1000, ...request.signal ? { signal: request.signal } : {}, - sandboxMode: request.sandboxMode ?? 'read-only', + sandboxPolicy: request.sandboxPolicy ?? { mode: 'read-only', workspaceRoot: process.cwd() }, } } run(spec: BashExecSpec): Promise { - this.modes.push(spec.sandboxMode) + this.modes.push(spec.sandboxPolicy?.mode) return Promise.resolve({ exitCode: 0, signal: null, @@ -119,18 +120,18 @@ class RecordingSandboxExecutor extends BashExecutor { timeoutMs: spec.timeoutMs, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, - sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false }, + sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false }, }) } start(spec: BashExecSpec): BashProcess { - this.modes.push(spec.sandboxMode) + this.modes.push(spec.sandboxPolicy?.mode) return { status: 'completed', exitCode: 0, signal: null, done: Promise.resolve(), - sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false }, + sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false }, readOutput: () => ({ delta: '', lossy: false }), kill: () => false, } @@ -147,7 +148,7 @@ class CountingStartExecutor extends BashExecutor { workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } @@ -173,6 +174,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(AgentRegistry) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) await ctx.plugin(ToolBash) @@ -524,6 +526,14 @@ describe('sandbox escalation through the generic task producer', () => { justification: 'the command needs workspace writes', } + it('fails load when a confining executor has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingSandboxExecutor) + await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') + }) + it('advertises the sandbox fields and validates their pairing', async () => { const { ctx } = await setupSandboxed() const schema = ctx.tools.schemas().find(item => item.name === 'bash')! @@ -962,7 +972,7 @@ describe('the model-facing bash tool builds its request from named args only (no ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } run(): Promise { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5705247a8a..1a80184d33 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -241,12 +241,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */', }, { - signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise', - jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this write runs under; a\n * sandboxing backend fences the write by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */', + signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', + jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */', }, { - signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise', - jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this edit runs under; a\n * sandboxing backend fences the edit by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */', + signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', + jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */', }, ], }, @@ -307,7 +307,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sandboxPolicy', summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).', - methods: [], + methods: [ + { + signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', + jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', + }, + ], }, { key: 'sessionPersistence', @@ -1005,11 +1010,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashExecRequest', - declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode?: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}', }, { name: 'BashExecSpec', - declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}', }, { name: 'BashProcess', @@ -1267,13 +1272,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', }, + { + name: 'SandboxExecutionPolicy', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + }, { name: 'SandboxMode', declaration: 'export type SandboxMode = \'read-only\' | \'workspace-write\' | \'danger-full-access\';', }, { name: 'SandboxPolicy', - declaration: 'export interface SandboxPolicy {\n mode: ConfinedSandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxPolicy extends SandboxExecutionPolicy {\n mode: ConfinedSandboxMode;\n}', + }, + { + name: 'SandboxPolicyRequest', + declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, { name: 'SaveTextSpill', diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 2c73226f3c..47a0a94483 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -45,11 +45,16 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", @@ -57,9 +62,11 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "node-addon-landlock-run": "0.0.0-test.0", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts new file mode 100644 index 0000000000..6ef409f7e3 --- /dev/null +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -0,0 +1,149 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import { CallId } from '@deepseek-ai/dsh-llm' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import type { ToolResult } from '@deepseek-ai/dsh-tools' +import { launcherPath } from 'node-addon-landlock-run' +import * as agentSpine from '../src/index.ts' + +const bwrapUsable = spawnSync('bwrap', [ + '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true', +], { timeout: 5_000, stdio: 'ignore' }).status === 0 +const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0 +const seatbeltUsable = process.platform === 'darwin' + && spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0 +const processSandboxUsable = bwrapUsable || landlockUsable || seatbeltUsable + +let ctx: Context | undefined +let projectA: string +let projectB: string +const tempDirs: string[] = [] + +async function projectDir(label: string): Promise { + const dir = await mkdtemp(join(homedir(), `dsh-${label}-`)) + tempDirs.push(dir) + return dir +} + +async function expectMissing(path: string): Promise { + await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) +} + +function resultText(result: ToolResult): string { + return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') +} + +beforeEach(async () => { + projectA = await projectDir('project-a') + projectB = await projectDir('project-b') + const fallbackRoot = await projectDir('fallback') + + ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) + await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) + await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) + await ctx.plugin(agentSpine, { + workspaceContext: false, + skills: { enabled: false }, + toolBash: { enableRunInBackground: false }, + toolTasks: false, + }) + await new Promise(resolve => setTimeout(resolve, 50)) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) +}) + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +async function agents() { + const active = ctx as Context + const [a, b] = await Promise.all([ + active.agents.create({ sessionId: SessionId('project-a-session'), meta: { cwd: projectA } }), + active.agents.create({ sessionId: SessionId('project-b-session'), meta: { cwd: projectB } }), + ]) + return { active, agentA: a.agent, agentB: b.agent } +} + +describe('one-context multi-project sandbox', () => { + it.skipIf(!processSandboxUsable)('confines concurrent bash calls to each calling session workspace', async () => { + const { active, agentA, agentB } = await agents() + const [aOwn, bOwn, aCross, bCross] = await Promise.all([ + active.tools.execute({ + callId: CallId('bash-a-own'), name: 'bash', agent: agentA, + arguments: { command: 'printf a > a-owned.txt', description: 'Write project A marker' }, + }), + active.tools.execute({ + callId: CallId('bash-b-own'), name: 'bash', agent: agentB, + arguments: { command: 'printf b > b-owned.txt', description: 'Write project B marker' }, + }), + active.tools.execute({ + callId: CallId('bash-a-cross'), name: 'bash', agent: agentA, + arguments: { command: `printf cross > ../${basename(projectB)}/from-a.txt`, description: 'Attempt project B write' }, + }), + active.tools.execute({ + callId: CallId('bash-b-cross'), name: 'bash', agent: agentB, + arguments: { command: `printf cross > ../${basename(projectA)}/from-b.txt`, description: 'Attempt project A write' }, + }), + ]) + + expect(aOwn.isError).toBe(false) + expect(bOwn.isError).toBe(false) + expect(aCross.isError).toBe(false) + expect(bCross.isError).toBe(false) + expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a') + expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b') + await expectMissing(join(projectB, 'from-a.txt')) + await expectMissing(join(projectA, 'from-b.txt')) + }) + + it('confines concurrent filesystem writes to each calling session workspace', async () => { + const { active, agentA, agentB } = await agents() + const [aOwn, bOwn, aCross, bCross] = await Promise.all([ + active.tools.execute({ + callId: CallId('fs-a-own'), name: 'write', agent: agentA, + arguments: { file_path: 'a-owned.txt', content: 'a' }, + }), + active.tools.execute({ + callId: CallId('fs-b-own'), name: 'write', agent: agentB, + arguments: { file_path: 'b-owned.txt', content: 'b' }, + }), + active.tools.execute({ + callId: CallId('fs-a-cross'), name: 'write', agent: agentA, + arguments: { file_path: join(projectB, 'from-a.txt'), content: 'cross' }, + }), + active.tools.execute({ + callId: CallId('fs-b-cross'), name: 'write', agent: agentB, + arguments: { file_path: join(projectA, 'from-b.txt'), content: 'cross' }, + }), + ]) + + expect(aOwn.isError).toBe(false) + expect(bOwn.isError).toBe(false) + expect(aCross.isError).toBe(true) + expect(bCross.isError).toBe(true) + expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a') + expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b') + await expectMissing(join(projectB, 'from-a.txt')) + await expectMissing(join(projectA, 'from-b.txt')) + }) +}) diff --git a/packages/fs/README.md b/packages/fs/README.md index f1025879ac..98737f380d 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -6,7 +6,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona |---|---|---| | `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) | +| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) | | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index 685ab838c8..23b5bb6628 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -2,11 +2,11 @@ `SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. -Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots. +Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots. ## The fence -The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default: +The per-call policy carries the effective mode (session override or escalation grant) together with the calling session's immutable cwd root, falling back to deployment policy only for calls without one: - `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`. - `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. @@ -30,4 +30,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s. - **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift. -- **Requires `ctx.sandboxPolicy`** — the backend reads the default mode and workspace root from it and does not confine without it composed. +- **Requires `ctx.sandboxPolicy`** — tools use it to resolve each session policy and the backend uses it for agentless-call fallbacks; the backend does not confine without it composed. diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 314778968e..2e22ca403e 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -3,7 +3,7 @@ * `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all * text-storage mechanics — resolve, stat, read/stream, list, the atomic * write and the read-match-write edit critical section — are the local - * implementation's, verbatim; this package adds only the per-call MODE fence + * implementation's, verbatim; this package adds only the per-call POLICY fence * on the two mutations. Reads pass through untouched: every mode permits * reading. * @@ -17,9 +17,9 @@ * syscall) is narrowed by re-canonicalizing immediately before delegating and * is accepted for this threat model. * - * Per-call mode: `read-only` denies every mutation; `workspace-write` allows a - * mutation only when the target canonicalizes under the workspace root or a - * platform temp area (the SAME writable-root set the Seatbelt profile grants, + * Per-call policy: `read-only` denies every mutation; `workspace-write` allows + * a mutation only when the target canonicalizes under the policy's workspace + * root or a platform temp area (the SAME writable-root set Seatbelt grants, * derived from the one `writableRoots` function so bash and fs cannot drift); * `danger-full-access` delegates unfenced. A denial throws the structured * `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel @@ -37,14 +37,14 @@ import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs' import { writableRoots } from '@deepseek-ai/dsh-sandbox' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve * base for relative paths). The sandbox default (mode + `workspace-write` - * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home - * both enforcing families share. + * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling + * session for both enforcing families. */ export type Config = LocalConfig @@ -59,26 +59,17 @@ function isUnder(path: string, root: string): boolean { * Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it * INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole * swap — the model-facing tools are untouched). Its configured default mode is - * the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's - * `sandbox/mode` override and stamps the effective mode onto each mutation, - * while an approved escalation may stamp a strictly wider mode for one call. + * the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves + * each session's mode and cwd into a policy for every mutation, while an + * approved escalation may stamp a strictly wider mode for one call. */ export class SandboxedFileSystem extends LocalFileSystem { static inject = ['sandboxPolicy'] private readonly defaultMode: SandboxMode - /** - * The canonical roots a `workspace-write` mutation may land under, computed - * once (the workspace root and platform temp areas are fixed for the - * provider's lifetime): the same set {@link writableRoots} gives every - * enforcement dialect, so the fs fence and the bash runner agree. - */ - private readonly writableRoots: string[] - constructor(ctx: Context, config: Config) { super(ctx, config) this.defaultMode = ctx.sandboxPolicy.defaultMode - this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot }) } /** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */ @@ -87,13 +78,14 @@ export class SandboxedFileSystem extends LocalFileSystem { } /** - * Fence the write by the per-call mode, then delegate to the inherited + * Fence the write by the per-call policy, then delegate to the inherited * atomic write. See {@link checkedTarget}. * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call mode; omit to use the deployment default. + * @param sandboxPolicy - the per-call mode and workspace root; omit to use + * the deployment fallback. * @returns the write outcome from the inherited backend. */ override async writeText( @@ -101,19 +93,20 @@ export class SandboxedFileSystem extends LocalFileSystem { content: string, expected?: FsWriteIntent, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal) + return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal) } /** - * Fence the edit by the per-call mode, then delegate to the inherited + * Fence the edit by the per-call policy, then delegate to the inherited * atomic edit. See {@link checkedTarget}. * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call mode; omit to use the deployment default. + * @param sandboxPolicy - the per-call mode and workspace root; omit to use + * the deployment fallback. * @returns the edit outcome from the inherited backend. */ override async editText( @@ -121,13 +114,13 @@ export class SandboxedFileSystem extends LocalFileSystem { edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal) + return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal) } /** - * Enforce the per-call mode against `target` and return the EXACT target the + * Enforce the per-call policy against `target` and return the EXACT target the * mutation must use, so the checked identity is the mutated one (no * check-here-write-there TOCTOU). `read-only` denies; `workspace-write` * re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor, @@ -137,8 +130,9 @@ export class SandboxedFileSystem extends LocalFileSystem { * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker * and the escalation hint. */ - private async checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise { - const mode = sandboxMode ?? this.defaultMode + private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise { + const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() + const { mode } = policy if (mode === 'danger-full-access') return target if (mode === 'read-only') { throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED') @@ -147,7 +141,7 @@ export class SandboxedFileSystem extends LocalFileSystem { // symlink ancestor swapped since the tool resolved this target), and the // mutation delegates with THIS fresh target — never the stale one. const fresh = await this.resolve(target.displayPath) - if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) { + if (!writableRoots(policy).some(root => isUnder(fresh.targetKey, root))) { throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED') } return fresh diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 12f0abb0df..6c837a4294 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the sandbox-enforcing filesystem backend: the per-call mode fence + * Tests for the sandbox-enforcing filesystem backend: the per-call policy fence * on write/edit (read-only denies, workspace-write contains, danger-full-access * passes through), reads always passing through, the capability fact, and the * containment matrix — `..` traversal, absolute paths outside, and symlink @@ -195,12 +195,12 @@ describe('danger-full-access', () => { }) }) -describe('the per-call mode override (escalation)', () => { +describe('the per-call policy override (escalation)', () => { it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => { await boot('read-only') const path = join(workspace, 'escalated.txt') - // Default read-only would deny; the per-call workspace-write stamp allows it (contained). - await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write') + // Default read-only would deny; the per-call workspace-write policy allows it (contained). + await fs.writeText(await target(path), 'granted', undefined, undefined, { mode: 'workspace-write', workspaceRoot: workspace }) expect(await readFile(path, 'utf8')).toBe('granted') // A neighboring plain call still runs under the read-only default. await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x')) @@ -210,7 +210,7 @@ describe('the per-call mode override (escalation)', () => { it('a danger-full-access stamp bypasses the fence for that call', async () => { await boot('read-only') const path = join(outside, 'granted-full.txt') - await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access') + await fs.writeText(await target(path), 'full', undefined, undefined, { mode: 'danger-full-access', workspaceRoot: workspace }) expect(await readFile(path, 'utf8')).toBe('full') }) }) diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 0279273d40..b43fa48c4b 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -7,7 +7,7 @@ */ import { Context, Service } from 'cordis' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { FsDirEntry, FsEditOutcome, @@ -170,9 +170,9 @@ export abstract class FileSystem extends Service { * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this write runs under; a - * sandboxing backend fences the write by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this write + * runs under; a sandboxing backend fences the write by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the write produced. */ abstract writeText( @@ -180,7 +180,7 @@ export abstract class FileSystem extends Service { content: string, expected?: FsWriteIntent, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise /** @@ -191,9 +191,9 @@ export abstract class FileSystem extends Service { * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. - * @param sandboxMode - the per-call sandbox mode this edit runs under; a - * sandboxing backend fences the edit by it, the bare backend ignores it. - * Omit to leave the backend its own default. + * @param sandboxPolicy - the per-call mode and workspace root this edit runs + * under; a sandboxing backend fences the edit by it, the bare backend + * ignores it. Omit to leave the backend its own default. * @returns the outcome, including the version the edit produced. */ abstract editText( @@ -201,7 +201,7 @@ export abstract class FileSystem extends Service { edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise } diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 2eeffe65e6..0f72529567 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -36,7 +36,7 @@ class ProbeSuccessBashExecutor extends BashExecutor { timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, signal: request.signal, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c11b10556a..bf3774517b 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -72,7 +72,7 @@ class FakeBash extends BashExecutor { timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, signal: request.signal, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } override async run(spec: BashExecSpec): Promise { diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 46655f80b7..a0742c730d 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -92,9 +92,9 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { }, async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseEditArgs(args) - // Resolve the per-call sandbox mode (escalation grant > session override - // > backend default) BEFORE anything executes. - const sandboxMode = await sandbox.stampMode('edit', args, exec) + // Resolve the per-call sandbox policy (approved mode > session override + // > backend default, plus the session cwd root) BEFORE anything executes. + const sandboxPolicy = await sandbox.resolvePolicy('edit', args, exec) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). @@ -107,11 +107,11 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, intent, exec.signal, - sandboxMode, + sandboxPolicy, ) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through. - throw sandbox.mapError(error, sandboxMode) + throw sandbox.mapError(error, sandboxPolicy) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index c7c217b609..a4c96d606b 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -64,7 +64,7 @@ export function apply(ctx: Context, config: Config): void { streamMinSize: resolved.readStreamMinSize, }) // One escalation surface shared by both mutating tools: advertisement gating, - // per-call mode stamping, and denial-marker mapping, all keyed off whether + // per-call policy resolution, and denial-marker mapping, all keyed off whether // the mounted ctx.fs confines (ctx.fs.sandboxMode). const sandbox = new FsSandboxSurface(ctx) applyWriteTool(ctx, sandbox) diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index f58f9d6b13..d9946b9f4a 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -1,6 +1,6 @@ /** * The sandbox-escalation surface shared by the `write` and `edit` tools: the - * per-call mode stamp, the advertised escalation fields, and the denial-marker + * per-call policy resolution, the advertised escalation fields, and the denial-marker * mapping — all delegating the vocabulary and the fail-closed approval * sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash` * uses), so bash and fs escalate identically. Built ONCE per plugin from @@ -12,9 +12,9 @@ import type { Context } from 'cordis' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { FsError } from '@deepseek-ai/dsh-fs' /** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */ @@ -30,20 +30,23 @@ export interface EscalationSchemaFields { } /** - * The filesystem escalation surface: advertisement gating, per-call mode - * stamping (folding the session's `sandbox/mode` override), the one-approved - * wider retry, and denial-marker mapping. A pure product of `ctx` at plugin - * apply time. + * The filesystem escalation surface: advertisement gating, per-call policy + * resolution, the one-approved wider retry, and denial-marker mapping. A pure + * product of `ctx` at plugin apply time. */ export class FsSandboxSurface { /** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */ readonly escalationModes: readonly SandboxMode[] - /** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */ - private readonly defaultMode: SandboxMode | undefined + /** Shared per-session policy resolver, required by a confining backend. */ + private readonly policy: SandboxPolicyService | undefined constructor(private readonly ctx: Context) { - this.defaultMode = ctx.fs.sandboxMode - this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS + const defaultMode = ctx.fs.sandboxMode + this.escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS + this.policy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && this.policy === undefined) { + throw new Error('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing') + } } /** @@ -70,37 +73,29 @@ export class FsSandboxSurface { } /** - * The session's standing mode override for an ordinary (non-escalating) - * call — the `sandbox/mode` fold of the calling agent's log. Undefined for a - * non-confining backend and for agent-less callers. - */ - private sessionOverride(exec: ToolExecution): SandboxMode | undefined { - if (this.defaultMode === undefined || exec.agent === undefined) return undefined - return effectiveSandboxMode(exec.agent.session.events) - } - - /** - * The mode to STAMP onto this mutation: an approved escalation grant (a + * The policy to stamp onto this mutation: an approved escalation grant (a * strictly wider retry resolved through `ctx.approval` before anything - * executes), else the session's standing override, else `undefined` (the - * backend applies its own default). Validates the escalation argument + * executes), else the session's standing mode. The calling session's cwd is + * always carried as the workspace root. Validates the escalation argument * pairing first. * @param toolName - the mutating tool's name, for the approval audit trail. * @param args - the call's escalation arguments. * @param exec - the tool-execution context (agent, callId, signal). - * @returns the mode to pass to the mutation, or undefined for the backend default. + * @returns the policy to pass to the mutation, or undefined for an + * unsandboxed backend. */ - async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise { + async resolvePolicy(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise { validateEscalationArgs(args.sandbox_permissions, args.justification) + const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} }) if (args.sandbox_permissions === undefined || args.justification === undefined) { - return this.sessionOverride(exec) + return standingPolicy } if (this.escalationModes.length === 0) { throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)') } - const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode - return approveEscalation( - { requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' }, + const policy = standingPolicy as SandboxExecutionPolicy + const approvedMode = await approveEscalation( + { requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode: policy.mode, subject: 'operation' }, { approver: this.ctx.get('approval'), agent: exec.agent, @@ -109,6 +104,7 @@ export class FsSandboxSurface { ...exec.signal ? { signal: exec.signal } : {}, }, ) + return { ...policy, mode: approvedMode } } /** @@ -122,14 +118,14 @@ export class FsSandboxSurface { * confining backend, which always advertises the escalation fields, so the * hint always applies here. * @param error - the error thrown by the mutation. - * @param stampedMode - the mode stamped onto the call (names the mode in the marker). + * @param policy - the policy stamped onto the call (names the mode in the marker). * @returns the error to throw — the marker `FsError` for a sandbox denial, else the original. */ - mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown { + mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown { if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error - // A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode - // (hence the resolved mode) is defined here. - const mode = (stampedMode ?? this.defaultMode) as SandboxMode + // A FS_SANDBOX_DENIED only arises under a confining backend, whose tool + // path always resolves a policy before mutation. + const mode = (policy as SandboxExecutionPolicy).mode return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error }) } } diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 3f23e9b5ea..4e7bf48816 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -76,21 +76,21 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { }, async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseWriteArgs(args) - // Resolve the per-call sandbox mode (escalation grant > session override - // > backend default) BEFORE anything executes; an escalating call - // resolves approval here and throws its distinct text on any non-grant. - const sandboxMode = await sandbox.stampMode('write', args, exec) + // Resolve the per-call sandbox policy (approved mode > session override + // > backend default, plus the session cwd root) BEFORE anything executes; + // an escalating call throws its distinct text on any non-grant. + const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) let outcome: FsWriteOutcome try { - outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode) + outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model // recognizes it from bash); any other error passes through. - throw sandbox.mapError(error, sandboxMode) + throw sandbox.mapError(error, sandboxPolicy) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4d80061584..4d71e9cd05 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -25,7 +25,8 @@ import { STREAM_MIN_SIZE } from '../src/read.ts' import { formatReadOutput } from '../src/read-render.ts' import type { FileReadOutcome } from '../src/read-render.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { @@ -584,9 +585,9 @@ describe('read caps are plugin config', () => { }) describe('sandbox escalation surface (write/edit)', () => { - /** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */ + /** A confining fake `ctx.fs`: reports a default mode, records each per-call policy, and can arm a sandbox denial. */ class SandboxingFakeFs extends FakeFs { - stamped: (SandboxMode | undefined)[] = [] + stamped: (SandboxExecutionPolicy | undefined)[] = [] override get sandboxMode(): SandboxMode { return 'workspace-write' } @@ -595,9 +596,9 @@ describe('sandbox escalation surface (write/edit)', () => { content: string, expected?: FsWriteIntent, _signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - this.stamped.push(sandboxMode) + this.stamped.push(sandboxPolicy) return super.writeText(target, content, expected) } override async editText( @@ -605,9 +606,9 @@ describe('sandbox escalation surface (write/edit)', () => { edit: FsEditRequest, expected?: { version: FsVersion }, _signal?: AbortSignal, - sandboxMode?: SandboxMode, + sandboxPolicy?: SandboxExecutionPolicy, ): Promise { - this.stamped.push(sandboxMode) + this.stamped.push(sandboxPolicy) return super.editText(target, edit, expected) } } @@ -616,6 +617,7 @@ describe('sandbox escalation surface (write/edit)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) if (opts.approval === true) await ctx.plugin(ApprovalService) @@ -628,7 +630,7 @@ describe('sandbox escalation surface (write/edit)', () => { return { id: 'agent-fs-esc', session: { - header: { version: 0, id: 'sess-fs-esc', createdAt: 0 }, + header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, events: [{ type: 'turn/start' }, ...events], append: (type: string, data: Record) => { events.push({ type, data }) }, }, @@ -641,6 +643,14 @@ describe('sandbox escalation surface (write/edit)', () => { return schema as unknown as { parameters: { properties: Record } } } + it('fails load when a confining filesystem has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SandboxingFakeFs) + await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing') + }) + it('advertises no escalation fields under a non-confining backend', async () => { const { ctx } = await setup() expect(ctx.fs.sandboxMode).toBeUndefined() @@ -660,16 +670,16 @@ describe('sandbox escalation surface (write/edit)', () => { } }) - it('a plain write stamps nothing (backend default) and no session override folds without one', async () => { + it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([undefined]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual(['read-only']) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -702,7 +712,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual(['danger-full-access']) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index c2990e10be..1e2ab0be0b 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -26,7 +26,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise): { ...request.signal ? { signal: request.signal } : {}, ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } }, async run(spec: BashExecSpec): Promise { diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 990da84d1a..1ab5fbcc07 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -1,12 +1,12 @@ # sandbox/ — process-sandbox capability family -The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages. +The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; a complete `SandboxExecutionPolicy` (mode + workspace root) rides each capability call, and its confined subset becomes the provider's `SandboxPolicy`. Different sessions and consumers can therefore confine under different policies at the same instant. All **product** packages. | Package | Role | ctx key | |---|---|---| | `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` | | `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) | -| `sandbox-policy/` | The policy home: the deployment default (mode + `workspace-write` boundary root) and the per-session `sandbox/mode` override (event + fold + write path). Both enforcing families read it, so bash and fs can never confine to different roots | `ctx.sandboxPolicy` | +| `sandbox-policy/` | The policy resolver: deployment fallbacks plus each session's durable mode and immutable cwd root. Both enforcing families consume its complete per-call result, so bash and fs cannot confine to different roots | `ctx.sandboxPolicy` | The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 4ea562b376..0dbab99e52 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -1,26 +1,27 @@ # dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`) -The single owner of the deployment's sandbox policy: the file-effect [`SandboxMode`](../sandbox/README.md) a session starts from, the `workspace-write` boundary root, and the per-session `sandbox/mode` override every enforcing capability family reads. +The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability family receives one resolved mode-and-root policy per call. ## Why a shared home -Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each held its own `mode` + `workspaceRoot` config, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both inject `ctx.sandboxPolicy` and read the SAME default instead. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the decision. +Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each resolved its own `mode` + `workspaceRoot`, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both tool layers resolve policy through `ctx.sandboxPolicy`, and both enforcing backends consume that complete per-call result. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision. ## Config - `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe). -- `workspaceRoot` — the absolute directory `workspace-write` may write under. Default `process.cwd()`, resolved absolute either way. +- `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved absolute either way. A normal agent call uses its session header's immutable `cwd` instead. ## Surface -- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default the enforcing implementations read for their resolve fallback and boundary. -- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`). The tool layers apply it to stamp each call, so neither the executor nor the provider depends on session events. +- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` becomes `workspaceRoot`, otherwise the configured fallback applies. +- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. +- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. ## The per-session store -A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = fold(events) ?? the deployment default`, so an override survives restart by replay, two sessions 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 enforcing tools' denial markers, never from the event. Execution honors the fold in each tool layer, weakest-precedence beneath an escalation grant. +A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. ## Model Experience @@ -32,5 +33,5 @@ No direct invalidation; the named consumers own any request-prefix changes, and ## Known Limitations and Deferred Work -- **`workspaceRoot` is process-wide and fixed for the service's lifetime** — a per-session workspace root is a deferred phase of the sandbox RFC; this package centralizing the root is its groundwork, not its design. +- **One primary workspace root per session** — policy resolves `SessionHeader.cwd`; extra writable roots are not part of `SandboxExecutionPolicy`. - **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index e5a318e3e1..39c01b450a 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", - "description": "Sandbox policy home (ctx.sandboxPolicy) for the DeepSeek Harness: the deployment default mode + workspace root and the per-session sandbox/mode override, shared by every enforcing capability family", + "description": "Per-call sandbox policy resolver (ctx.sandboxPolicy): deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index cd7a1545a8..cd768c1db8 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -1,30 +1,25 @@ /** * The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the - * deployment's sandbox default — the file-effect {@link SandboxMode} a session - * starts from and the `workspace-write` boundary root — plus the per-session - * override kit (the `sandbox/mode` event, its fold, and its write path, from - * `./session-mode.ts`). + * deployment's sandbox fallbacks plus per-session resolution: the file-effect + * {@link SandboxMode}, the `workspace-write` root, and the override kit (the + * `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`). * * Both enforcing capability families read the SAME policy here: the sandboxed * bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem - * provider (`@deepseek-ai/dsh-fs-sandbox`) inject `ctx.sandboxPolicy` for the - * default mode and workspace root, so bash and fs can never confine to - * different roots — the split world the sandbox RFC warns about. The default - * lives here rather than on either executor's config precisely because it is - * one fact two families share. - * - * This service holds only the DEFAULT; the per-session fold - * ({@link effectiveSandboxMode}) is a pure function the tool layers apply to - * stamp each call, so neither the executor nor the provider depends on session - * events. + * provider (`@deepseek-ai/dsh-fs-sandbox`) consume the SAME resolved per-call + * policy, so bash and fs can never confine to different roots — the split + * world the sandbox RFC warns about. The service reads session state once at + * the tool boundary; executors and providers remain session-free. * * @module @deepseek-ai/dsh-sandbox-policy */ -import { resolve } from 'node:path' +import { resolve as resolvePath } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { Session } from '@deepseek-ai/dsh-session' +import { effectiveSandboxMode } from './session-mode.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' @@ -45,17 +40,25 @@ export interface Config { /** File-sandbox mode a session starts from (default: `read-only`). */ mode?: SandboxMode /** - * Absolute root directory `workspace-write` may write under (default: - * `process.cwd()`). Both enforcing families fence against this SAME root. + * Fallback root for agentless calls and sessions without a cwd (default: + * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string } +/** Inputs that select the sandbox policy for one capability call. */ +export interface SandboxPolicyRequest { + /** Calling session; its immutable cwd becomes the workspace boundary. */ + session?: Session + /** Explicit approved mode override, which outranks session policy. */ + mode?: SandboxMode +} + /** * The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment - * default mode and workspace root; enforcing implementations read - * {@link defaultMode} and {@link workspaceRoot}, and the tool layers fold each - * session's `sandbox/mode` override with {@link effectiveSandboxMode} on top. + * default mode and fallback workspace root. Tool layers call {@link resolve} + * for each execution so a session's mode log and immutable cwd travel together + * to every enforcing capability. */ export class SandboxPolicyService extends Service { // Inline schema call: the config catalog walks `static Config` statically. @@ -68,7 +71,7 @@ export class SandboxPolicyService extends Service { /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode - /** The absolute `workspace-write` boundary root both families fence against. */ + /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string constructor(ctx: Context, config: Config) { @@ -77,7 +80,24 @@ export class SandboxPolicyService extends Service { // runtime fact. `workspaceRoot` has NO schema default, so its fallback to // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode - this.workspaceRoot = resolve(config.workspaceRoot ?? process.cwd()) + this.workspaceRoot = resolvePath(config.workspaceRoot ?? process.cwd()) + } + + /** + * Resolve the complete policy for one capability call. An approved explicit + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. + * @param request - optional session and approved mode override. + * @returns the fully resolved per-call mode and absolute workspace root. + */ + resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { + const { session } = request + return { + mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode, + workspaceRoot: resolvePath(session?.header.cwd ?? this.workspaceRoot), + } } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 62be36501f..ad7fe0ef29 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -7,9 +7,9 @@ * and there is no external config store. The event is log-only (the * `approval/*` precedent): the model learns the mode from the boundary * markers in the enforcing tools, never from the event itself. EXECUTION - * honors the fold in each tool layer — it stamps the effective mode onto the - * per-call policy carrier (a bash request's `sandboxMode`, an fs mutation's - * `sandboxMode`), weakest-precedence beneath an escalation grant. + * honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode + * together with the calling session's workspace root onto each capability + * call, weakest-precedence beneath an escalation grant. * * The override is policy state shared by every enforcing family (bash and * filesystem alike), so it lives here in the policy package rather than in any diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 52476fdece..77d6b6dae0 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -16,6 +16,16 @@ async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'dange return ctx } +function session(id: string, cwd?: string): Session { + const sessionId = SessionId(id) + return new Session(sessionId, undefined, { + version: 0, + id: sessionId, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) +} + describe('SandboxPolicyService', () => { it('defaults to read-only under the process cwd', async () => { const ctx = await mounted() @@ -29,6 +39,49 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub')) }) + it('resolves the deployment policy for an agentless call', async () => { + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) + expect(ctx.sandboxPolicy.resolve()).toEqual({ + mode: 'workspace-write', + workspaceRoot: resolve('/fallback'), + }) + }) + + it('resolves each session mode and cwd together without changing the fallback', async () => { + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) + const first = session('sess-first', '/projects/first') + const second = session('sess-second', '/projects/second') + setSandboxMode(second, 'read-only') + + expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ + mode: 'workspace-write', + workspaceRoot: resolve('/projects/first'), + }) + expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ + mode: 'read-only', + workspaceRoot: resolve('/projects/second'), + }) + expect(ctx.sandboxPolicy.resolve()).toEqual({ + mode: 'workspace-write', + workspaceRoot: resolve('/fallback'), + }) + }) + + it('lets an approved mode outrank the session mode while retaining its root', async () => { + const ctx = await mounted({ workspaceRoot: '/fallback' }) + const active = session('sess-approved', '/projects/approved') + setSandboxMode(active, 'read-only') + expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ + mode: 'danger-full-access', + workspaceRoot: resolve('/projects/approved'), + }) + }) + + it('uses the configured root when a session has no cwd', async () => { + const ctx = await mounted({ workspaceRoot: '/fallback' }) + expect(ctx.sandboxPolicy.resolve({ session: session('sess-no-cwd') }).workspaceRoot).toBe(resolve('/fallback')) + }) + it('rejects a mode outside the closed vocabulary at load', async () => { const ctx = new Context() // schemastery rejects the union violation when the plugin loads. diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index fccb08c18f..8c49c01a62 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-sandbox -Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. +Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend. The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined. diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index d54ef581ab..58a5165242 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -30,6 +30,18 @@ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' /** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */ export type ConfinedSandboxMode = Exclude +/** + * The complete file-effect policy resolved for one capability call. The root + * is carried even under modes that do not consume it so callers can resolve + * policy once before choosing the enforcement path. + */ +export interface SandboxExecutionPolicy { + /** The file-effect mode this execution runs under. */ + mode: SandboxMode + /** Absolute root directory `workspace-write` may write under. */ + workspaceRoot: string +} + /** * Enforcement completeness for this host. `partial` means an active backend or * older kernel ABI cannot govern every promised file effect; callers requiring @@ -42,15 +54,12 @@ export type SandboxEnforcement = 'full' | 'partial' * fixed on the provider: two consumers may confine under different policies * at the same instant (bash under `read-only` while a confined child agent * needs its state directory writable), and an approved escalated retry is a - * new call with a wider policy. Defaulting/resolution is the consumer's - * explicit step (its config owns the fallback chain); the provider treats - * the policy as fully specified. + * new call with a wider policy. Defaulting/resolution is an explicit step at + * the consumer boundary; the provider treats the policy as fully specified. */ -export interface SandboxPolicy { +export interface SandboxPolicy extends SandboxExecutionPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode - /** Absolute root directory `workspace-write` may write under. */ - workspaceRoot: string } /** diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts index 2d70148cdf..06e8cd7c09 100644 --- a/packages/sandbox/sandbox/src/roots.ts +++ b/packages/sandbox/sandbox/src/roots.ts @@ -15,7 +15,7 @@ import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' -import type { SandboxPolicy } from './index.ts' +import type { SandboxExecutionPolicy } from './index.ts' /** * Resolve a granted root to the path the enforcement layer actually compares: @@ -45,7 +45,7 @@ export function canonicalPath(path: string): string { * @param policy - the file-effect policy to derive the allow-list from. * @returns the canonical writable roots; empty exactly under `read-only`. */ -export function writableRoots(policy: SandboxPolicy): string[] { +export function writableRoots(policy: SandboxExecutionPolicy): string[] { if (policy.mode !== 'workspace-write') return [] return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0381eeda0a..5c8c1f97cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -750,9 +750,18 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home @@ -765,6 +774,12 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -783,6 +798,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../skill/tool-skill @@ -798,6 +816,9 @@ importers: cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + node-addon-landlock-run: + specifier: 0.0.0-test.0 + version: 0.0.0-test.0 packages/examples/cli-demo: devDependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9c35e19bd2..f3e01abd5c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -78,8 +78,10 @@ export const LINK_MAP: Record = { SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', ConfinedArgv: 'sandbox.md', + SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', SandboxPolicy: 'sandbox.md', + SandboxPolicyRequest: 'sandbox.md', ScopeKey: 'scope.md', Scoped: 'scope.md', EpochHeader: 'session.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 4840d81dbc..e6d037012a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -55,7 +55,7 @@ class CatalogSearchBashExecutor extends BashExecutor { timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, signal: request.signal, - sandboxMode: request.sandboxMode, + sandboxPolicy: request.sandboxPolicy, } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 61f5d52e62..032a22eb19 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -122,8 +122,10 @@ { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxExecutionPolicy", "source": "packages/sandbox/sandbox/src/index.ts" }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxEnforcement", "source": "packages/sandbox/sandbox/src/index.ts" }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxPolicyRequest", "source": "packages/sandbox/sandbox-policy/src/index.ts" }, { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" },