Merge branch 'codex/invariant-service-seam' into codex/invariant-package-registration-gate
This commit is contained in:
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user