Kaynağa Gözat

fix(sandbox): resolve workspace roots per session

Tianyi Cui 2 ay önce
ebeveyn
işleme
ff21f91a39
54 değiştirilmiş dosya ile 664 ekleme ve 305 silme
  1. 8 0
      .agents/notes/implemented/feature/2026-06-14-acp-multi-session.md
  2. 8 8
      .agents/notes/implemented/feature/2026-07-06-sandbox.md
  3. 2 2
      .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml
  4. 11 7
      .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md
  5. 11 7
      .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md
  6. 9 10
      docs/config-catalog.md
  7. 27 12
      docs/cordis-catalog/services.md
  8. 5 5
      docs/core-data-structures/bash.md
  9. 1 1
      docs/core-data-structures/core.md
  10. 32 7
      docs/core-data-structures/sandbox.md
  11. 3 3
      examples/acp-agent/README.md
  12. 2 2
      examples/acp-agent/cordis.yml
  13. 4 0
      knip.json
  14. 2 2
      packages/bash/bash-local/src/index.ts
  15. 3 3
      packages/bash/bash-sandbox/README.md
  16. 24 30
      packages/bash/bash-sandbox/src/index.ts
  17. 1 1
      packages/bash/bash-sandbox/tests/bwrap.e2e.ts
  18. 1 1
      packages/bash/bash-sandbox/tests/landlock.e2e.ts
  19. 16 11
      packages/bash/bash-sandbox/tests/sandbox.spec.ts
  20. 1 1
      packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
  21. 1 1
      packages/bash/bash/README.md
  22. 5 5
      packages/bash/bash/src/types.ts
  23. 2 2
      packages/bash/bash/tests/service.spec.ts
  24. 26 12
      packages/bash/tool-bash/src/index.ts
  25. 17 7
      packages/bash/tool-bash/tests/tools.spec.ts
  26. 21 8
      packages/cordis/tool-cordis/src/api-catalog.ts
  27. 7 0
      packages/examples/agent-spine-demo/package.json
  28. 149 0
      packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts
  29. 1 1
      packages/fs/README.md
  30. 3 3
      packages/fs/fs-sandbox/README.md
  31. 25 31
      packages/fs/fs-sandbox/src/index.ts
  32. 5 5
      packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
  33. 9 9
      packages/fs/fs/src/index.ts
  34. 1 1
      packages/fs/tool-fs-search/tests/load-path.spec.ts
  35. 1 1
      packages/fs/tool-fs-search/tests/tools.spec.ts
  36. 5 5
      packages/fs/tool-fs/src/edit.ts
  37. 1 1
      packages/fs/tool-fs/src/index.ts
  38. 31 35
      packages/fs/tool-fs/src/sandbox.ts
  39. 6 6
      packages/fs/tool-fs/src/write.ts
  40. 22 12
      packages/fs/tool-fs/tests/tools.spec.ts
  41. 1 1
      packages/hooks/hook-protocol/tests/runner.spec.ts
  42. 2 2
      packages/sandbox/README.md
  43. 8 7
      packages/sandbox/sandbox-policy/README.md
  44. 1 1
      packages/sandbox/sandbox-policy/package.json
  45. 43 23
      packages/sandbox/sandbox-policy/src/index.ts
  46. 3 3
      packages/sandbox/sandbox-policy/src/session-mode.ts
  47. 53 0
      packages/sandbox/sandbox-policy/tests/policy.spec.ts
  48. 1 1
      packages/sandbox/sandbox/README.md
  49. 15 6
      packages/sandbox/sandbox/src/index.ts
  50. 2 2
      packages/sandbox/sandbox/src/roots.ts
  51. 21 0
      pnpm-lock.yaml
  52. 2 0
      scripts/gen-cordis-catalog.ts
  53. 1 1
      scripts/gen-tool-catalog.ts
  54. 2 0
      scripts/type-equiv.manifest.json

+ 8 - 0
.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.

+ 8 - 8
.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).

