Extend SandboxMode enforcement from bash to the filesystem tools, the sandbox RFC's deferred cross-family phase. - dsh-sandbox-policy (new, ctx.sandboxPolicy): the single home for the deployment default mode + workspaceRoot and the per-session override event, renamed bash/sandbox-mode -> sandbox/mode and moved here with its fold/setter. Decouples the bash seam from dsh-session. - dsh-fs-sandbox (new): SandboxedFileSystem extends LocalFileSystem and fences write/edit by the per-call mode (read-only denies, workspace-write contains to the workspace + temp roots via the shared writableRoots, danger passes through); reads pass through. Structured FS_SANDBOX_DENIED; in-lock parent re-canonicalization. A policy fence in trusted code, not a kernel boundary. - dsh-sandbox: the shared escalation kit (writableRoots, the strictly-wider ladder, denial/hint markers, approveEscalation) both tool families use; approveEscalation takes a structural approver so dsh-sandbox gains no approval/agent dependency, and both tools stay duplication-free. - tool-fs: write/edit advertise sandbox_permissions/justification under a confining ctx.fs, map FS_SANDBOX_DENIED to the shared [sandbox: ...] marker, and resolve the same one-approved-wider retry. - examples/acp-agent: composes sandbox-policy + fs-sandbox, drops the gating that disabled the fs stack under confined modes. RFC docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md; the old sandbox RFC's In-process/deferred/FAQ sections updated to shipped fact.
225 lines
9.9 KiB
TypeScript
225 lines
9.9 KiB
TypeScript
/**
|
|
* 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 } 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('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')
|
|
})
|
|
})
|