Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

This commit is contained in:
Tianyi Cui
2026-07-22 17:05:38 +08:00
482 changed files with 37413 additions and 1133 deletions

View File

@@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.

View File

@@ -32,6 +32,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -12,6 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
const BINARY_SAMPLE_BYTES = 8192
@@ -74,10 +75,16 @@ function versionOf(info: BigIntStats): FsVersion {
* file before it is renamed over the target.
*/
export interface FsIoInternals {
/** Override the host platform for native-publication unit coverage. */
platform?: NodeJS.Platform
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Override the Win32 DACL copy boundary. */
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
@@ -133,6 +140,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
// A path component is a file, not a directory (e.g. "afile/child.txt" where
// "afile" is a regular file): the target can neither exist nor be created,
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
/* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
if (!isENOENT(error)) throw error
@@ -145,8 +153,22 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
while (true) {
try {
const realAncestor = await realpath(ancestor)
// On Windows, realpath of a regular file succeeds where POSIX returns
// ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).
// Stat the ancestor to restore the semantic distinction: a non-directory
// ancestor means the target passes through a file and can never be created.
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
if (process.platform === 'win32') {
const parentInfo = await stat(realAncestor)
if (!parentInfo.isDirectory()) {
throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
}
}
/* v8 ignore stop */
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
} catch (error: unknown) {
/* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
if (error instanceof FsError) throw error
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
if (!isENOENT(error)) throw error
const parent = dirname(ancestor)
@@ -160,7 +182,9 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
if (info.isFile()) return 'file'
/* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
if (info.isDirectory()) return 'directory'
/* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
return 'other'
}
@@ -224,6 +248,7 @@ function listingIoError(displayPath: string, error: unknown): FsError {
if (error instanceof FsError) return error
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
/* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
@@ -394,9 +419,13 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
/**
* Atomically replace a file through a private, synced staging file in the same directory.
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
* onto the empty temp before writing and preserves the target descriptor at publication.
* @param absolutePath - destination; missing parent directories are created.
* @param content - the full UTF-8 text to write.
* @param mode - final mode, or `0o600` when omitted.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
*/
@@ -416,6 +445,9 @@ export async function writeFileAtomic(
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
const platform = internals.platform ?? process.platform
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
const replaceFile = internals.replaceFile ?? replaceFileWin32
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
@@ -425,6 +457,9 @@ export async function writeFileAtomic(
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
if (platform === 'win32' && mode !== undefined) {
await copyFileDacl(absolutePath, tempPath)
}
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
@@ -433,7 +468,18 @@ export async function writeFileAtomic(
handle = undefined
throwIfAborted(signal, 'write')
await rename(tempPath, absolutePath)
if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during
// staging: the temp already carries that target's protected DACL, so rename recreates it.
if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath)
}
} else {
await rename(tempPath, absolutePath)
}
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */

View File

@@ -0,0 +1,134 @@
/**
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
* non-Windows processes never open Win32 libraries.
* @module @deepseek-ai/dsh-fs-local/win32
*/
import { toNamespacedPath } from 'node:path'
type GetFileSecurityW = (
path: string,
requestedInformation: number,
descriptor: Buffer | null,
length: number,
needed: [number],
) => number
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
type ReplaceFileW = (
replaced: string,
replacement: string,
backup: null,
flags: number,
exclude: null,
reserved: null,
) => number
type GetLastError = () => number
interface Win32Bindings {
getFileSecurityW: GetFileSecurityW
setFileSecurityW: SetFileSecurityW
replaceFileW: ReplaceFileW
getLastError: GetLastError
}
interface Win32ErrnoException extends NodeJS.ErrnoException {
win32Code: number
}
const DACL_SECURITY_INFORMATION = 0x00000004
const PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
let bindings: Win32Bindings | undefined
async function win32(): Promise<Win32Bindings> {
if (bindings !== undefined) return bindings
const koffi = (await import('koffi')).default
const advapi32 = koffi.load('advapi32.dll')
const kernel32 = koffi.load('kernel32.dll')
bindings = {
getFileSecurityW: advapi32.func('int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)') as GetFileSecurityW,
setFileSecurityW: advapi32.func('int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)') as SetFileSecurityW,
replaceFileW: kernel32.func('int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)') as ReplaceFileW,
getLastError: kernel32.func('uint32_t __stdcall GetLastError()') as GetLastError,
}
return bindings
}
function errnoCode(win32Code: number): string {
switch (win32Code) {
case ERROR_FILE_NOT_FOUND:
case ERROR_PATH_NOT_FOUND:
return 'ENOENT'
case ERROR_ACCESS_DENIED:
return 'EACCES'
default:
return 'EIO'
}
}
function win32Error(syscall: string, win32Code: number, path: string): Win32ErrnoException {
const code = errnoCode(win32Code)
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`) as Win32ErrnoException
error.code = code
error.errno = win32Code
error.syscall = syscall
error.path = path
error.win32Code = win32Code
return error
}
/**
* Read a file's self-relative DACL security descriptor.
* @param path - existing file whose DACL is read.
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
*/
export async function readFileDaclWin32(path: string): Promise<Buffer> {
const api = await win32()
const nativePath = toNamespacedPath(path)
const needed: [number] = [0]
api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed)
if (needed[0] === 0) throw win32Error('GetFileSecurityW', api.getLastError(), path)
const descriptor = Buffer.alloc(needed[0])
if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) {
throw win32Error('GetFileSecurityW', api.getLastError(), path)
}
return descriptor.subarray(0, needed[0])
}
/**
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
* The destination must still be empty when confidentiality depends on this call.
* @param source - existing file whose DACL is copied.
* @param destination - existing file that receives the protected DACL.
*/
export async function copyFileDaclWin32(source: string, destination: string): Promise<void> {
const descriptor = await readFileDaclWin32(source)
const api = await win32()
const information = (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) >>> 0
if (api.setFileSecurityW(toNamespacedPath(destination), information, descriptor) === 0) {
throw win32Error('SetFileSecurityW', api.getLastError(), destination)
}
}
/**
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
* @param replaced - existing destination file.
* @param replacement - closed staging file on the same volume.
*/
export async function replaceFileWin32(replaced: string, replacement: string): Promise<void> {
const api = await win32()
if (api.replaceFileW(
toNamespacedPath(replaced),
toNamespacedPath(replacement),
null,
0,
null,
null,
) === 0) {
throw win32Error('ReplaceFileW', api.getLastError(), replaced)
}
}

View File

@@ -6,7 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
@@ -23,6 +23,7 @@ import {
writeFileAtomic,
} from '../src/fsio.ts'
import type { LocalTarget } from '../src/fsio.ts'
import { copyFileDaclWin32, readFileDaclWin32 } from '../src/win32.ts'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
let dir: string
@@ -367,24 +368,135 @@ describe('streamWholeText', () => {
})
})
// Windows drives only the read-only attribute through `chmod` and reports synthetic `stat` mode
// bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately.
const posixModes = process.platform !== 'win32'
function daclAcePolicy(descriptor: Buffer): string[] {
const daclOffset = descriptor.readUInt32LE(16)
if (daclOffset === 0) return []
const aceCount = descriptor.readUInt16LE(daclOffset + 4)
const policy: string[] = []
const seen = new Set<string>()
let offset = daclOffset + 8
for (let index = 0; index < aceCount; index++) {
const size = descriptor.readUInt16LE(offset + 2)
const ace = Buffer.from(descriptor.subarray(offset, offset + size))
// INHERITED_ACE records provenance, not the entry's access policy.
ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
const key = ace.toString('hex')
if (!seen.has(key)) {
seen.add(key)
policy.push(key)
}
offset += size
}
return policy
}
describe('writeFileAtomic — temp-file safety', () => {
it('writes through a private staging dir and owner-only temp file', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
if (posixModes) await chmod(file, 0o640)
let inspected = false
await writeFileAtomic(file, 'hello', 0o640, undefined, {
inspectTemp: async ({ stagingDir, tempPath }) => {
inspected = true
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)])
expect(staging.isDirectory()).toBe(true)
expect(temp.isFile()).toBe(true)
if (posixModes) {
expect(staging.mode & 0o777).toBe(0o700)
expect(temp.mode & 0o777).toBe(0o600)
}
},
})
expect(inspected).toBe(true)
expect(await readFile(file, 'utf8')).toBe('hello')
expect((await stat(file)).mode & 0o777).toBe(0o640)
if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640)
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
})
it('creates new files owner-only by default', async () => {
it.skipIf(process.platform !== 'win32')('protects staged content with the existing target DACL and preserves it after replacement', async () => {
const file = join(dir, 'protected.txt')
await writeFile(file, 'old')
await copyFileDaclWin32(file, file)
const expectedDacl = await readFileDaclWin32(file)
await writeFileAtomic(file, 'new', (await stat(file)).mode, undefined, {
inspectTemp: async ({ tempPath }) => {
expect(await readFileDaclWin32(tempPath)).toEqual(expectedDacl)
},
})
expect(await readFile(file, 'utf8')).toBe('new')
expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl))
})
it('copies a Windows target DACL before content and publishes through secure replacement', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const calls: string[] = []
await writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: async (source, temp) => {
calls.push(`copy:${source}`)
expect(await readFile(temp, 'utf8')).toBe('')
},
replaceFile: async (target, temp) => {
calls.push(`replace:${target}`)
await rename(temp, target)
},
})
expect(calls).toEqual([`copy:${file}`, `replace:${file}`])
expect(await readFile(file, 'utf8')).toBe('new')
})
it('creates a new Windows file through directory inheritance without replacement calls', async () => {
const file = join(dir, 'new.txt')
const unexpected = async (): Promise<void> => { throw new Error('unexpected native replacement call') }
await writeFileAtomic(file, 'new', undefined, undefined, {
platform: 'win32',
copyFileDacl: unexpected,
replaceFile: unexpected,
})
expect(await readFile(file, 'utf8')).toBe('new')
})
it('recreates a vanished Windows target with the already-protected temp', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const missing = Object.assign(new Error('target vanished'), { code: 'ENOENT' })
await writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: () => Promise.resolve(),
replaceFile: async () => { throw missing },
})
expect(await readFile(file, 'utf8')).toBe('new')
})
it('surfaces a Windows secure-replacement failure and cleans the staging directory', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const denied = Object.assign(new Error('replace denied'), { code: 'EACCES' })
await expect(writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: () => Promise.resolve(),
replaceFile: async () => { throw denied },
})).rejects.toBe(denied)
expect(await readFile(file, 'utf8')).toBe('old')
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
const file = join(dir, 'a.txt')
await writeFileAtomic(file, 'hello', undefined, undefined)
expect((await stat(file)).mode & 0o777).toBe(0o600)

View File

@@ -0,0 +1,146 @@
/** Host-independent binding tests for the Win32 DACL and replacement helpers. */
import { toNamespacedPath } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
type GetFileSecurityW = (
path: string,
requestedInformation: number,
descriptor: Buffer | null,
length: number,
needed: [number],
) => number
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
type ReplaceFileW = (
replaced: string,
replacement: string,
backup: null,
flags: number,
exclude: null,
reserved: null,
) => number
interface NativeMock {
getFileSecurityW: GetFileSecurityW
setFileSecurityW: SetFileSecurityW
replaceFileW: ReplaceFileW
getLastError: () => number
}
async function importWithNative(native: NativeMock): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => ({
default: {
load: () => ({
func: (definition: string) => {
if (definition.includes('GetFileSecurityW')) return native.getFileSecurityW
if (definition.includes('SetFileSecurityW')) return native.setFileSecurityW
if (definition.includes('ReplaceFileW')) return native.replaceFileW
if (definition.includes('GetLastError')) return native.getLastError
throw new Error(`unexpected native function: ${definition}`)
},
}),
},
}))
return import('../src/win32.ts')
}
function successfulNative(descriptor: Buffer): NativeMock & { installed: Buffer[]; replacements: string[][] } {
let lastError = 0
const installed: Buffer[] = []
const replacements: string[][] = []
return {
installed,
replacements,
getLastError: () => lastError,
getFileSecurityW: (_path, _requested, output, _length, needed) => {
needed[0] = descriptor.length
if (output === null) {
lastError = 122
return 0
}
descriptor.copy(output)
lastError = 0
return 1
},
setFileSecurityW: (_path, information, value) => {
expect(information).toBe(0x80000004)
installed.push(Buffer.from(value))
lastError = 0
return 1
},
replaceFileW: (replaced, replacement, backup, flags, exclude, reserved) => {
expect([backup, flags, exclude, reserved]).toEqual([null, 0, null, null])
replacements.push([replaced, replacement])
lastError = 0
return 1
},
}
}
afterEach(() => {
vi.doUnmock('koffi')
vi.resetModules()
})
describe('Windows file-security helpers', () => {
it('reads and installs a protected DACL before replacing the destination', async () => {
const descriptor = Buffer.from([1, 2, 3, 4])
const native = successfulNative(descriptor)
const { copyFileDaclWin32, readFileDaclWin32, replaceFileWin32 } = await importWithNative(native)
expect(await readFileDaclWin32('source')).toEqual(descriptor)
await copyFileDaclWin32('source', 'temp')
expect(native.installed).toEqual([descriptor])
await replaceFileWin32('target', 'temp')
expect(native.replacements).toEqual([[toNamespacedPath('target'), toNamespacedPath('temp')]])
})
it('maps descriptor-size probe failures to Node-style codes', async () => {
const cases = [[2, 'ENOENT'], [3, 'ENOENT'], [5, 'EACCES'], [9999, 'EIO']] as const
for (const [win32Code, code] of cases) {
const native = successfulNative(Buffer.from([1]))
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
needed[0] = 0
return 0
}
native.getLastError = () => win32Code
const { readFileDaclWin32 } = await importWithNative(native)
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code, win32Code, path: 'source' })
}
})
it('surfaces a descriptor read failure after the size probe', async () => {
const native = successfulNative(Buffer.from([1, 2]))
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
needed[0] = 2
return 0
}
native.getLastError = () => 5
const { readFileDaclWin32 } = await importWithNative(native)
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code: 'EACCES', syscall: 'GetFileSecurityW' })
})
it('surfaces DACL installation and replacement failures', async () => {
const setFailure = successfulNative(Buffer.from([1]))
setFailure.setFileSecurityW = () => 0
setFailure.getLastError = () => 5
const setModule = await importWithNative(setFailure)
await expect(setModule.copyFileDaclWin32('source', 'temp')).rejects.toMatchObject({
code: 'EACCES',
syscall: 'SetFileSecurityW',
path: 'temp',
})
const replaceFailure = successfulNative(Buffer.from([1]))
replaceFailure.replaceFileW = () => 0
replaceFailure.getLastError = () => 2
const replaceModule = await importWithNative(replaceFailure)
await expect(replaceModule.replaceFileWin32('target', 'temp')).rejects.toMatchObject({
code: 'ENOENT',
syscall: 'ReplaceFileW',
path: 'target',
})
})
})

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,76 @@
/**
* 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'
const MISSING_CODES: ReadonlySet<NodeJS.ErrnoException['code']> = new Set(['ENOENT', 'ENOTDIR'])
function isMissing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code
return MISSING_CODES.has(code)
}
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,57 @@
/**
* 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)
expect(await isPathUnder(join(base, 'case-sensitive-child'), base, true)).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 {

View File

@@ -12,6 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
@@ -503,8 +504,8 @@ describe('glob results', () => {
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['src/a.ts', '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
it('validates arguments (blank pattern, blank path)', async () => {
@@ -631,7 +632,7 @@ describe('grep results', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`)
const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') })
expect(text(result)).toContain('deep/a.ts\nLine 2: hit')
expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`)
})
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
@@ -801,7 +802,7 @@ describe('presentation', () => {
describe('helpers', () => {
it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => {
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts')
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe(join('a', 'b.ts'))
expect(toWorkdirRelative('/w', '/w')).toBe('.')
expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts')
expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts')