mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md # .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md # examples/acp-agent/README.md # packages/examples/agent-spine-demo/package.json # packages/fs/fs-sandbox/src/index.ts # packages/fs/tool-fs-search/tests/tools.spec.ts # packages/support/acp-snapshot/README.md # packages/support/acp-snapshot/src/suite.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.json
237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
* 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, parse } 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 on that volume', async () => {
|
|
// A degenerate but valid config: the filesystem root containing the target.
|
|
// It exercises the separator-suffixed-root branch on POSIX and Windows.
|
|
const rootCtx = new Context()
|
|
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root })
|
|
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
|
|
const rootFs = rootCtx.fs as SandboxedFileSystem
|
|
try {
|
|
const path = join(base, 'anywhere.txt') // under HOME, outside temp — allowed only via the filesystem 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 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 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'))
|
|
.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, { mode: 'danger-full-access', workspaceRoot: workspace })
|
|
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')
|
|
})
|
|
})
|