feat(sandbox): cross-family file sandbox — one policy home, sandboxed fs provider, fs escalation parity

Extend SandboxMode enforcement from bash to the filesystem tools, the sandbox
RFC's deferred cross-family phase.

- dsh-sandbox-policy (new, ctx.sandboxPolicy): the single home for the
  deployment default mode + workspaceRoot and the per-session override event,
  renamed bash/sandbox-mode -> sandbox/mode and moved here with its fold/setter.
  Decouples the bash seam from dsh-session.
- dsh-fs-sandbox (new): SandboxedFileSystem extends LocalFileSystem and fences
  write/edit by the per-call mode (read-only denies, workspace-write contains to
  the workspace + temp roots via the shared writableRoots, danger passes
  through); reads pass through. Structured FS_SANDBOX_DENIED; in-lock parent
  re-canonicalization. A policy fence in trusted code, not a kernel boundary.
- dsh-sandbox: the shared escalation kit (writableRoots, the strictly-wider
  ladder, denial/hint markers, approveEscalation) both tool families use;
  approveEscalation takes a structural approver so dsh-sandbox gains no
  approval/agent dependency, and both tools stay duplication-free.
- tool-fs: write/edit advertise sandbox_permissions/justification under a
  confining ctx.fs, map FS_SANDBOX_DENIED to the shared [sandbox: ...] marker,
  and resolve the same one-approved-wider retry.
- examples/acp-agent: composes sandbox-policy + fs-sandbox, drops the gating
  that disabled the fs stack under confined modes.

RFC docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md; the old
sandbox RFC's In-process/deferred/FAQ sections updated to shipped fact.
This commit is contained in:
kingwl
2026-07-14 20:05:57 +08:00
parent c3137b5474
commit 2dc62497ce
100 changed files with 2238 additions and 385 deletions

View File

@@ -53,7 +53,7 @@ describe('PermissionService', () => {
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
const ctx = await mounted()
const session = freshSession('sess-custom')
session.append('bash/sandbox-mode', { mode: 'read-only' })
session.append('sandbox/mode', { mode: 'read-only' })
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
// Switching FROM custom is an ordinary write-through; custom itself is
// never a target.
@@ -80,7 +80,7 @@ describe('PermissionService', () => {
expect(ctx.permission.current(session.events)).toBe('agentish')
// A knob drifts: the fold's bundle no longer matches → reverse map wins.
session.append('approval/policy', { policy: 'never' })
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
})
@@ -90,7 +90,7 @@ describe('PermissionService', () => {
ctx.permission.set(session, 'danger-full-access')
expect(session.events.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['bash/sandbox-mode', { mode: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
})
@@ -109,12 +109,12 @@ describe('PermissionService', () => {
// A knob drifts out from under the preset (a direct setter call, a test
// scenario): the session derives custom, and re-asserting the preset is
// a real switch again — choice re-recorded, only the drifted knob moves.
session.append('bash/sandbox-mode', { mode: 'read-only' })
session.append('sandbox/mode', { mode: 'read-only' })
ctx.permission.set(session, 'danger-full-access')
const tail = session.events.slice(4)
expect(tail.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['bash/sandbox-mode', { mode: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
])
})