Forráskód Böngészése

fix(fs-sandbox): recognize Windows path aliases

Tianyi Cui 3 hónapja
szülő
commit
37f2c15e68

+ 2 - 2
.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

+ 3 - 3
.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.

+ 3 - 3
.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` 事件名,一次性重录。

+ 2 - 2
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> 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> 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
 

+ 74 - 0
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<BigIntStats | undefined> {
+  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<boolean> {
+  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
+  }
+}

+ 9 - 9
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

+ 56 - 0
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)
+  })
+})

+ 6 - 7
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 {