fix(sandbox): resolve workspace roots per session

This commit is contained in:
Tianyi Cui
2026-07-21 00:44:28 +08:00
parent 171a0e2b30
commit ff21f91a39
54 changed files with 664 additions and 305 deletions
+5 -5
View File
@@ -92,9 +92,9 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
},
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)
// Resolve the per-call sandbox policy (approved mode > session override
// > backend default, plus the session cwd root) BEFORE anything executes.
const sandboxPolicy = await sandbox.resolvePolicy('edit', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// Single-slot decision: the policy plugin returns { version: vObserved } or
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
@@ -107,11 +107,11 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
intent,
exec.signal,
sandboxMode,
sandboxPolicy,
)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
throw sandbox.mapError(error, sandboxMode)
throw sandbox.mapError(error, sandboxPolicy)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
+1 -1
View File
@@ -64,7 +64,7 @@ export function apply(ctx: Context, config: Config): void {
streamMinSize: resolved.readStreamMinSize,
})
// One escalation surface shared by both mutating tools: advertisement gating,
// per-call mode stamping, and denial-marker mapping, all keyed off whether
// per-call policy resolution, and denial-marker mapping, all keyed off whether
// the mounted ctx.fs confines (ctx.fs.sandboxMode).
const sandbox = new FsSandboxSurface(ctx)
applyWriteTool(ctx, sandbox)
+31 -35
View File
@@ -1,6 +1,6 @@
/**
* The sandbox-escalation surface shared by the `write` and `edit` tools: the
* per-call mode stamp, the advertised escalation fields, and the denial-marker
* per-call policy resolution, 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
@@ -12,9 +12,9 @@
import type { Context } from 'cordis'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, 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 type { SandboxPolicyService } 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). */
@@ -30,20 +30,23 @@ export interface EscalationSchemaFields {
}
/**
* 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.
* The filesystem escalation surface: advertisement gating, per-call policy
* resolution, 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
/** Shared per-session policy resolver, required by a confining backend. */
private readonly policy: SandboxPolicyService | undefined
constructor(private readonly ctx: Context) {
this.defaultMode = ctx.fs.sandboxMode
this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS
const defaultMode = ctx.fs.sandboxMode
this.escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS
this.policy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && this.policy === undefined) {
throw new Error('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')
}
}
/**
@@ -70,37 +73,29 @@ export class FsSandboxSurface {
}
/**
* 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
* The policy 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
* executes), else the session's standing mode. The calling session's cwd is
* always carried as the workspace root. 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.
* @returns the policy to pass to the mutation, or undefined for an
* unsandboxed backend.
*/
async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxMode | undefined> {
async resolvePolicy(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxExecutionPolicy | undefined> {
validateEscalationArgs(args.sandbox_permissions, args.justification)
const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} })
if (args.sandbox_permissions === undefined || args.justification === undefined) {
return this.sessionOverride(exec)
return standingPolicy
}
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' },
const policy = standingPolicy as SandboxExecutionPolicy
const approvedMode = await approveEscalation(
{ requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode: policy.mode, subject: 'operation' },
{
approver: this.ctx.get('approval'),
agent: exec.agent,
@@ -109,6 +104,7 @@ export class FsSandboxSurface {
...exec.signal ? { signal: exec.signal } : {},
},
)
return { ...policy, mode: approvedMode }
}
/**
@@ -122,14 +118,14 @@ export class FsSandboxSurface {
* 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).
* @param policy - the policy stamped onto the call (names the mode in the marker).
* @returns the error to throw — the marker `FsError` for a sandbox denial, else the original.
*/
mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown {
mapError(error: unknown, policy: SandboxExecutionPolicy | 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
// A FS_SANDBOX_DENIED only arises under a confining backend, whose tool
// path always resolves a policy before mutation.
const mode = (policy as SandboxExecutionPolicy).mode
return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })
}
}
+6 -6
View File
@@ -76,21 +76,21 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
},
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)
// Resolve the per-call sandbox policy (approved mode > session override
// > backend default, plus the session cwd root) BEFORE anything executes;
// an escalating call throws its distinct text on any non-grant.
const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// 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)
let outcome: FsWriteOutcome
try {
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode)
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
} 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)
throw sandbox.mapError(error, sandboxPolicy)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)