fix(sandbox): preserve filesystem cwd semantics

This commit is contained in:
Tianyi Cui
2026-07-21 19:53:02 +08:00
parent 7677d49c93
commit ae98294ec5
14 changed files with 119 additions and 27 deletions

View File

@@ -8,7 +8,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `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 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/*`); resolves relative paths from the filesystem identity of the session cwd 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

@@ -95,7 +95,7 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
// 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))
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, 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.

View File

@@ -9,6 +9,7 @@
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
/**
* The session workspace cwd for this call, or `undefined` when none applies.
@@ -16,16 +17,18 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
* @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
const cwd = exec.agent?.session.header.cwd
return cwd === undefined ? undefined : canonicalPath(cwd)
}
/**
* Resolution options shared by all model-facing filesystem tools.
* @param exec - the tool-execution context supplying session cwd and cancellation.
* @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, policyWorkspaceRoot?: string): { cwd?: string; signal?: AbortSignal } {
const cwd = policyWorkspaceRoot ?? sessionCwd(exec)
return {
...cwd !== undefined ? { cwd } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},

View File

@@ -80,7 +80,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
// > 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))
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, 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)