Merge branch 'codex/invariant-service-seam' into codex/invariant-package-registration-gate
This commit is contained in:
@@ -6,11 +6,12 @@ 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-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/*`) | (registers on `ctx.tools`) |
|
||||
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
|
||||
| `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-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. 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. 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 results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
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).
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
|
||||
33
packages/fs/fs-sandbox/README.md
Normal file
33
packages/fs/fs-sandbox/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# dsh-fs-sandbox — the sandbox-enforcing filesystem backend
|
||||
|
||||
`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.
|
||||
|
||||
## The fence
|
||||
|
||||
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
|
||||
|
||||
- `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. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `danger-full-access` — delegates unfenced.
|
||||
|
||||
## Threat model: a policy fence, not a kernel boundary
|
||||
|
||||
The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here.
|
||||
|
||||
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-fs`, which renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
45
packages/fs/fs-sandbox/package.json
Normal file
45
packages/fs/fs-sandbox/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-sandbox",
|
||||
"description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
157
packages/fs/fs-sandbox/src/index.ts
Normal file
157
packages/fs/fs-sandbox/src/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the
|
||||
* `@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
|
||||
* on the two mutations. Reads pass through untouched: every mode permits
|
||||
* reading.
|
||||
*
|
||||
* The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,
|
||||
* NOT a kernel boundary — the operations are the seam's own (open, rename),
|
||||
* and only the target path is untrusted, so canonicalize-then-contain is the
|
||||
* complete answer to this surface. Kernel-grade isolation of untrusted CODE
|
||||
* stays `ctx.bash`'s job (`@deepseek-ai/dsh-bash-sandbox`). This mirrors the
|
||||
* `code-runtime` stance: containment, not a security boundary. The residual
|
||||
* TOCTOU (an ancestor symlink swapped between the containment re-check and the
|
||||
* 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,
|
||||
* 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
|
||||
* stderr), because an in-process fence knows exactly what it refused. The
|
||||
* escalation retry lives in the tool layer (`@deepseek-ai/dsh-tool-fs`),
|
||||
* exactly as bash's does.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-sandbox
|
||||
*/
|
||||
|
||||
import { sep } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
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 {} 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.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/** Whether `path` is `root` itself or lies beneath it (both already canonical). */
|
||||
function isUnder(path: string, root: string): boolean {
|
||||
if (path === root) return true
|
||||
const prefix = root.endsWith(sep) ? root : root + sep
|
||||
return path.startsWith(prefix)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.defaultMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence the write by the per-call mode, 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.
|
||||
* @returns the write outcome from the inherited backend.
|
||||
*/
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsWriteOutcome> {
|
||||
return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence the edit by the per-call mode, 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.
|
||||
* @returns the edit outcome from the inherited backend.
|
||||
*/
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsEditOutcome> {
|
||||
return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the per-call mode 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,
|
||||
* reflecting a concurrently swapped symlink), requires containment under a
|
||||
* writable root, and returns THAT fresh target; `danger-full-access` returns
|
||||
* the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
|
||||
* 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
|
||||
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')
|
||||
}
|
||||
// workspace-write: containment on the FRESH canonical path (catches a
|
||||
// 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))) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxedFileSystem
|
||||
27
packages/fs/fs-sandbox/src/invariant.ts
Normal file
27
packages/fs/fs-sandbox/src/invariant.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-fs-sandbox`.
|
||||
* @module @deepseek-ai/dsh-fs-sandbox/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'fs-sandbox-invariant'
|
||||
/** Services required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this stateless adapter delegates policy and filesystem relations to their owning seams. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
237
packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
Normal file
237
packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Tests for the sandbox-enforcing filesystem backend: the per-call mode 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
|
||||
* escapes (a symlinked directory inside the workspace pointing out, and a new
|
||||
* file created under one). The fence is exercised on a real filesystem: a
|
||||
* denied write leaves no file on disk.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
let base: string
|
||||
let workspace: string
|
||||
let outside: string
|
||||
let ctx: Context
|
||||
let fs: SandboxedFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
async function boot(mode: SandboxMode): Promise<void> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
fs = ctx.fs as SandboxedFileSystem
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Base under HOME, deliberately NOT tmpdir: `workspace-write` grants /tmp and
|
||||
// os.tmpdir() (parity with the bash runner), so an "outside" dir under tmpdir
|
||||
// would be legitimately writable. Sibling dirs under HOME are outside every
|
||||
// grant, so containment failures are real denials. (The bwrap e2e roots its
|
||||
// workspaces under HOME for the same reason.)
|
||||
base = await mkdtemp(join(homedir(), '.dsh-fssbx-'))
|
||||
workspace = join(base, 'ws')
|
||||
outside = join(base, 'out')
|
||||
await mkdir(workspace)
|
||||
await mkdir(outside)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber?.dispose()
|
||||
await rm(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Resolve a path through the backend and return its target. */
|
||||
function target(path: string): Promise<FsTarget> {
|
||||
return fs.resolve(path)
|
||||
}
|
||||
|
||||
describe('the capability fact', () => {
|
||||
it('reports the deployment default mode (what the tool layer advertises against)', async () => {
|
||||
await boot('workspace-write')
|
||||
expect(fs.sandboxMode).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only', () => {
|
||||
beforeEach(() => boot('read-only'))
|
||||
|
||||
it('denies write, leaving no file on disk', async () => {
|
||||
const path = join(workspace, 'denied.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(path)).toBe(false)
|
||||
})
|
||||
|
||||
it('denies edit of an existing file (the content is unchanged)', async () => {
|
||||
const path = join(workspace, 'file.txt')
|
||||
await writeFile(path, 'original')
|
||||
await expect(fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(await readFile(path, 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('allows reads (every mode permits reading)', async () => {
|
||||
const path = join(workspace, 'readable.txt')
|
||||
await writeFile(path, 'hello')
|
||||
expect(await fs.readText(await target(path))).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-write containment', () => {
|
||||
beforeEach(() => boot('workspace-write'))
|
||||
|
||||
it('a write under the workspace lands', async () => {
|
||||
const path = join(workspace, 'nested', 'ok.txt')
|
||||
const outcome = await fs.writeText(await target(path), 'inside')
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(path, 'utf8')).toBe('inside')
|
||||
})
|
||||
|
||||
it('a write to the platform temp area lands (parity with the bash runner grant)', async () => {
|
||||
const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt')
|
||||
await fs.writeText(await target(path), 'temp')
|
||||
expect(await readFile(path, 'utf8')).toBe('temp')
|
||||
})
|
||||
|
||||
it('an absolute path outside the workspace is denied, no file created', async () => {
|
||||
const path = join(outside, 'escape.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(path)).toBe(false)
|
||||
})
|
||||
|
||||
it('a `..` traversal out of the workspace is denied', async () => {
|
||||
const path = join(workspace, '..', 'sibling-escape.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(join(workspace, '..', 'sibling-escape.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('a symlinked directory inside the workspace pointing OUT is denied (canonicalized before containment)', async () => {
|
||||
// workspace/link -> outside ; writing workspace/link/f.txt would land in outside/f.txt.
|
||||
await symlink(outside, join(workspace, 'link'))
|
||||
const path = join(workspace, 'link', 'f.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(join(outside, 'f.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('a NEW file created under a symlinked-out directory is denied (deepest-ancestor realpath)', async () => {
|
||||
await symlink(outside, join(workspace, 'link'))
|
||||
const path = join(workspace, 'link', 'newdir', 'deep.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(join(outside, 'newdir'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an edit outside the workspace is denied; the original is untouched', async () => {
|
||||
const path = join(outside, 'file.txt')
|
||||
await writeFile(path, 'original')
|
||||
await expect(fs.editText(await target(path), { oldString: 'original', newString: 'x', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(await readFile(path, 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('an edit inside the workspace lands', async () => {
|
||||
const path = join(workspace, 'edit.txt')
|
||||
await writeFile(path, 'original')
|
||||
const outcome = await fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false })
|
||||
expect(outcome.after).toBe('changed')
|
||||
expect(await readFile(path, 'utf8')).toBe('changed')
|
||||
})
|
||||
|
||||
it('mutates the freshly checked identity, not a stale outside targetKey (TOCTOU direction)', async () => {
|
||||
// A target whose displayPath is inside the workspace but whose targetKey is
|
||||
// a STALE outside path — as if an ancestor symlink pointed out at the tool's
|
||||
// resolve() and was swapped in before the write. The fence re-resolves
|
||||
// displayPath (now inside) AND delegates with that fresh target, so the byte
|
||||
// lands inside and the stale outside path is never written.
|
||||
const insidePath = join(workspace, 'landed.txt')
|
||||
const staleTarget: FsTarget = { displayPath: insidePath, targetKey: FsTargetKey(join(outside, 'escaped.txt')) }
|
||||
await fs.writeText(staleTarget, 'inside')
|
||||
expect(await readFile(insidePath, 'utf8')).toBe('inside')
|
||||
expect(existsSync(join(outside, 'escaped.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('the workspace root itself passes the fence (path equal to a writable root), failing only on file type', async () => {
|
||||
// isUnder's path-equals-root branch: the fence allows the root, and the
|
||||
// write then fails because the root is a directory, not a regular file.
|
||||
await expect(fs.writeText(await target(workspace), 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => {
|
||||
it('grants writes anywhere: containment against `/` allows any absolute path', async () => {
|
||||
// A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's
|
||||
// separator-suffixed-root branch: `/` already ends in the separator, so the
|
||||
// prefix stays `/` and every absolute path is contained.
|
||||
const rootCtx = new Context()
|
||||
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' })
|
||||
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
const rootFs = rootCtx.fs as SandboxedFileSystem
|
||||
try {
|
||||
const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root
|
||||
await rootFs.writeText(await rootFs.resolve(path), 'anywhere')
|
||||
expect(await readFile(path, 'utf8')).toBe('anywhere')
|
||||
} finally {
|
||||
await rootFiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
beforeEach(() => boot('danger-full-access'))
|
||||
|
||||
it('writes anywhere, unfenced', async () => {
|
||||
const path = join(outside, 'free.txt')
|
||||
await fs.writeText(await target(path), 'free')
|
||||
expect(await readFile(path, 'utf8')).toBe('free')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the per-call mode 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')
|
||||
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'))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
})
|
||||
|
||||
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')
|
||||
expect(await readFile(path, 'utf8')).toBe('full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration and HMR safety', () => {
|
||||
it('registers as ctx.fs and unregisters cleanly from a child fiber', async () => {
|
||||
await boot('workspace-write')
|
||||
expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem)
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('fs')).toBeUndefined()
|
||||
// Re-mount below the disposed one to prove no lingering registration.
|
||||
fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FsError identity', () => {
|
||||
it('the denial is a structured FsError distinct from a host permission error', async () => {
|
||||
await boot('read-only')
|
||||
const error = await fs.writeText(await target(join(workspace, 'x.txt')), 'x').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(FsError)
|
||||
expect((error as FsError).code).toBe('FS_SANDBOX_DENIED')
|
||||
})
|
||||
})
|
||||
33
packages/fs/fs-sandbox/tsconfig.json
Normal file
33
packages/fs/fs-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../fs"
|
||||
},
|
||||
{
|
||||
"path": "../fs-local"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,12 +30,14 @@
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
@@ -82,6 +83,23 @@ export abstract class FileSystem extends Service {
|
||||
super(ctx, 'fs')
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* The sandbox mode this backend enforces on mutations BY DEFAULT, or
|
||||
* `undefined` when it does not confine at all — the capability fact the tool
|
||||
* layer reads to advertise the escalation fields honestly (mirrors
|
||||
* `BashExecutor.sandboxMode`). The base class and the bare local backend
|
||||
* report `undefined`; a sandboxing backend (`@deepseek-ai/dsh-fs-sandbox`)
|
||||
* overrides it with the deployment default. A session override may make the
|
||||
* effective mode narrower or wider, so strict escalation widening is checked
|
||||
* per call rather than encoded in this default-relative fact.
|
||||
* @returns the configured default mode of a sandboxing backend; `undefined`
|
||||
* for a backend that never confines.
|
||||
*/
|
||||
get sandboxMode(): SandboxMode | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a
|
||||
* remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence
|
||||
@@ -152,9 +170,18 @@ 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.
|
||||
* @returns the outcome, including the version the write produced.
|
||||
*/
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
abstract writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsWriteOutcome>
|
||||
|
||||
/**
|
||||
* Atomically edit literal text. When supplied, the version guard is checked
|
||||
@@ -164,9 +191,18 @@ 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.
|
||||
* @returns the outcome, including the version the edit produced.
|
||||
*/
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
abstract editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsEditOutcome>
|
||||
}
|
||||
|
||||
export default FileSystem
|
||||
|
||||
@@ -168,6 +168,7 @@ export type FsErrorCode =
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_SANDBOX_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# @deepseek-ai/dsh-tool-fs-search
|
||||
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor, then the discovery tools.
|
||||
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
|
||||
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
|
||||
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
|
||||
|
||||
## Deployment requirement: co-located bash + filesystem
|
||||
## Deployment requirement: rg + co-located bash/filesystem
|
||||
|
||||
Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
|
||||
The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -43,7 +43,7 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax
|
||||
|
||||
## Errors
|
||||
|
||||
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
|
||||
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -51,7 +51,7 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
|
||||
##### Glob guidance
|
||||
|
||||
@@ -67,7 +67,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed guidance cost per request while the plugin is active.
|
||||
Fixed guidance cost per request while the tools are registered.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -77,7 +77,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Activation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
|
||||
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -118,5 +118,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
|
||||
- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools
|
||||
* only when the mounted bash executor can find `rg` on its `PATH`.
|
||||
*
|
||||
* ## Bash-backed, not a `ctx.fs` provider method
|
||||
*
|
||||
@@ -12,9 +13,11 @@
|
||||
* parsing, retention, formatted-result spill, and timeout declaration; the
|
||||
* bash executor owns request defaulting/capping, subprocess execution,
|
||||
* process-group termination, environment scrubbing, raw output capture, and
|
||||
* backend substitution. The package injects `tools`, `systemPrompt`, and
|
||||
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
|
||||
* with `ctx.get()` because formatted-result spill is optional.
|
||||
* backend substitution. At load, the package probes `command -v rg` through the
|
||||
* same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt
|
||||
* sections are not registered. The package injects `tools`, `systemPrompt`,
|
||||
* and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read
|
||||
* opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
*
|
||||
* Returned paths are displayed relative to the resolved bash workdir and are
|
||||
* follow-up-readable only in co-located deployments where the bash workdir and
|
||||
@@ -80,6 +83,9 @@ export const Config: z<Config> = z.object({
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */
|
||||
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
@@ -87,8 +93,38 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the `glob`/`grep` filesystem discovery tool suite. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
/**
|
||||
* Check whether the mounted bash executor can find `rg`.
|
||||
*
|
||||
* Nonzero exit means "not available" and disables this optional tool suite.
|
||||
* Infrastructure failures stay loud: a deployment with a broken bash executor
|
||||
* should not silently lose tools in a way that looks like a deliberate skip.
|
||||
*
|
||||
* @param ctx - plugin context whose `bash` service is the executor the tools will use.
|
||||
* @returns true when `command -v rg` exits 0, false when it exits nonzero.
|
||||
*/
|
||||
async function ripgrepAvailable(ctx: Context): Promise<boolean> {
|
||||
const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND })
|
||||
let result
|
||||
try {
|
||||
result = await ctx.bash.run(spec)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error })
|
||||
}
|
||||
if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) {
|
||||
throw new Error('tool-fs-search: ripgrep availability probe did not complete')
|
||||
}
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists.
|
||||
*
|
||||
* @param ctx - plugin context; registrations are effects scoped to this plugin.
|
||||
* @param config - resolved plugin configuration from schemastery.
|
||||
* @returns when ripgrep is unavailable, resolves without registering any tools.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
|
||||
@@ -96,6 +132,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
if (!await ripgrepAvailable(ctx)) {
|
||||
ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered')
|
||||
return
|
||||
}
|
||||
applyGlobTool(ctx, {
|
||||
maxResults: resolved.globMaxResults,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
|
||||
@@ -18,9 +18,48 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/**
|
||||
* Deterministic bash service for this Loader guard: the test wants to exercise
|
||||
* the real unwrap/inject path, not depend on whether the host image has rg.
|
||||
*/
|
||||
class ProbeSuccessBashExecutor extends BashExecutor {
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command !== RG_PROBE_COMMAND) {
|
||||
throw new Error(`unexpected command in load-path guard: ${spec.command}`)
|
||||
}
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
throw new Error('load-path guard must not start background processes')
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in toolFsSearch).toBe(false)
|
||||
@@ -38,7 +77,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ProbeSuccessBashExecutor)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Consumer-surface tests for the search tools over a FAKE bash executor and a
|
||||
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
|
||||
* bypasses the tool registry. The fake executor makes every seam outcome
|
||||
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
|
||||
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
|
||||
* validation, shell-safe command construction, workdir derivation, signal
|
||||
* forwarding, `SEARCH_*` error classification, retention, formatted-result
|
||||
* spill handoff, and the no-background-task invariant. Real-`rg` behavior is
|
||||
* pinned separately in integration.spec.ts.
|
||||
* scriptable — registration-time `rg` probing, truncated stdout with/without a
|
||||
* raw spill path, abort/timeout, signal kills, ripgrep exit codes — so these
|
||||
* tests verify schemas, argument validation, shell-safe command construction,
|
||||
* workdir derivation, signal forwarding, `SEARCH_*` error classification,
|
||||
* retention, formatted-result spill handoff, and the no-background-task
|
||||
* invariant. Real-`rg` behavior is pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
toWorkdirRelative,
|
||||
} from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
@@ -52,13 +54,18 @@ function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunR
|
||||
* create a background task.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
probeRequests: BashExecRequest[] = []
|
||||
probeSpecs: BashExecSpec[] = []
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
probeResult: BashRunResult = runResult('')
|
||||
probeError?: Error
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request)
|
||||
else this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
@@ -68,9 +75,14 @@ class FakeBash extends BashExecutor {
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command === RG_PROBE_COMMAND) {
|
||||
this.probeSpecs.push(spec)
|
||||
if (this.probeError) throw this.probeError
|
||||
return this.probeResult
|
||||
}
|
||||
this.specs.push(spec)
|
||||
return Promise.resolve(this.handler(spec))
|
||||
return this.handler(spec)
|
||||
}
|
||||
override start(): BashProcess {
|
||||
this.startCalls++
|
||||
@@ -97,18 +109,36 @@ class FakeSpill extends SpillStore {
|
||||
interface SetupOptions {
|
||||
config?: ToolFsSearch.Config
|
||||
spill?: boolean
|
||||
probeError?: Error
|
||||
probeResult?: BashRunResult
|
||||
}
|
||||
|
||||
async function setup(options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
const bash = ctx.bash as FakeBash
|
||||
if (options.probeResult) bash.probeResult = options.probeResult
|
||||
if (options.probeError) bash.probeError = options.probeError
|
||||
if (options.spill === true) await ctx.plugin(FakeSpill)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, options.config)
|
||||
const bash = ctx.bash as FakeBash
|
||||
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
|
||||
return { ctx, bash, spill, fiber }
|
||||
return { ctx, bash, spill, fiber, warnings }
|
||||
}
|
||||
|
||||
/** Assert plugin setup rejects without letting Vitest pretty-print a live Context on failure. */
|
||||
async function expectSetupRejects(options: SetupOptions, message: RegExp): Promise<void> {
|
||||
let thrown: string | undefined
|
||||
try {
|
||||
const loaded = await setup(options)
|
||||
await loaded.fiber.dispose()
|
||||
} catch (error: unknown) {
|
||||
thrown = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
expect(thrown).toMatch(message)
|
||||
}
|
||||
|
||||
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
|
||||
@@ -136,13 +166,37 @@ function matchLine(path: string, lineNumber: number, lineText: string): string {
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers glob and grep with their prompt sections', async () => {
|
||||
const { ctx } = await setup()
|
||||
const { ctx, bash } = await setup()
|
||||
expect(bash.probeRequests).toHaveLength(1)
|
||||
expect(bash.probeRequests[0]?.command).toBe(RG_PROBE_COMMAND)
|
||||
expect(bash.probeRequests[0]).not.toHaveProperty('workdir')
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep'])
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the glob tool')
|
||||
expect(prompt).toContain('Use the grep tool')
|
||||
})
|
||||
|
||||
it('does not register glob or grep when the bash executor cannot find rg', async () => {
|
||||
const { ctx, warnings } = await setup({ probeResult: runResult('', { exitCode: 1 }) })
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name)
|
||||
expect(sections).not.toContain('tool:glob')
|
||||
expect(sections).not.toContain('tool:grep')
|
||||
expect(warnings).toEqual([
|
||||
'tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects plugin load when the rg availability probe cannot run', async () => {
|
||||
await expectSetupRejects({ probeError: new Error('spawn bash ENOENT') }, /spawn bash ENOENT/)
|
||||
})
|
||||
|
||||
it('rejects plugin load when the rg availability probe is aborted or killed', async () => {
|
||||
await expectSetupRejects({
|
||||
probeResult: runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }),
|
||||
}, /tool-fs-search: ripgrep availability probe did not complete/)
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -34,9 +34,12 @@
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,9 +52,12 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,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 { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
@@ -22,6 +23,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`
|
||||
@@ -56,8 +71,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,
|
||||
@@ -72,20 +88,31 @@ 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 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).
|
||||
// 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)
|
||||
// An edit necessarily changes content, so result metadata carries at least one applied hunk.
|
||||
|
||||
@@ -7,10 +7,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'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs'
|
||||
@@ -61,6 +63,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)
|
||||
}
|
||||
|
||||
135
packages/fs/tool-fs/src/sandbox.ts
Normal file
135
packages/fs/tool-fs/src/sandbox.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 a
|
||||
* `FsError` whose text is the shared `[sandbox: …]` denial marker plus the
|
||||
* same-turn escalation hint, so a policy denial reads identically to bash's
|
||||
* WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRegistry`
|
||||
* populates `result.error` only for `HarnessError` instances, so a plain
|
||||
* `Error` would strip the code retry/observers key off. 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 `FsError` 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 FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,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 { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: only a non-blank
|
||||
@@ -41,11 +42,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,
|
||||
@@ -58,14 +72,26 @@ 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 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)
|
||||
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)
|
||||
// Overwrites carry applied hunks. Creates have no prior text, so result presentation uses
|
||||
|
||||
@@ -24,6 +24,8 @@ 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 ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
@@ -580,3 +582,163 @@ describe('read caps are plugin config', () => {
|
||||
expect('default' in ToolFs).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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. */
|
||||
class SandboxingFakeFs extends FakeFs {
|
||||
stamped: (SandboxMode | undefined)[] = []
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'workspace-write'
|
||||
}
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
_signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsWriteOutcome> {
|
||||
this.stamped.push(sandboxMode)
|
||||
return super.writeText(target, content, expected)
|
||||
}
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
_signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsEditOutcome> {
|
||||
this.stamped.push(sandboxMode)
|
||||
return super.editText(target, edit, expected)
|
||||
}
|
||||
}
|
||||
|
||||
async function setupConfining(opts: { approval?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SandboxingFakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolFs)
|
||||
return { ctx, fs: ctx.fs as SandboxingFakeFs }
|
||||
}
|
||||
|
||||
/** A fake agent whose session records appends (the approval audit surface), mid-turn, carrying the given events for the fold. */
|
||||
function escalationAgent(events: Array<{ type: string; data?: Record<string, unknown> }> = []): object {
|
||||
return {
|
||||
id: 'agent-fs-esc',
|
||||
session: {
|
||||
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
|
||||
events: [{ type: 'turn/start' }, ...events],
|
||||
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fsSchema(ctx: Context, name: 'write' | 'edit') {
|
||||
const schema = ctx.tools.schemas().find(s => s.name === name)
|
||||
if (!schema) throw new Error(`${name} tool not registered`)
|
||||
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
}
|
||||
|
||||
it('advertises no escalation fields under a non-confining backend', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.fs.sandboxMode).toBeUndefined()
|
||||
for (const name of ['write', 'edit'] as const) {
|
||||
const props = fsSchema(ctx, name).parameters.properties
|
||||
expect(props['sandbox_permissions']).toBeUndefined()
|
||||
expect(props['justification']).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('advertises the closed target vocabulary on write and edit under a confining backend', async () => {
|
||||
const { ctx } = await setupConfining()
|
||||
for (const name of ['write', 'edit'] as const) {
|
||||
const props = fsSchema(ctx, name).parameters.properties
|
||||
expect(props['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(props['justification']).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(fs.stamped).toEqual([undefined])
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
fs.rejectWith = new FsError('denied', 'FS_SANDBOX_DENIED')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(text(result)).toContain('retry this exact operation once with sandbox_permissions')
|
||||
})
|
||||
|
||||
it('a non-FS_SANDBOX_DENIED provider error passes through unchanged', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
fs.rejectWith = new FsError('boom', 'FS_IO_ERROR')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('boom')
|
||||
expect(text(result)).not.toContain('[sandbox:')
|
||||
})
|
||||
|
||||
it('an approved escalation stamps the granted mode onto that write', async () => {
|
||||
const { ctx, fs } = await setupConfining({ approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve('allowed-once' as const))
|
||||
// Pass a signal so the escalation ask forwards it to the approval request
|
||||
// (the request rides the tool-execution abort signal).
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('call-fs-esc-grant'),
|
||||
name: 'write',
|
||||
arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' },
|
||||
agent: escalationAgent() as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(fs.stamped).toEqual(['danger-full-access'])
|
||||
})
|
||||
|
||||
it('a rejected escalation fails closed with its own text and never mutates', async () => {
|
||||
const { ctx, fs } = await setupConfining({ approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve('rejected' as const))
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('the user rejected escalating this operation to "danger-full-access"')
|
||||
expect(fs.stamped).toEqual([])
|
||||
})
|
||||
|
||||
it('escalation without an approval service fails closed', async () => {
|
||||
const { ctx } = await setupConfining()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no approval service is composed')
|
||||
})
|
||||
|
||||
it('escalation with an approval service but no agent fails closed', async () => {
|
||||
const { ctx } = await setupConfining({ approval: true })
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no agent to route it through')
|
||||
})
|
||||
|
||||
it('rejects the escalation argument pairing (one field without the other)', async () => {
|
||||
const { ctx } = await setupConfining()
|
||||
const missing = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write' }, escalationAgent())
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(text(missing)).toContain('sandbox_permissions requires a justification')
|
||||
})
|
||||
|
||||
it('sandbox_permissions under a non-confining backend fails closed (unadvertised field still reaches execute)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not available in this composition')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user