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

@@ -27,6 +27,8 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
class FakeFs extends FileSystem {
@@ -570,3 +572,163 @@ describe('read caps are plugin config', () => {
expect('default' in ToolFs).toBe(false)
})
})
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. */
class SandboxingFakeFs extends FakeFs {
stamped: (SandboxMode | undefined)[] = []
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsWriteOutcome> {
this.stamped.push(sandboxMode)
return super.writeText(target, content, expected)
}
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
): Promise<FsEditOutcome> {
this.stamped.push(sandboxMode)
return super.editText(target, edit, expected)
}
}
async function setupConfining(opts: { approval?: boolean } = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SandboxingFakeFs)
await ctx.plugin(FsPolicy)
if (opts.approval === true) await ctx.plugin(ApprovalService)
await ctx.plugin(ToolFs)
return { ctx, fs: ctx.fs as SandboxingFakeFs }
}
/** A fake agent whose session records appends (the approval audit surface), mid-turn, carrying the given events for the fold. */
function escalationAgent(events: Array<{ type: string; data?: Record<string, unknown> }> = []): object {
return {
id: 'agent-fs-esc',
session: {
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
events: [{ type: 'turn/start' }, ...events],
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
},
}
}
function fsSchema(ctx: Context, name: 'write' | 'edit') {
const schema = ctx.tools.schemas().find(s => s.name === name)
if (!schema) throw new Error(`${name} tool not registered`)
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
}
it('advertises no escalation fields under a non-confining backend', async () => {
const { ctx } = await setup()
expect(ctx.fs.sandboxMode).toBeUndefined()
for (const name of ['write', 'edit'] as const) {
const props = fsSchema(ctx, name).parameters.properties
expect(props['sandbox_permissions']).toBeUndefined()
expect(props['justification']).toBeUndefined()
}
})
it('advertises the closed target vocabulary on write and edit under a confining backend', async () => {
const { ctx } = await setupConfining()
for (const name of ['write', 'edit'] as const) {
const props = fsSchema(ctx, name).parameters.properties
expect(props['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
expect(props['justification']).toBeDefined()
}
})
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([undefined])
})
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'])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
const { ctx, fs } = await setupConfining()
fs.rejectWith = new FsError('denied', 'FS_SANDBOX_DENIED')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(text(result)).toContain('retry this exact operation once with sandbox_permissions')
})
it('a non-FS_SANDBOX_DENIED provider error passes through unchanged', async () => {
const { ctx, fs } = await setupConfining()
fs.rejectWith = new FsError('boom', 'FS_IO_ERROR')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('boom')
expect(text(result)).not.toContain('[sandbox:')
})
it('an approved escalation stamps the granted mode onto that write', async () => {
const { ctx, fs } = await setupConfining({ approval: true })
ctx.on('approval/request', () => Promise.resolve('allowed-once' as const))
// Pass a signal so the escalation ask forwards it to the approval request
// (the request rides the tool-execution abort signal).
await ctx.tools.execute({
callId: CallId('call-fs-esc-grant'),
name: 'write',
arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' },
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual(['danger-full-access'])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {
const { ctx, fs } = await setupConfining({ approval: true })
ctx.on('approval/request', () => Promise.resolve('rejected' as const))
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('the user rejected escalating this operation to "danger-full-access"')
expect(fs.stamped).toEqual([])
})
it('escalation without an approval service fails closed', async () => {
const { ctx } = await setupConfining()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('no approval service is composed')
})
it('escalation with an approval service but no agent fails closed', async () => {
const { ctx } = await setupConfining({ approval: true })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('no agent to route it through')
})
it('rejects the escalation argument pairing (one field without the other)', async () => {
const { ctx } = await setupConfining()
const missing = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write' }, escalationAgent())
expect(missing.isError).toBe(true)
expect(text(missing)).toContain('sandbox_permissions requires a justification')
})
it('sandbox_permissions under a non-confining backend fails closed (unadvertised field still reaches execute)', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent())
expect(result.isError).toBe(true)
expect(text(result)).toContain('not available in this composition')
})
})