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