fix(fs-sandbox): recognize Windows path aliases

This commit is contained in:
Tianyi Cui
2026-07-20 21:03:01 +08:00
parent 6edd2ee0f3
commit 37f2c15e68
8 changed files with 155 additions and 26 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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 {