|
|
@@ -0,0 +1,224 @@
|
|
|
+/**
|
|
|
+ * 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')
|
|
|
+ })
|
|
|
+})
|