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:
@@ -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'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
@@ -29,6 +30,20 @@ interface EditInput {
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `edit` 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 EditToolArgs {
|
||||
file_path: string
|
||||
old_string: string
|
||||
new_string: string
|
||||
replace_all?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank
|
||||
* `file_path`, a non-empty `old_string`, and `old_string !== new_string`
|
||||
@@ -63,8 +78,9 @@ export function formatEditOutput(displayPath: string, replaceAll: boolean): stri
|
||||
/**
|
||||
* Register the `edit` 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 applyEditTool(ctx: Context): void {
|
||||
export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
order: 102,
|
||||
@@ -79,21 +95,32 @@ export function applyEditTool(ctx: Context): void {
|
||||
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseEditArgs(args)
|
||||
// Resolve the per-call sandbox mode (escalation grant > session override
|
||||
// > backend default) BEFORE anything executes.
|
||||
const sandboxMode = await sandbox.stampMode('edit', args, exec)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
)
|
||||
let outcome
|
||||
try {
|
||||
outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
sandboxMode,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker; 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)
|
||||
// The result-time applied-hunk diff (before→after with context lines). An
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
|
||||
import { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
|
||||
export type { ReadToolCaps } from './read.ts'
|
||||
@@ -37,6 +39,8 @@ export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } f
|
||||
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
|
||||
export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
export type { FsDiffMeta } from './diff.ts'
|
||||
export { FsSandboxSurface } from './sandbox.ts'
|
||||
export type { EscalationSchemaFields, FsEscalationArgs } from './sandbox.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs'
|
||||
@@ -87,6 +91,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
maxBytes: resolved.readMaxBytes,
|
||||
streamMinSize: resolved.readStreamMinSize,
|
||||
})
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
// One escalation surface shared by both mutating tools: advertisement gating,
|
||||
// per-call mode stamping, and denial-marker mapping, all keyed off whether
|
||||
// the mounted ctx.fs confines (ctx.fs.sandboxMode).
|
||||
const sandbox = new FsSandboxSurface(ctx)
|
||||
applyWriteTool(ctx, sandbox)
|
||||
applyEditTool(ctx, sandbox)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The sandbox-escalation surface shared by the `write` and `edit` tools: the
|
||||
* per-call mode stamp, the advertised escalation fields, and the denial-marker
|
||||
* mapping — all delegating the vocabulary and the fail-closed approval
|
||||
* sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash`
|
||||
* uses), so bash and fs escalate identically. Built ONCE per plugin from
|
||||
* `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?)
|
||||
* and shared by both mutating tools.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/sandbox
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */
|
||||
export interface FsEscalationArgs {
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/** The schema fields for the escalation arguments, spread into a tool's `parameters` when a confining backend is mounted. */
|
||||
export interface EscalationSchemaFields {
|
||||
sandbox_permissions: { type: 'string'; enum: string[]; description: string }
|
||||
justification: { type: 'string'; description: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* The filesystem escalation surface: advertisement gating, per-call mode
|
||||
* stamping (folding the session's `sandbox/mode` override), the one-approved
|
||||
* wider retry, and denial-marker mapping. A pure product of `ctx` at plugin
|
||||
* apply time.
|
||||
*/
|
||||
export class FsSandboxSurface {
|
||||
/** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
|
||||
readonly escalationModes: readonly SandboxMode[]
|
||||
/** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */
|
||||
private readonly defaultMode: SandboxMode | undefined
|
||||
|
||||
constructor(private readonly ctx: Context) {
|
||||
this.defaultMode = ctx.fs.sandboxMode
|
||||
this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
}
|
||||
|
||||
/**
|
||||
* The escalation schema fields for a mutating tool's `parameters`. Call it
|
||||
* only under a confining backend (guard on {@link escalationModes}); the
|
||||
* enum pins the closed target vocabulary, the strict-wider check happens per
|
||||
* call at execution.
|
||||
* @returns the two escalation parameter specs.
|
||||
*/
|
||||
schemaFields(): EscalationSchemaFields {
|
||||
return {
|
||||
sandbox_permissions: {
|
||||
type: 'string',
|
||||
enum: [...this.escalationModes],
|
||||
description: 'The wider sandbox mode this file operation needs. Only valid as a one-shot retry '
|
||||
+ 'of an operation the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string',
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
||||
+ 'why this exact file operation needs the wider access.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call — the `sandbox/mode` fold of the calling agent's log. Undefined for a
|
||||
* non-confining backend and for agent-less callers.
|
||||
*/
|
||||
private sessionOverride(exec: ToolExecution): SandboxMode | undefined {
|
||||
if (this.defaultMode === undefined || exec.agent === undefined) return undefined
|
||||
return effectiveSandboxMode(exec.agent.session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode to STAMP onto this mutation: an approved escalation grant (a
|
||||
* strictly wider retry resolved through `ctx.approval` before anything
|
||||
* executes), else the session's standing override, else `undefined` (the
|
||||
* backend applies its own default). Validates the escalation argument
|
||||
* pairing first.
|
||||
* @param toolName - the mutating tool's name, for the approval audit trail.
|
||||
* @param args - the call's escalation arguments.
|
||||
* @param exec - the tool-execution context (agent, callId, signal).
|
||||
* @returns the mode to pass to the mutation, or undefined for the backend default.
|
||||
*/
|
||||
async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxMode | undefined> {
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
if (args.sandbox_permissions === undefined || args.justification === undefined) {
|
||||
return this.sessionOverride(exec)
|
||||
}
|
||||
if (this.escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)')
|
||||
}
|
||||
const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode
|
||||
return approveEscalation(
|
||||
{ requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' },
|
||||
{
|
||||
approver: this.ctx.get('approval'),
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes an
|
||||
* error whose text is the shared `[sandbox: …]` denial marker plus the
|
||||
* same-turn escalation hint, so a policy denial reads identically to bash's;
|
||||
* any other error passes through unchanged. A `FS_SANDBOX_DENIED` only arises
|
||||
* under a confining backend, which always advertises the escalation fields,
|
||||
* so the hint always applies here.
|
||||
* @param error - the error thrown by the mutation.
|
||||
* @param stampedMode - the mode stamped onto the call (names the mode in the marker).
|
||||
* @returns the error to throw — the marker error for a sandbox denial, else the original.
|
||||
*/
|
||||
mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown {
|
||||
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
|
||||
// A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode
|
||||
// (hence the resolved mode) is defined here.
|
||||
const mode = (stampedMode ?? this.defaultMode) as SandboxMode
|
||||
return new Error(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user