fix(fs-sandbox): recognize Windows path aliases
This commit is contained in:
74
packages/fs/fs-sandbox/src/containment.ts
Normal file
74
packages/fs/fs-sandbox/src/containment.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user