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

View File

@@ -3,7 +3,7 @@
* `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all
* text-storage mechanics — resolve, stat, read/stream, list, the atomic
* write and the read-match-write edit critical section — are the local
* implementation's, verbatim; this package adds only the per-call MODE fence
* implementation's, verbatim; this package adds only the per-call POLICY fence
* on the two mutations. Reads pass through untouched: every mode permits
* reading.
*
@@ -17,9 +17,9 @@
* syscall) is narrowed by re-canonicalizing immediately before delegating and
* is accepted for this threat model.
*
* Per-call mode: `read-only` denies every mutation; `workspace-write` allows a
* mutation only when the target canonicalizes under the workspace root or a
* platform temp area (the SAME writable-root set the Seatbelt profile grants,
* Per-call policy: `read-only` denies every mutation; `workspace-write` allows
* a mutation only when the target canonicalizes under the policy's workspace
* root or a platform temp area (the SAME writable-root set Seatbelt grants,
* derived from the one `writableRoots` function so bash and fs cannot drift);
* `danger-full-access` delegates unfenced. A denial throws the structured
* `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
@@ -37,14 +37,14 @@ import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
/**
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
* base for relative paths). The sandbox default (mode + `workspace-write`
* boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home
* both enforcing families share.
* fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
* session for both enforcing families.
*/
export type Config = LocalConfig
@@ -59,26 +59,17 @@ function isUnder(path: string, root: string): boolean {
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
* swap — the model-facing tools are untouched). Its configured default mode is
* the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's
* `sandbox/mode` override and stamps the effective mode onto each mutation,
* while an approved escalation may stamp a strictly wider mode for one call.
* the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves
* each session's mode and cwd into a policy for every mutation, while an
* approved escalation may stamp a strictly wider mode for one call.
*/
export class SandboxedFileSystem extends LocalFileSystem {
static inject = ['sandboxPolicy']
private readonly defaultMode: SandboxMode
/**
* The canonical roots a `workspace-write` mutation may land under, computed
* once (the workspace root and platform temp areas are fixed for the
* provider's lifetime): the same set {@link writableRoots} gives every
* enforcement dialect, so the fs fence and the bash runner agree.
*/
private readonly writableRoots: string[]
constructor(ctx: Context, config: Config) {
super(ctx, config)
this.defaultMode = ctx.sandboxPolicy.defaultMode
this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot })
}
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
@@ -87,13 +78,14 @@ export class SandboxedFileSystem extends LocalFileSystem {
}
/**
* Fence the write by the per-call mode, then delegate to the inherited
* Fence the write by the per-call policy, then delegate to the inherited
* atomic write. See {@link checkedTarget}.
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call mode; omit to use the deployment default.
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
* the deployment fallback.
* @returns the write outcome from the inherited backend.
*/
override async writeText(
@@ -101,19 +93,20 @@ export class SandboxedFileSystem extends LocalFileSystem {
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsWriteOutcome> {
return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal)
return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal)
}
/**
* Fence the edit by the per-call mode, then delegate to the inherited
* Fence the edit by the per-call policy, then delegate to the inherited
* atomic edit. See {@link checkedTarget}.
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call mode; omit to use the deployment default.
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
* the deployment fallback.
* @returns the edit outcome from the inherited backend.
*/
override async editText(
@@ -121,13 +114,13 @@ export class SandboxedFileSystem extends LocalFileSystem {
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome> {
return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal)
return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal)
}
/**
* Enforce the per-call mode against `target` and return the EXACT target the
* Enforce the per-call policy against `target` and return the EXACT target the
* mutation must use, so the checked identity is the mutated one (no
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
@@ -137,8 +130,9 @@ export class SandboxedFileSystem extends LocalFileSystem {
* refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
* and the escalation hint.
*/
private async checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise<FsTarget> {
const mode = sandboxMode ?? this.defaultMode
private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const { mode } = policy
if (mode === 'danger-full-access') return target
if (mode === 'read-only') {
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
@@ -147,7 +141,7 @@ export class SandboxedFileSystem extends LocalFileSystem {
// symlink ancestor swapped since the tool resolved this target), and the
// mutation delegates with THIS fresh target — never the stale one.
const fresh = await this.resolve(target.displayPath)
if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) {
if (!writableRoots(policy).some(root => isUnder(fresh.targetKey, root))) {
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
}
return fresh