+ 2 - 2
.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

+ 11 - 7
.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> 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> 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.

+ 11 - 7
.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> 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> 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` 事件名,一次性重录。

+ 9 - 10
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`
 

+ 27 - 12
docs/cordis-catalog/services.md

@@ -458,12 +458,12 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
  * @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<FsWriteOutcome>
+abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsWriteOutcome>
 
 /**
  * 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<FsEditOutcome>
+abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>
 ```
 
-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)
 

+ 5 - 5
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<string, string> | 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.
 

+ 1 - 1
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 |

+ 32 - 7
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
 }
 ```
 

+ 3 - 3
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
 

+ 2 - 2
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'
 

+ 4 - 0
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"]

+ 2 - 2
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,
     }
   }
 

+ 3 - 3
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
 

+ 24 - 30
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<BashRunResult> {
-    // 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,

+ 1 - 1
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')

+ 1 - 1
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')

+ 16 - 11
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')

+ 1 - 1
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')

+ 1 - 1
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).
 

+ 5 - 5
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<string, string> | 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. */

+ 2 - 2
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)

+ 26 - 12
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<SandboxMode> => {
+  const approveBashEscalation = (
+    mode: string,
+    justification: string,
+    exec: ToolExecution,
+    standingPolicy: SandboxExecutionPolicy | undefined,
+  ): Promise<SandboxMode> => {
     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.

+ 17 - 7
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<BashRunResult> {
-    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<BashRunResult> {

+ 21 - 8
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<FsWriteOutcome>',
-        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<FsWriteOutcome>',
+        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<FsEditOutcome>',
-        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<FsEditOutcome>',
+        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<string, string> | 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<string, string> | 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<string, string> | 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<string, string> | 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',

+ 7 - 0
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": {

+ 149 - 0
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<string> {
+  const dir = await mkdtemp(join(homedir(), `dsh-${label}-`))
+  tempDirs.push(dir)
+  return dir
+}
+
+async function expectMissing(path: string): Promise<void> {
+  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'))
+  })
+})

+ 1 - 1
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`) |

+ 3 - 3
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.

+ 25 - 31
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<FsWriteOutcome> {
-    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<FsEditOutcome> {
-    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<FsTarget> {
-    const mode = sandboxMode ?? this.defaultMode
+  private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
+    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

+ 5 - 5
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')
   })
 })

+ 9 - 9
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<FsWriteOutcome>
 
   /**
@@ -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<FsEditOutcome>
 }
 

+ 1 - 1
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,
     }
   }
 

+ 1 - 1
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<BashRunResult> {

+ 5 - 5
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)

+ 1 - 1
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)

+ 31 - 35
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<SandboxMode | undefined> {
+  async resolvePolicy(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxExecutionPolicy | undefined> {
     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 })
   }
 }

+ 6 - 6
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)

+ 22 - 12
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<FsWriteOutcome> {
-      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<FsEditOutcome> {
-      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<string, unknown>) => { events.push({ type, data }) },
       },
@@ -641,6 +643,14 @@ describe('sandbox escalation surface (write/edit)', () => {
     return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
   }
 
+  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 () => {

+ 1 - 1
packages/hooks/hook-protocol/tests/runner.spec.ts

@@ -26,7 +26,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
         ...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<BashRunResult> {

+ 2 - 2
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).
 

+ 8 - 7
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.

+ 1 - 1
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",

+ 43 - 23
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),
+    }
   }
 }
 

+ 3 - 3
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

+ 53 - 0
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.

+ 1 - 1
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.
 

+ 15 - 6
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<SandboxMode, 'danger-full-access'>
 
+/**
+ * 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
 }
 
 /**

+ 2 - 2
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))]
 }

+ 21 - 0
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:

+ 2 - 0
scripts/gen-cordis-catalog.ts

@@ -78,8 +78,10 @@ export const LINK_MAP: Record<string, string> = {
   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',

+ 1 - 1
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,
     }
   }
 

+ 2 - 0
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" },