diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 8e44e3a6bc..41246ca3b3 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-cross-family-fs-sandbox.md: 9b6312e5994469606bd1645902fc798f70258580 -2026-07-14-cross-family-fs-sandbox.zh.md: d4816e03d94bdf12b2db875d71dccb7db3a2c0d7 +2026-07-14-cross-family-fs-sandbox.md: 0897695cc14b7573ebb53f3ffa6a460652882b37 +2026-07-14-cross-family-fs-sandbox.zh.md: 15de061a0d2b18392f839c927e9b0f5d0cacf28b diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index 9b6312e599..0897695cc1 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -31,7 +31,7 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching `packages/fs/fs-sandbox/` (`@deepseek-ai/dsh-fs-sandbox`) mirrors the `bash-local`/`bash-sandbox` split: `SandboxedFileSystem extends LocalFileSystem`, registered as `ctx.fs`, injecting `sandboxPolicy`. Reads (`resolve`/`stat`/`readText`/`streamText`/`listDir`) pass through untouched — every mode permits reading. The two mutations enforce by mode before delegating to the inherited atomic write: - `read-only` denies `writeText`/`editText` outright. -- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Containment is prefix-inclusion on real paths; the target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. +- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Canonical spellings take a lexical containment fast path; when Windows exposes one directory through different casing or long-name/8.3 spellings, an ancestor walk compares filesystem identity rather than weakening the boundary to textual prefix guesses. The target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. - `danger-full-access` delegates unfenced. A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. @@ -74,7 +74,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the What shipped — the tiers in § Testing hold each: - Under `read-only`, `write`/`edit` return the `[sandbox: file access denied under read-only mode]` marker and the disk is untouched; `read`/`listDir` behave identically to `dsh-fs-local`. -- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, and a new file created under such a symlink — denies every escape on real disks. +- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks. - A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. - One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. - A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. @@ -90,5 +90,5 @@ Costs and accepted limits: ## Testing -- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, root-ending-in-separator) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, and alias-equivalent spelling) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. - Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index d4816e03d9..15de061a0d 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -31,7 +31,7 @@ Status: implemented `packages/fs/fs-sandbox/`(`@deepseek-ai/dsh-fs-sandbox`)镜像 `bash-local`/`bash-sandbox` 的拆分:`SandboxedFileSystem extends LocalFileSystem`,注册为 `ctx.fs`,注入 `sandboxPolicy`。读取(`resolve`/`stat`/`readText`/`streamText`/`listDir`)原样透传——每种模式都允许读。两个变更操作在委托给继承来的原子写之前按模式执行: - `read-only` 直接拒绝 `writeText`/`editText`。 -- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。包含判定是对真实路径的前缀包含;目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 +- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。 - `danger-full-access` 不加围栏地委托。 拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。 @@ -74,7 +74,7 @@ Status: implemented 已交付的部分——§ Testing 的各层各自钉住: - 在 `read-only` 下,`write`/`edit` 返回 `[sandbox: file access denied under read-only mode]` 标记,磁盘不受触动;`read`/`listDir` 与 `dsh-fs-local` 行为一致。 -- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录,以及在这样一个符号链接下新建的文件——在真实磁盘上拒绝每一种逃逸。 +- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。 - 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。 - 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 @@ -90,5 +90,5 @@ Status: implemented ## Testing -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、以分隔符结尾的根),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index 685ab838c8..c7043e2e70 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -9,14 +9,14 @@ Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../. 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. +- `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. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. 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]` 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). +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]` 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 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md). ## Model Experience diff --git a/packages/fs/fs-sandbox/src/containment.ts b/packages/fs/fs-sandbox/src/containment.ts new file mode 100644 index 0000000000..782ceb151d --- /dev/null +++ b/packages/fs/fs-sandbox/src/containment.ts @@ -0,0 +1,74 @@ +/** + * Path-containment mechanics for the filesystem sandbox. Canonical spellings + * take the fast lexical path; filesystem identity supplies the conservative + * fallback for alias-equivalent roots such as Windows 8.3 names and casing. + * @module @deepseek-ai/dsh-fs-sandbox/containment + */ + +import type { BigIntStats } from 'node:fs' +import { stat } from 'node:fs/promises' +import { dirname, sep } from 'node:path' + +function isMissing(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +function comparablePath(path: string, caseSensitive: boolean): string { + return caseSensitive ? path : path.toLowerCase() +} + +function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): boolean { + const comparableTarget = comparablePath(path, caseSensitive) + const comparableRoot = comparablePath(root, caseSensitive) + if (comparableTarget === comparableRoot) return true + const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep + return comparableTarget.startsWith(prefix) +} + +async function statIfPresent(path: string): Promise { + try { + return await stat(path, { bigint: true }) + } catch (error: unknown) { + /* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */ + if (isMissing(error)) return undefined + /* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */ + throw error + } +} + +function sameIdentity(left: BigIntStats, right: BigIntStats): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +/** + * Determine whether a canonical target is a writable root or lies beneath it. + * The lexical fast path handles normal canonical spellings. When spellings + * differ, walk the target's existing ancestors and compare filesystem identity + * with the root; this recognizes Windows long-name/8.3 aliases and casing + * without weakening containment to a textual approximation. + * @param path - canonical target key, which may end in a missing suffix. + * @param root - canonical writable root. + * @param caseSensitive - whether lexical comparison preserves case; defaults + * to the host filesystem convention used by supported platforms. + * @returns whether the target is the root or a descendant of it. + */ +export async function isPathUnder( + path: string, + root: string, + caseSensitive = process.platform !== 'win32', +): Promise { + if (isLexicallyUnder(path, root, caseSensitive)) return true + + const rootInfo = await statIfPresent(root) + if (!rootInfo) return false + + let ancestor = path + while (true) { + const ancestorInfo = await statIfPresent(ancestor) + if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true + const parent = dirname(ancestor) + if (parent === ancestor) return false + ancestor = parent + } +} diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 314778968e..5268412955 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -30,7 +30,6 @@ * @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' @@ -39,6 +38,7 @@ import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { isPathUnder } from './containment.ts' /** * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve @@ -48,13 +48,6 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' */ 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 @@ -147,7 +140,14 @@ 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))) { + let contained = false + for (const root of this.writableRoots) { + if (await isPathUnder(fresh.targetKey, root)) { + contained = true + break + } + } + if (!contained) { throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED') } return fresh diff --git a/packages/fs/fs-sandbox/tests/containment.spec.ts b/packages/fs/fs-sandbox/tests/containment.spec.ts new file mode 100644 index 0000000000..30821cc826 --- /dev/null +++ b/packages/fs/fs-sandbox/tests/containment.spec.ts @@ -0,0 +1,56 @@ +/** + * Containment tests for lexical canonical paths and filesystem-identity aliases. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse } from 'node:path' +import { isPathUnder } from '../src/containment.ts' + +let base: string + +beforeEach(async () => { + base = await mkdtemp(join(tmpdir(), 'dsh-fssbx-containment-')) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('filesystem sandbox containment', () => { + it('accepts equal paths, descendants, and a filesystem-root boundary', async () => { + expect(await isPathUnder(base, base)).toBe(true) + expect(await isPathUnder(join(base, 'child'), base)).toBe(true) + expect(await isPathUnder(base, parse(base).root)).toBe(true) + }) + + it('uses case-insensitive lexical comparison for Windows-style containment', async () => { + expect(await isPathUnder(join(base.toUpperCase(), 'child'), base.toLowerCase(), false)).toBe(true) + }) + + it('recognizes an alias-equivalent root by filesystem identity for a missing target', async () => { + const realRoot = join(base, 'real') + const aliasRoot = join(base, 'alias') + await mkdir(realRoot) + await symlink(realRoot, aliasRoot) + expect(await isPathUnder(join(await realpath(realRoot), 'missing', 'file.txt'), aliasRoot)).toBe(true) + }) + + it('denies unrelated and missing roots', async () => { + const allowed = join(base, 'allowed') + const outside = join(base, 'outside') + await mkdir(allowed) + await mkdir(outside) + expect(await isPathUnder(join(outside, 'file.txt'), allowed)).toBe(false) + expect(await isPathUnder(join(outside, 'file.txt'), join(base, 'missing-root'))).toBe(false) + }) + + it('treats a regular-file path segment as a missing target, not containment', async () => { + const allowed = join(base, 'allowed') + const blocker = join(base, 'blocker') + await mkdir(allowed) + await writeFile(blocker, 'not a directory') + expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false) + }) +}) diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 12f0abb0df..65472f2ece 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -12,7 +12,7 @@ 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 { 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' @@ -167,16 +167,15 @@ describe('workspace-write containment', () => { }) 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. + 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: '/' }) + 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 /tmp — allowed only via the `/` root + 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 {