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

@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionCwd } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
/**
* Validate value constraints the schema DSL can't express: only a non-blank
@@ -47,11 +48,24 @@ ${verb} file
</content>`
}
/**
* The `write` tool's validated argument shape: the base parameters plus the
* two escalation fields, advertised only under a confining `ctx.fs` (absent
* from the schema otherwise, so the validator rejects them before `execute`).
*/
interface WriteToolArgs {
file_path: string
content: string
sandbox_permissions?: string
justification?: string
}
/**
* Register the `write` tool and its system-prompt guidance.
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
* @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
*/
export function applyWriteTool(ctx: Context): void {
export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
ctx.systemPrompt.section({
name: 'tool:write',
order: 101,
@@ -64,15 +78,27 @@ export function applyWriteTool(ctx: Context): void {
parameters: {
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
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)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// 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)
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
let outcome: FsWriteOutcome
try {
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode)
} 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)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version