Merge refreshed schema DSL into canonical tool outputs

# Conflicts:
#	packages/bash/tool-bash/tests/tools.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-22 22:03:54 +08:00
81 changed files with 1285 additions and 412 deletions

View File

@@ -6,9 +6,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|---|---|---|
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).

View File

@@ -2,11 +2,11 @@
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots.
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots.
## The fence
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
The per-call policy carries the effective mode (session override or escalation grant) together with the calling session's immutable cwd root, falling back to deployment policy only for calls without one:
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
@@ -30,4 +30,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s.
- **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift.
- **Requires `ctx.sandboxPolicy`** — the backend reads the default mode and workspace root from it and does not confine without it composed.
- **Requires `ctx.sandboxPolicy`** — tools use it to resolve each session policy and the backend uses it for agentless-call fallbacks; the backend does not confine without it composed.

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
@@ -36,15 +36,15 @@ 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'
import { isPathUnder } from './containment.ts'
/**
* 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
@@ -52,26 +52,17 @@ export type Config = LocalConfig
* 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. */
@@ -80,13 +71,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(
@@ -94,19 +86,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(
@@ -114,13 +107,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,
@@ -130,8 +123,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')
@@ -141,7 +135,7 @@ export class SandboxedFileSystem extends LocalFileSystem {
// mutation delegates with THIS fresh target — never the stale one.
const fresh = await this.resolve(target.displayPath)
let contained = false
for (const root of this.writableRoots) {
for (const root of writableRoots(policy)) {
if (await isPathUnder(fresh.targetKey, root)) {
contained = true
break

View File

@@ -1,5 +1,5 @@
/**
* Tests for the sandbox-enforcing filesystem backend: the per-call mode fence
* Tests for the sandbox-enforcing filesystem backend: the per-call policy fence
* on write/edit (read-only denies, workspace-write contains, danger-full-access
* passes through), reads always passing through, the capability fact, and the
* containment matrix — `..` traversal, absolute paths outside, and symlink
@@ -194,12 +194,12 @@ describe('danger-full-access', () => {
})
})
describe('the per-call mode override (escalation)', () => {
describe('the per-call policy override (escalation)', () => {
it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => {
await boot('read-only')
const path = join(workspace, 'escalated.txt')
// Default read-only would deny; the per-call workspace-write stamp allows it (contained).
await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write')
// Default read-only would deny; the per-call workspace-write policy allows it (contained).
await fs.writeText(await target(path), 'granted', undefined, undefined, { mode: 'workspace-write', workspaceRoot: workspace })
expect(await readFile(path, 'utf8')).toBe('granted')
// A neighboring plain call still runs under the read-only default.
await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x'))
@@ -209,7 +209,7 @@ describe('the per-call mode override (escalation)', () => {
it('a danger-full-access stamp bypasses the fence for that call', async () => {
await boot('read-only')
const path = join(outside, 'granted-full.txt')
await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access')
await fs.writeText(await target(path), 'full', undefined, undefined, { mode: 'danger-full-access', workspaceRoot: workspace })
expect(await readFile(path, 'utf8')).toBe('full')
})
})

View File

@@ -7,7 +7,7 @@
*/
import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {
FsDirEntry,
FsEditOutcome,
@@ -170,9 +170,9 @@ export abstract class FileSystem extends Service {
* @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 sandbox mode this write runs under; a
* sandboxing backend fences the write by it, the bare backend ignores it.
* Omit to leave the backend its own default.
* @param sandboxPolicy - the per-call mode and workspace root this write
* runs under; a sandboxing backend fences the write by it, the bare backend
* ignores it. Omit to leave the backend its own default.
* @returns the outcome, including the version the write produced.
*/
abstract writeText(
@@ -180,7 +180,7 @@ export abstract class FileSystem extends Service {
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsWriteOutcome>
/**
@@ -191,9 +191,9 @@ export abstract class FileSystem extends Service {
* @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 sandbox mode this edit runs under; a
* sandboxing backend fences the edit by it, the bare backend ignores it.
* Omit to leave the backend its own default.
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
* under; a sandboxing backend fences the edit by it, the bare backend
* ignores it. Omit to leave the backend its own default.
* @returns the outcome, including the version the edit produced.
*/
abstract editText(
@@ -201,7 +201,7 @@ export abstract class FileSystem extends Service {
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome>
}

View File

@@ -36,7 +36,7 @@ class ProbeSuccessBashExecutor extends BashExecutor {
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}

View File

@@ -75,7 +75,7 @@ class FakeBash extends BashExecutor {
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...this.forwardSignal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {

View File

@@ -110,10 +110,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
},
async execute(args: EditToolArgs, exec) {
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 target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(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, input.filePath, sandboxPolicy?.workspaceRoot))
// 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.
@@ -125,11 +125,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)

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)

View File

@@ -123,7 +123,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
isConcurrencySafe: () => true,
async execute(args, exec) {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
// One stat: type check + size routing + the version recorded as observed.
// A concurrent write can only make a later guarded mutation fail stale and require reread.

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 {
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 })
}
}

View File

@@ -9,23 +9,36 @@
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/
/**
* The session workspace cwd for this call, or `undefined` when none applies.
* @param exec - the tool-execution context; only its optional `agent` is read.
* @param requestedPath - the path the provider will resolve; parent traversal
* makes a symlinked cwd's filesystem identity observable.
* @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
*/
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined {
const cwd = exec.agent?.session.header.cwd
if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd
return canonicalPath(cwd)
}
/**
* Resolution options shared by all model-facing filesystem tools.
* @param exec - the tool-execution context supplying session cwd and cancellation.
* @param requestedPath - the path the provider will resolve.
* @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy.
* @returns provider resolution options for the current tool call.
*/
export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; signal?: AbortSignal } {
const cwd = sessionCwd(exec)
export function sessionResolveOptions(
exec: ToolExecution,
requestedPath: string,
policyWorkspaceRoot?: string,
): { cwd?: string; signal?: AbortSignal } {
const cwd = policyWorkspaceRoot ?? sessionCwd(exec, requestedPath)
return {
...cwd !== undefined ? { cwd } : {},
signal: exec.signal,

View File

@@ -100,21 +100,21 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
},
async execute(args: WriteToolArgs, exec) {
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 target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(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, input.filePath, sandboxPolicy?.workspaceRoot))
// 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)

View File

@@ -5,6 +5,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -24,8 +27,10 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { STREAM_MIN_SIZE } from '../src/read.ts'
import { formatReadOutput } from '../src/read-render.ts'
import type { FileReadOutcome } from '../src/read-render.ts'
import { sessionCwd } from '../src/session-cwd.ts'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
const testToolSignal = new AbortController().signal
@@ -107,6 +112,32 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('session cwd resolution', () => {
const execution = (cwd?: string) => cwd === undefined
? {}
: { agent: { session: { header: { cwd } } } }
it('retains ordinary spelling but resolves the cwd before parent traversal', () => {
const cwd = process.cwd()
const throughParent = `${cwd}${sep}..`
expect(sessionCwd(execution() as never, 'file.txt')).toBeUndefined()
expect(sessionCwd(execution(cwd) as never, 'file.txt')).toBe(cwd)
expect(sessionCwd(execution(throughParent) as never, 'file.txt')).toBe(realpathSync.native(throughParent))
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-fs-session-cwd-'))
const physical = join(root, 'physical')
const link = join(root, 'link')
try {
mkdirSync(physical)
symlinkSync(physical, link, process.platform === 'win32' ? 'junction' : 'dir')
expect(sessionCwd(execution(link) as never, 'child.txt')).toBe(link)
expect(sessionCwd(execution(link) as never, `..${sep}parent.txt`)).toBe(realpathSync.native(link))
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describe('registration', () => {
it('registers read, write, and edit', async () => {
const { ctx } = await setup()
@@ -610,9 +641,9 @@ describe('read caps are plugin config', () => {
})
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. */
/** A confining fake `ctx.fs`: reports a default mode, records each per-call policy, and can arm a sandbox denial. */
class SandboxingFakeFs extends FakeFs {
stamped: (SandboxMode | undefined)[] = []
stamped: (SandboxExecutionPolicy | undefined)[] = []
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
@@ -621,9 +652,9 @@ describe('sandbox escalation surface (write/edit)', () => {
content: string,
expected?: FsWriteIntent,
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsWriteOutcome> {
this.stamped.push(sandboxMode)
this.stamped.push(sandboxPolicy)
return super.writeText(target, content, expected)
}
override async editText(
@@ -631,9 +662,9 @@ describe('sandbox escalation surface (write/edit)', () => {
edit: FsEditRequest,
expected?: { version: FsVersion },
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome> {
this.stamped.push(sandboxMode)
this.stamped.push(sandboxPolicy)
return super.editText(target, edit, expected)
}
}
@@ -642,6 +673,7 @@ describe('sandbox escalation surface (write/edit)', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' })
await ctx.plugin(SandboxingFakeFs)
await ctx.plugin(FsPolicy)
if (opts.approval === true) await ctx.plugin(ApprovalService)
@@ -654,7 +686,7 @@ describe('sandbox escalation surface (write/edit)', () => {
return {
id: 'agent-fs-esc',
session: {
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' },
events: [{ type: 'turn/start' }, ...events],
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
},
@@ -667,6 +699,14 @@ describe('sandbox escalation surface (write/edit)', () => {
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
}
it('fails load when a confining filesystem has no shared sandbox-policy resolver', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SandboxingFakeFs)
await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')
})
it('advertises no escalation fields under a non-confining backend', async () => {
const { ctx } = await setup()
expect(ctx.fs.sandboxMode).toBeUndefined()
@@ -686,16 +726,16 @@ describe('sandbox escalation surface (write/edit)', () => {
}
})
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
it('a plain write stamps the default mode with the calling session root', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([undefined])
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
})
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'])
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
@@ -728,7 +768,7 @@ describe('sandbox escalation surface (write/edit)', () => {
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual(['danger-full-access'])
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {