fix(sandbox): address PR #309 review — TOCTOU direction, denial metadata, shared roots, docs

- fs-sandbox: delegate the mutation with the freshly re-canonicalized target
  (not the stale one), so the checked identity is the mutated identity — a
  symlink swapped in after resolve() can no longer escape workspace-write.
- tool-fs: map a denial to an FsError carrying FS_SANDBOX_DENIED (not a plain
  Error), so ToolRegistry keeps the structured code on result.error for
  retry/observers while the message stays the shared marker.
- sandbox-local: derive the Seatbelt writable set from the shared
  writableRoots() helper, so the profile and the fs fence cannot drift.
- gen-doc-graphs: ctx.sandboxPolicy is owned by dsh-sandbox-policy and read only
  by the sandboxed executor/provider (the tool layers use the pure fold).
- docs: bash-sandbox/bash/permission READMEs and bash.md reflect the relocated
  policy home and the sandbox/mode rename; drop the stale stdout.golden.jsonl.
This commit is contained in:
kingwl
2026-07-20 13:59:18 +08:00
parent 1dd5757897
commit 2530bf8aa3
11 changed files with 65 additions and 107 deletions

View File

@@ -14,7 +14,7 @@ import { existsSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
@@ -146,6 +146,19 @@ describe('workspace-write containment', () => {
expect(await readFile(path, 'utf8')).toBe('changed')
})
it('mutates the freshly checked identity, not a stale outside targetKey (TOCTOU direction)', async () => {
// A target whose displayPath is inside the workspace but whose targetKey is
// a STALE outside path — as if an ancestor symlink pointed out at the tool's
// resolve() and was swapped in before the write. The fence re-resolves
// displayPath (now inside) AND delegates with that fresh target, so the byte
// lands inside and the stale outside path is never written.
const insidePath = join(workspace, 'landed.txt')
const staleTarget: FsTarget = { displayPath: insidePath, targetKey: FsTargetKey(join(outside, 'escaped.txt')) }
await fs.writeText(staleTarget, 'inside')
expect(await readFile(insidePath, 'utf8')).toBe('inside')
expect(existsSync(join(outside, 'escaped.txt'))).toBe(false)
})
it('the workspace root itself passes the fence (path equal to a writable root), failing only on file type', async () => {
// isUnder's path-equals-root branch: the fence allows the root, and the
// write then fails because the root is a directory, not a regular file.