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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user