From f64ba40f43961fc65b7e1724132708a0b6d163cf Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 01:24:33 +0800 Subject: [PATCH] feat(sandbox): Windows ACL write-restriction sandbox (restricted-token runner) Confine Windows command execution through a WRITE_RESTRICTED token whose restricting SIDs carry an orphan-SID write allowlist, ported from https://github.com/huoyaoyuan/windows-acl-restrict-poc (@ 10e4dfb). Every Win32 call is checked and fails closed - the POC silently ran children with the FULL token when CreateRestrictedToken failed. - @deepseek-ai/dsh-sandbox-windows-acl: koffi primitives verified against the MinGW Windows headers (verify/abi-probe.cpp) plus the confinement runner ([node, runner, --workspace, --temp, --mode, --, argv...]: kill-on-close job, stdio passthrough, exit-code mirroring, windows-acl-run: failure signature, grant revocation). read-only = strict zero grants (NUL device not writable; documented). Windows-only execution: exempted from the Linux coverage lane (windowsOnlyCoverageExclusions). - @deepseek-ai/dsh-sandbox-local: PLATFORM_CHAINS.win32 filled with the windows-acl runner (full enforcement, ACL denial dialect, runner-failure rules). - @deepseek-ai/dsh-pwsh-sandbox: sandbox-consuming pwsh executor (call-for-call mirror of dsh-bash-sandbox) over a new argv-level seam in dsh-pwsh-local; per-file coverage complete via the fake-provider spec. - bundle/base: the Windows platform layer mounts the confined pwsh roster - sandbox/policy/fs-sandbox/permission/approval re-enabled, the POSIX bash stack stays disabled. Co-authored-by: Huo Yaoyuan --- packages/bash/pwsh-local/src/index.ts | 48 ++- packages/bash/pwsh-sandbox/README.i18n.yaml | 6 + packages/bash/pwsh-sandbox/README.md | 24 ++ packages/bash/pwsh-sandbox/README.zh.md | 24 ++ packages/bash/pwsh-sandbox/package.json | 46 +++ packages/bash/pwsh-sandbox/src/helpers.ts | 120 +++++++ packages/bash/pwsh-sandbox/src/index.ts | 184 ++++++++++ packages/bash/pwsh-sandbox/src/invariant.ts | 30 ++ packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 115 +++++++ .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 320 ++++++++++++++++++ packages/bash/pwsh-sandbox/tsconfig.json | 39 +++ packages/bundle/base/tests/base.spec.ts | 36 +- packages/bundle/base/windows.cordis.patch.yml | 59 +--- packages/sandbox/sandbox-local/package.json | 3 +- packages/sandbox/sandbox-local/src/index.ts | 85 ++++- .../sandbox/sandbox-local/tests/local.spec.ts | 11 +- .../sandbox-windows-acl/README.i18n.yaml | 6 + .../sandbox/sandbox-windows-acl/README.md | 64 ++++ .../sandbox/sandbox-windows-acl/README.zh.md | 64 ++++ .../sandbox/sandbox-windows-acl/package.json | 44 +++ .../sandbox/sandbox-windows-acl/src/acl.ts | 113 +++++++ .../sandbox/sandbox-windows-acl/src/errors.ts | 21 ++ .../sandbox/sandbox-windows-acl/src/ffi.ts | 307 +++++++++++++++++ .../sandbox/sandbox-windows-acl/src/index.ts | 280 +++++++++++++++ .../sandbox-windows-acl/src/invariant.ts | 31 ++ .../sandbox/sandbox-windows-acl/src/runner.ts | 135 ++++++++ .../sandbox/sandbox-windows-acl/src/spawn.ts | 296 ++++++++++++++++ .../sandbox/sandbox-windows-acl/src/token.ts | 138 ++++++++ .../sandbox-windows-acl/src/win32-abi.ts | 136 ++++++++ .../sandbox-windows-acl/tests/probe.spec.ts | 97 ++++++ .../tests/provider-chain.spec.ts | 58 ++++ .../sandbox-windows-acl/tests/runner.spec.ts | 107 ++++++ .../sandbox/sandbox-windows-acl/tsconfig.json | 22 ++ .../sandbox-windows-acl/tsdown.config.ts | 16 + .../sandbox-windows-acl/verify/abi-probe.cpp | 177 ++++++++++ pnpm-lock.yaml | 49 +++ tsconfig.host.json | 2 + vitest.config.ts | 11 + 38 files changed, 3230 insertions(+), 94 deletions(-) create mode 100644 packages/bash/pwsh-sandbox/README.i18n.yaml create mode 100644 packages/bash/pwsh-sandbox/README.md create mode 100644 packages/bash/pwsh-sandbox/README.zh.md create mode 100644 packages/bash/pwsh-sandbox/package.json create mode 100644 packages/bash/pwsh-sandbox/src/helpers.ts create mode 100644 packages/bash/pwsh-sandbox/src/index.ts create mode 100644 packages/bash/pwsh-sandbox/src/invariant.ts create mode 100644 packages/bash/pwsh-sandbox/tests/acl.e2e.ts create mode 100644 packages/bash/pwsh-sandbox/tests/sandbox.spec.ts create mode 100644 packages/bash/pwsh-sandbox/tsconfig.json create mode 100644 packages/sandbox/sandbox-windows-acl/README.i18n.yaml create mode 100644 packages/sandbox/sandbox-windows-acl/README.md create mode 100644 packages/sandbox/sandbox-windows-acl/README.zh.md create mode 100644 packages/sandbox/sandbox-windows-acl/package.json create mode 100644 packages/sandbox/sandbox-windows-acl/src/acl.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/errors.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/ffi.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/index.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/invariant.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/runner.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/spawn.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/token.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/win32-abi.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tsconfig.json create mode 100644 packages/sandbox/sandbox-windows-acl/tsdown.config.ts create mode 100644 packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 20ecffd969..ceef4725fb 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + /** + * The pwsh invocation argv for one resolved spec — the argv-level seam a + * confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of + * `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see + * `@deepseek-ai/dsh-pwsh-sandbox`). + */ + protected argv(spec: BashExecSpec): string[] { + return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`] + } + + /** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */ + private spawnSpec( + spec: BashExecSpec, + stdoutMaxBytes: number, + signal: AbortSignal | undefined, + argv: readonly string[], + ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + argv: [...argv], cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor { } async run(spec: BashExecSpec): Promise { + return this.runArgv(spec, this.argv(spec)) + } + + /** Foreground run of an exact argv (the confining subclass re-wraps it). */ + protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv)) const outcome = await handle.done const collected = PwshLocalExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. @@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor { } start(spec: BashExecSpec): BashProcess { + return this.startArgv(spec, this.argv(spec)) + } + + /** Background start of an exact argv (the confining subclass re-wraps it). */ + protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing @@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, collected.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. * The base implementation is intentionally empty. Mirrored from - * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is - * the declared seam for a future pwsh-confining subclass and has no consumer - * in this package yet. + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the + * pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + * @param _spawnFailed - whether the spawn rejected before any process existed. + * @param _spawnError - the spawn rejection, when `_spawnFailed`. */ - protected onProcessDone(_proc: BashProcess, _stderr: string): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } /* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..7110763e72 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/bash/pwsh-sandbox/README.md +README.md: 5eaa513b7802fe1b4411a5c0bafabafd232d4da7 +README.zh.md: 4924feed26cb7bc50d4863a063d9a4b0bf26d954 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md new file mode 100644 index 0000000000..5eaa513b78 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +English | [中文](README.zh.md) + +Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command ` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt. + +The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy). + +## Behavior + +- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`. +- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`. + +## Model Experience + +### Confinement works, denial surfaces as command failure + +The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +## Known Limitations and Deferred Work + +- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`), mirroring Landlock's `/tmp` grant — a per-run private temp would need an env-block rewrite in the runner and is deferred. +- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md new file mode 100644 index 0000000000..4924feed26 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +[English](README.md) | 中文 + +沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 + +执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 + +## 行为 + +- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。 +- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。 + +## 模型体验 + +### 隔离生效,拒绝以命令失败呈现 + +模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +## 已知限制与后续工作 + +- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`),与 Landlock 授予 `/tmp` 同语义——按运行创建私有临时目录需要 runner 改写环境块,留待后续。 +- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json new file mode 100644 index 0000000000..47f51b2ac1 --- /dev/null +++ b/packages/bash/pwsh-sandbox/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-sandbox", + "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pwsh-local": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-sandbox/src/helpers.ts b/packages/bash/pwsh-sandbox/src/helpers.ts new file mode 100644 index 0000000000..44524a3d87 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/helpers.ts @@ -0,0 +1,120 @@ +/** + * Internal sandbox-result classification helpers — deliberate call-for-call + * mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of + * the bash consumer shares the identical classification dialect). + * + * @module @deepseek-ai/dsh-pwsh-sandbox/helpers + */ + +/* jscpd:ignore-start */ +import { accessSync, constants, statSync } from 'node:fs' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Node-local spawn codes proven to identify executable resolution or permission failure. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT']) + +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance + * after independently ruling out the caller-owned cwd. A supplied error path + * must exactly identify the runner; without one, the syscall must. With a + * usable cwd, these codes describe resolution or execute permission for that + * argv[0] or its shebang interpreter. + * The workdir is checked at classification time, not atomically with spawn; + * concurrent path replacement may change attribution but cannot permit an + * unconfined execution. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the caller-owned spawn cwd, checked independently for usability. + * @returns whether the rejection has executable-specific runner evidence. + */ +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, +): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false + if (typeof error !== 'object' || error === null) return false + const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown } + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false + if (typeof syscall !== 'string') return false + const exactSyscall = `spawn ${runnerProgram}` + if (path === undefined) return syscall === exactSyscall + if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false + return syscall === 'spawn' || syscall === exactSyscall +} + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string +} + +/** + * Classify a failed run against the selected backend's denial dialect. + * @param result - settled foreground run. + * @param signatures - case-insensitive denial substrings from the active wrap. + * @returns whether the failed run matches that denial dialect. + */ +export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean { + return matchesSignature(result.exitCode, result.stderr.text, signatures) +} + +/** + * Classify one settled process against the selected backend's structured + * runner-failure rules. Each rule requires a nonzero exit, its optional + * exit-code gate, and a fatal signature on one stderr line after exact + * informational lines are excluded. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text, left unchanged. + * @param rules - structured runner-failure rules from the active wrap. + * @returns the first matching fatal line, or undefined when evidence is insufficient. + */ +export function classifyRunnerFailure( + exitCode: number | null, + stderr: string, + rules: readonly RunnerFailureRule[], +): RunnerFailureMatch | undefined { + if (exitCode === null || exitCode === 0) return undefined + const lines = stderr.split(/\r?\n/) + for (const rule of rules) { + if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue + const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. + const fatalSignatures = rule.fatalSignatures + .filter(signature => signature.trim().length > 0) + .map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined +} + +/** + * Match a non-zero exit against case-insensitive stderr signatures. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text. + * @param signatures - substrings identifying the selected backend's dialect. + * @returns whether this is a non-zero exit whose stderr matches a signature. + */ +export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean { + if (exitCode === null || exitCode === 0) return false + const lowered = stderr.toLowerCase() + return signatures.some(signature => lowered.includes(signature.toLowerCase())) +} +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts new file mode 100644 index 0000000000..c88719f889 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -0,0 +1,184 @@ +/** + * Sandbox-consuming PowerShell executor — the pwsh twin of + * `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through + * `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner + * chain), inherits local process mechanics, and reports the selected mode, + * enforcement, and denial facts. Positive runner-launch evidence means the + * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while + * background processes carry `runnerFailed`; other spawn rejections retain + * local-executor semantics. The tool owns approval and passes a complete + * per-call policy. + * @module @deepseek-ai/dsh-pwsh-sandbox + */ + +import { Context } from 'cordis' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedArgv, + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' + +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig + +/** + * Registers as `ctx.bash` in place of the local pwsh executor and requires a + * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is + * unchanged. Tool calls pass the calling session's resolved policy; direct + * calls fall back to deployment policy. `result.sandbox` reports the mode and + * enforcement actually used. + */ +export class SandboxPwshExecutor extends PwshLocalExecutor { + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] + + // No own Config: the sandbox default (mode + workspaceRoot) moved to + // ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config + // verbatim (the config catalog walks the inherited static). + + private readonly mode: SandboxMode + /** + * Per-process confinement facts retained until settlement. Providers may + * vary enforcement and diagnostic dialect between overlapping calls, so a + * shared latest-wrap value would classify a process against the wrong facts. + * Unconfined processes have no entry. + */ + private readonly processFacts = new Map() + + constructor(ctx: Context, config: Config) { + super(ctx, config) + // The default mode is the capability fact used for schema advertisement; + // actual tool executions carry their resolved per-call policy. + this.mode = ctx.sandboxPolicy.defaultMode + } + + /** The configured default mode — the capability fact the tool layer reads. */ + override get sandboxMode(): SandboxMode { + return this.mode + } + + /** + * Stamp a complete per-call policy onto the spec. Tool calls supply the + * calling session's resolved mode and root; lower-level callers fall back to + * the deployment policy. + */ + override resolve(request: BashExecRequest): BashExecSpec { + return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() } + } + + override async run(spec: BashExecSpec): Promise { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') { + const result = await super.run(spec) + return { ...result, sandbox: { mode, denied: false } } + } + const confined = this.confine(spec, { ...policy, mode }) + let result: BashRunResult + try { + result = await this.runArgv(spec, confined.argv) + } catch (error) { + // An upstream abort remains cancellation even when it prevents spawn. + if (spec.signal?.aborted === true) spec.signal.throwIfAborted() + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) + } + return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } + } + + override start(spec: BashExecSpec): BashProcess { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') return super.start(spec) + // Once startArgv returns, install facts synchronously; promise settlement + // cannot run before start() returns. + const confined = this.confine(spec, { ...policy, mode }) + let proc: BashProcess + try { + proc = this.startArgv(spec, confined.argv) + } catch (error) { + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + workdir: spec.workdir, + }) + return proc + } + + /** + * Stamp per-process sandbox facts before `done` settles. Full-access + * processes have no facts; signal deaths are not denials. + */ + protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { + const facts = this.processFacts.get(proc) + if (facts !== undefined) { + this.processFacts.delete(proc) + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnFailed + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + proc.sandbox = { + mode: facts.mode, + denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), + enforcement: facts.enforcement, + ...(runnerFailed ? { runnerFailed } : {}), + } + } + super.onProcessDone(proc, stderr, spawnFailed, spawnError) + } + + /** + * Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors + * propagate unchanged; the returned argv is handed directly to the local + * executor's subprocess path. + * @param spec - resolved execution spec whose pwsh argv is confined. + * @param policy - resolved confined execution policy. + * @returns the provider's exact argv and settlement-classification facts. + */ + private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv { + return this.ctx.sandbox.confine(this.argv(spec), policy) + } +} + +export default SandboxPwshExecutor diff --git a/packages/bash/pwsh-sandbox/src/invariant.ts b/packages/bash/pwsh-sandbox/src/invariant.ts new file mode 100644 index 0000000000..6afda519ff --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`. + * @module @deepseek-ai/dsh-pwsh-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-sandbox-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond contracts enforced at its owning seams. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts new file mode 100644 index 0000000000..a5909f0544 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -0,0 +1,115 @@ +/** + * Real-backend end-to-end: LocalSandboxProvider (win32 chain → the + * windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with + * REAL pwsh spawns confined through the runner — the debug-instance + * verification of both modes: read-only denies every write (not even NUL), + * workspace-write allows the workspace and temp while denying escape writes, + * and denial/classification facts ride the settled result. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let executor!: SandboxPwshExecutor + + beforeAll(async () => { + // The escape probe must live OUTSIDE every legitimately granted tree: the + // provider's workspace-write grants the workspace plus the REAL temp dir + // (the 'backend-defined temp area', same as Landlock granting /tmp), so a + // scratch dir under temp would inherit the grant and the probe would be a + // false pass. A mkdtemp under the profile is removed by afterAll. + scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxPwshExecutor, {}) + executor = ctx.bash as SandboxPwshExecutor + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => { + const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: DENIED') + expect(result.stdout.text).toContain('TEMP-WRITE: DENIED') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false) + // A self-caught denial keeps the command exit 0: no denial fact. + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + + // A raw failing write must classify as a denial of the ACL dialect. + const denied = await executor.run(executor.resolve({ + command: `Set-Content -Path '${escapeFile}' -Value x`, + sandboxPolicy: policy, + })) + expect(denied.exitCode).not.toBe(0) + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 60_000) + + it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: OK') + expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(escapeFile)).toBe(false) + expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + }, 60_000) +}) diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts new file mode 100644 index 0000000000..1c8e5e1bd0 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -0,0 +1,320 @@ +/** + * Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service + * makes wrapping, policy hand-off, fail-closed propagation, and fact stamping + * deterministic; real-provider integration lives in `tests/acl.e2e.ts`. + * Requires pwsh for the integration block (skips without it — same gate as + * pwsh-local's suites); the helpers block is pure and always runs. + */ + +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' +import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) + +/** One recorded provider call: the argv handed over and the policy it rode with. */ +interface ConfineCall { + argv: string[] + policy: SandboxPolicy +} + +/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */ +const passthrough = (argv: readonly string[]): ConfinedArgv => + ({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] }) + +/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */ +function throwingSubprocessService(error: unknown): new (ctx: Context) => Service { + return class extends Service { + constructor(ctx: Context) { + super(ctx, 'subprocess') + } + + spawn(): never { + throw error + } + } +} + +async function setup( + behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough, + subprocess: new (ctx: Context) => Service = LocalSubprocessService, +): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> { + const calls: ConfineCall[] = [] + class FakeSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + calls.push({ argv: [...argv], policy }) + return behavior(argv, policy) + } + } + const ctx = new Context() + await ctx.plugin(FakeSandboxProvider) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir }) + await ctx.plugin(subprocess) + if (ctx.subprocess instanceof LocalSubprocessService) { + ctx.subprocess.internals = { spillDir } + } + await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 }) + return { executor: ctx.bash as SandboxPwshExecutor, calls } +} + +describe('helpers (pure)', () => { + const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-')) + afterAll(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe('isRunnerSpawnFailure', () => { + const absolute = process.execPath + const bare = 'node' + const relative = './sandbox-runner' + + it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => { + for (const runnerProgram of [absolute, bare, relative]) { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true) + } + }) + + it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false) + expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false) + // An existing FILE (not a directory) workdir is unusable without throwing. + const fileWorkdir = join(workdir, 'a-file') + writeFileSync(fileWorkdir, 'x') + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false) + }) + }) + + describe('classifyRunnerFailure', () => { + const rules: readonly RunnerFailureRule[] = [{ + allowedExitCodes: [127], + fatalSignatures: ['fake-runner: '], + informationalLines: ['fake-runner: partial enforcement'], + }] + + it('matches a fatal signature on a gated exit code, skipping informational lines', () => { + expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules)) + .toEqual({ detail: 'fake-runner: profile refused' }) + }) + + it('rejects zero/null exits, gate mismatches, and empty signatures', () => { + expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() + }) + }) + + describe('matchesSignature', () => { + it('matches non-zero exits case-insensitively, never zero or signal exits', () => { + expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true) + expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true) + expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false) + expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false) + expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false) + }) + }) +}) + +describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => { + // Denial device for the POSIX classification cases: a mode-0555 directory + // INSIDE a temp scratch tree (the same device as bash-sandbox's suites) — + // unit tests never attempt writes outside the system temp directory. On + // win32 there is no POSIX mode denial; the real-sandbox denial coverage + // lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths. + const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-')) + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555) + const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')` + + afterAll(() => { + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755) + rmSync(readOnlyDir, { recursive: true, force: true }) + rmSync(spillDir, { recursive: true, force: true }) + }) + + const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' } + + it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(1) + const call = calls[0] + expect(call?.policy).toEqual(RO) + // The confined argv is the pwsh invocation, ready for a runner prefix. + expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u) + expect(call?.argv).toContain('-NonInteractive') + expect(call?.argv.at(-1)).toContain('echo wrapped') + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => { + const { executor, calls } = await setup() + expect(executor.sandboxMode).toBe('workspace-write') + const result = await executor.run(executor.resolve({ command: 'echo fallback' })) + expect(result.exitCode).toBe(0) + expect(calls[0]?.policy.mode).toBe('workspace-write') + }, 30_000) + + it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(0) + expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) + }, 30_000) + + it('an aborted caller signal outranks runner-spawn attribution', async () => { + const controller = new AbortController() + controller.abort('caller-cancel') + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [], + })) + await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal }))) + .rejects.toThrow('caller-cancel') + }, 30_000) + + // POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the + // real-sandbox denial classification is covered by tests/acl.e2e.ts + // (the ACL runner denies scratch paths — unit tests never leave temp). + it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => { + const { executor } = await setup() + const result = await executor.run(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => { + const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow('sync-emfile') + }, 30_000) + + it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => { + const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow('sync-emfile-start') + }, 30_000) + + it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => { + const { executor } = await setup(() => ({ + argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('background confined runs stamp clean facts at settlement', async () => { + const { executor } = await setup() + const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO })) + await clean.done + expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + // POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial + // coverage lives in tests/acl.e2e.ts. + it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => { + const { executor } = await setup() + const denied = executor.start(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + await denied.done + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('background spawn rejections settle as runnerFailed facts', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO })) + await proc.done + expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true }) + // The failure note surfaces through the read path. + const read = proc.readOutput() + expect(read.delta).toContain('spawn failed') + }, 30_000) + + it('danger-full-access background runs bypass confine and carry no facts', async () => { + const { executor, calls } = await setup() + const proc = executor.start(executor.resolve({ + command: 'echo full-bg', + sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' }, + })) + await proc.done + expect(calls).toHaveLength(0) + expect(proc.sandbox).toBeUndefined() + }, 30_000) +}) diff --git a/packages/bash/pwsh-sandbox/tsconfig.json b/packages/bash/pwsh-sandbox/tsconfig.json new file mode 100644 index 0000000000..55eac06470 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/pwsh-local" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index dc18224dda..ab2dc874ce 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -30,7 +30,7 @@ describe('dsh-base bundle', () => { expect(rows.some(row => row.id === 'agent-loop')).toBe(true) }) - it('ships the Windows platform layer as the documented danger-full-access roster', () => { + it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const parsed = yaml.load( readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), @@ -44,30 +44,20 @@ describe('dsh-base bundle', () => { const disables = parsed .filter(patch => patch.disabled === true) .map(patch => patch.id) - // The POSIX-only sandboxed stacks leave the Windows roster as one unit: - // shell (bash-sandbox/tool-bash), the permission switcher it requires, - // the fs/sandbox policy stack whose OS runners do not exist on win32, - // and the approval service — nothing on Windows asks for approval, so - // the model is never told approval exists or that asks auto-reject. - expect(disables).toEqual( - expect.arrayContaining([ - 'bash-sandbox', - 'tool-bash', - 'permission', - 'ui-permission', - 'sandbox', - 'sandbox-policy', - 'fs-sandbox', - 'approval', - ]), - ) + // Only the POSIX bash stack is disabled: the Windows roster confines the + // pwsh executor through the ACL runner chain, so the sandbox/policy rows, + // the permission switcher, fs-sandbox, and the approval service all stay + // enabled exactly as on POSIX — only the shell is swapped. + expect(disables).toEqual(['bash-sandbox', 'tool-bash']) const inserted = parsed .flatMap(patch => patch.insert ?? []) .map(row => row.id) - expect(inserted).toEqual( - expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), - ) - // Full danger-full-access degradation: no approval surface at all. - expect(parsed.find(patch => patch.id === 'approval')?.config).toBeUndefined() + expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh', 'fs-local']) + // The patch no longer touches the permission/approval surface at all. + expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined() }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 1abd4ba568..66ed54112f 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,26 +1,19 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers. Windows cannot run -# the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack -# for the PowerShell stack AND drops the whole permission surface: no OS -# runner exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any -# policy would be theater — the unconfined shell could bypass fs-only path -# rules with one command. Windows therefore degrades to danger-full-access: -# unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher -# (dsh-permission requires a confining executor), and no approval service — -# nothing in the roster asks for approval, and the model is never told -# approval exists or that requests are auto-rejected. -# The launcher reads THIS file from the base bundle package (never through -# dsh.bundle.patch — that field names the one universal layer). A Windows -# host that prefers bash or confinement overrides these rows through its -# profile or home cordis.patch.yml. -# The bash-restore recipe must be complete: disable pwsh-local and tool-pwsh -# AND re-enable bash-sandbox and tool-bash (plus permission/ui-permission only -# if the switcher is wanted) — both executors register the same 'bash' -# service, so re-enabling the bash rows while pwsh-local stays inserted fails -# loud at load on a duplicate registration. -# The ui-permission disable targets a row owned by dsh-web-app, not dsh-base: -# a base-only profile (e.g. the `dsh plugin --profile` default template) has -# no such row, and the no-match logs a harmless warning on every load. +# hosts, between the bundle layers and the user layers. Windows confines +# through the ACL restricted-token runner (the win32 chain of +# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped +# stack is the SANDBOXED PowerShell executor plus the full permission +# surface: sandbox/sandbox-policy enforce the file-effect policy, the +# permission switcher and the approval service run exactly as on POSIX, and +# fs-sandbox fences the in-process filesystem view. Only the POSIX bash +# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. +# A Windows host that prefers the unconfined local pwsh executor or full +# access overrides these rows through its profile or home cordis.patch.yml. +# The bash-restore recipe must be complete: disable pwsh-sandbox and +# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor +# families register the same 'bash' service, so re-enabling the bash rows +# while pwsh-sandbox stays inserted fails loud at load on a duplicate +# registration. - id: bash-sandbox disabled: true @@ -28,27 +21,9 @@ - id: tool-bash disabled: true -- id: permission - disabled: true - -- id: ui-permission - disabled: true - -- id: sandbox - disabled: true - -- id: sandbox-policy - disabled: true - -- id: fs-sandbox - disabled: true - -- id: approval - disabled: true - - insert: - - id: pwsh-local - name: '@deepseek-ai/dsh-pwsh-local' + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 2683ab3654..060026e284 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-local", - "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed", + "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", "version": "0.0.1", "private": true, "type": "module", @@ -31,6 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "node-addon-landlock-run": "0.0.0-test.0", "schemastery": "^3.18.0" }, diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 64e92d9bf2..22182095d5 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,12 +1,16 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then - * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr classification facts. Missing or unusable - * confinement fails closed rather than returning the original argv. + * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes + * competing candidates once, and reports each wrap's enforcement and stderr + * classification facts. Missing or unusable confinement fails closed rather + * than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, @@ -69,6 +73,27 @@ function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean return probe.status === 0 } +/** + * Functional windows-acl probe: run the runner in read-only mode (zero grants, + * no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created + * the restricted token and spawned the child under it. The win32 chain is a + * sole candidate, so the product never probes; the probe exists for override + * chains and mirrors the other rungs' shape. + */ +function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean { + const program = runnerInvocation[0] + if (program === undefined) return false + const probe = spawnSync(program, [ + ...runnerInvocation.slice(1), + '--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only', + '--', 'cmd', '/c', 'exit', '0', + ], { + timeout: timeoutMs, + stdio: 'ignore', + }) + return probe.status === 0 +} + /** Test seam: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -85,10 +110,14 @@ export interface SandboxInternals { landlockLauncher?: string /** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */ seatbeltExec?: string + /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ + windowsAclRunnerArgs?: string[] + /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ + probeWindowsAcl?: () => boolean } /** The chain's verdict: which runner confines, and how completely it enforces. */ -type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement } +type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } /** * The runner chain per platform — selection is BY PLATFORM first, probes @@ -102,11 +131,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: const PLATFORM_CHAINS: Record = { linux: ['bwrap', 'landlock'], darwin: ['seatbelt'], - // Reserved slot, deliberately empty: Windows support fills it with a confinement runner - // (AppContainer / restricted-token family, shipped from its own repository on the - // landlock-run template) plus a SelectedRunner['runner'] union member — the switches' - // assertNever guards then walk the implementer to every site. - win32: [], + // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl): + // a sole candidate, selected without a probe — its execution-time refusal + // fails closed through its stderr signature (windows-acl-run:) and exit 127. + win32: ['windows-acl'], } /** @@ -122,6 +150,9 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', + // The restricted token intersects every write access by construction, so + // the ACL runner governs every promised file effect — full enforcement. + 'windows-acl': 'full', } /** @@ -144,6 +175,9 @@ const DENIAL_SIGNATURES = { bwrap: ['read-only file system'], landlock: ['permission denied'], seatbelt: ['operation not permitted'], + // pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied."; + // node EACCES: "permission denied". + 'windows-acl': ['access is denied', 'access to the path', 'permission denied'], runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record @@ -152,7 +186,9 @@ const DENIAL_SIGNATURES = { * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. - * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * The windows-acl runner prints `windows-acl-run: ` on every + * runner-side failure and exits 127. Keep the Landlock tuple aligned with the + * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ const RUNNER_FAILURE_RULES = { @@ -163,6 +199,7 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], + 'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record /** @@ -245,6 +282,14 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] + case 'windows-acl': return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Explicit, never GetTempPathW-defaulted: the runner grants exactly + // this directory (workspace-write) or nothing (read-only). + '--temp', tmpdir(), + '--mode', policy.mode, + ] default: return assertNever(runner) } } @@ -295,6 +340,11 @@ export class LocalSandboxProvider extends SandboxProvider { const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs)) return probe(this.seatbeltExec()) ? 'full' : 'unusable' } + case 'windows-acl': { + const probe = this.internals.probeWindowsAcl + ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)) + return probe() ? 'full' : 'unusable' + } default: return assertNever(runner) } } @@ -308,6 +358,21 @@ export class LocalSandboxProvider extends SandboxProvider { private seatbeltExec(): string { return this.internals.seatbeltExec ?? 'sandbox-exec' } + + /** + * The windows-acl runner argv prefix: the built lib/runner.js entry when + * present (production), else the package source through tsx (development). + * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps + * the same argv contract and only swaps these entries. + */ + private windowsAclRunnerInvocation(): string[] { + const override = this.internals.windowsAclRunnerArgs + if (override !== undefined) return override + const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + if (existsSync(builtEntry)) return [process.execPath, builtEntry] + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] + } } export default LocalSandboxProvider diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 74d4c2a8a1..5e12b37ad2 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -209,13 +209,10 @@ describe('the platform chains', () => { expect(probeSeatbelt).not.toHaveBeenCalled() }) - it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => { - // The slot exists so Windows support is an additive fill-in (chain entry - // + runner union member), never a redesign — and reserving it must not - // weaken the fail-closed end in the meantime. - const { sandbox } = await setup({}, { platform: 'win32' }) - expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) - }) + // The win32 chain's argv contract, denial dialect, and runner-failure rules + // live in @deepseek-ai/dsh-sandbox-windows-acl/tests/provider-chain.spec.ts + // (platform-independent assertions that run in every CI lane, including + // Windows where this package's POSIX-only suites are excluded). it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => { const probeBwrap = vi.fn(() => true) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml new file mode 100644 index 0000000000..d1ed2c0d43 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/sandbox/sandbox-windows-acl/README.md +README.md: 34a9e7160bae3af93da17cae254202bc1b555967 +README.zh.md: 0ecfd146a2ef500aa34fe7b6314926a8daf802ab diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md new file mode 100644 index 0000000000..34a9e7160b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -0,0 +1,64 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +English | [中文](README.zh.md) + +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), built as the preparation layer for a Windows `SandboxProvider` (`workspace-write` / `read-only` modes). Linux/macOS backends live in [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/). + +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. + +## Usage + +```ts +import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // revokes all standing grants; reports every cleanup failure +``` + +Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. + +## The confinement runner + +The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: + +```sh +node runner.js --workspace --temp --mode -- +``` + +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. + +Modes: +- `workspace-write`: the workspace and temp directories carry the orphan-SID Write grant; every other write is denied by the token intersection. +- `read-only`: STRICT zero grants — nothing is writable. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Documented behavior, not a prompt promise — the model-facing surface makes no sink claims for read-only mode. + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns. + +## Header verification + +All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts): + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory. + +## Verified boundaries (inherent to restricted tokens, not this port) + +- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. +- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. +- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. + +## Known Limitations and Deferred Work + +- **No `SandboxProvider` wiring yet** — this package is the primitives layer; the `ctx.sandbox.confine()` integration (spawn-side token application plus the `denialSignatures`/`runnerFailureRules` contract) is the next step and cannot reuse the argv-wrapping style of `dsh-sandbox-local` because the restricted token must be applied at `CreateProcess` time. +- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. +- **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Read-side confinement, network policy, and job-object kill-on-close are out of scope** for this layer and belong to the future provider design. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md new file mode 100644 index 0000000000..0ecfd146a2 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -0,0 +1,64 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +[English](README.md) | 中文 + +面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 Windows 端 `SandboxProvider`(`workspace-write` / `read-only` 模式)实装的准备层。Linux/macOS 后端见 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)。 + +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 + +## 用法 + +```ts +import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行 + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败 +``` + +本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 + +## 隔离 runner + +面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: + +```sh +node runner.js --workspace --temp --mode -- +``` + +runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 + +模式: +- `workspace-write`:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`:**严格零授权**——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。 + +`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。 + +## 头文件查证 + +所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)逐一核实,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(尺寸、偏移、枚举值、static_assert)交叉验证: + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。 + +## 已验证的边界(受限令牌固有,非本移植缺陷) + +- **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 +- **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。 +- **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 + +## 已知限制与后续工作 + +- **尚未接入 `SandboxProvider`** —— 本包是原语层;`ctx.sandbox.confine()` 的集成(在 spawn 侧应用受限令牌,并补齐 `denialSignatures`/`runnerFailureRules` 契约)是下一步。该集成不能沿用 `dsh-sandbox-local` 的 argv 包装风格,因为受限令牌必须在 `CreateProcess` 时生效。 +- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 +- **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **读侧隔离、网络策略、job-object 关闭即杀** 超出本层范围,留给未来的 provider 设计。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json new file mode 100644 index 0000000000..3e2afd8fb3 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-sandbox-windows-acl", + "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./runner": { + "types": "./lib/types/runner.d.ts", + "default": "./lib/runner.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/runner.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "koffi": "^3.1.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts new file mode 100644 index 0000000000..58020c4470 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -0,0 +1,113 @@ +/** + * ACL editing helpers: grant/revoke the orphan write SID on a directory via + * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with + * the failure handling the POC lacks). Every API call is checked and every + * failure is reported with the API name, the exact Win32 code, the formatted + * system text, and the affected path. + * @module @deepseek-ai/dsh-sandbox-windows-acl/acl + */ + +import { allocPtrSlot, decodePtr, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp): + * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, + * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. + * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which + * removes every ACE for the trustee. + */ +function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { + const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) + entry.writeUInt32LE(permissions, 0) // grfAccessPermissions + entry.writeUInt32LE(mode, 4) // grfAccessMode + entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation + entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm + entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType + entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID + return entry +} + +/** + * Grant `FILE_GENERIC_WRITE & ~READ_CONTROL` (displays as "Write") to the + * orphan SID on `path`, inheriting to subcontainers and objects. The directory + * must be owned by the caller (owner implicit WRITE_DAC) — same precondition + * as the POC. + */ +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), null, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', mergeResult, path) + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `null ACL for ${path}`) + + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + // Free the LocalAlloc'd ACL before any throw; capture both outcomes first. + const freed = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, path) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path})`) +} + +/** + * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * merge — other entries are preserved). Returns whether an ACE removal was + * attempted (false when the directory carries no DACL at all). + * + * Allocation contract (the POC's RevokeAccess, minus its missing checks): + * GetNamedSecurityInfoW returns the DACL pointer INSIDE the security + * descriptor allocation — only the descriptor may be LocalFree'd, and it must + * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL + * pointer itself corrupts the heap (verified the hard way). + */ +export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) + const oldAcl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + + if (oldAcl === null) { + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + } + return false + } + + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) { + if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + throwWin32(api, 'SetEntriesInAclW', mergeResult, `revokeWrite(${path})`) + } + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) { + if (descriptor !== null) api.localFree(descriptor) + throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `revokeWrite(${path}): null new ACL`) + } + + // The descriptor block (oldAcl included) is dead after the merge — free it + // before applying, exactly like the POC. + const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + const freedNew = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `revokeWrite(${path})`) + if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) new ACL`) + return true +} diff --git a/packages/sandbox/sandbox-windows-acl/src/errors.ts b/packages/sandbox/sandbox-windows-acl/src/errors.ts new file mode 100644 index 0000000000..b57d6dd466 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/errors.ts @@ -0,0 +1,21 @@ +/** + * Fail-closed Win32 error type. Every backend API failure raises this with the + * API name and the exact Win32 code; the original POC silently ignored every + * failed call and would run children UNRESTRICTED (fail-open) — that is the + * failure mode this class exists to prevent. + * @module @deepseek-ai/dsh-sandbox-windows-acl/errors + */ + +export class Win32Error extends Error { + /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */ + readonly api: string + /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */ + readonly win32Code: number + + constructor(api: string, win32Code: number, detail?: string) { + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + this.name = 'Win32Error' + this.api = api + this.win32Code = win32Code + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts new file mode 100644 index 0000000000..a2fefecdbb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -0,0 +1,307 @@ +/** + * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so + * non-Windows processes never open Win32 libraries. Every function signature + * below was verified against the MinGW Windows headers on this machine + * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / + * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h); + * struct layouts are asserted at load time against verify/abi-probe.cpp. + * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi + */ + +import koffi from 'koffi' +import { Win32Error } from './errors.ts' +import * as abi from './win32-abi.ts' + +/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ +declare const nativePtr: unique symbol +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** True for NULL pointers, however koffi returns them (null or 0n). */ +export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { + return value === null || value === undefined || (value as bigint) === 0n +} + +type Ptr = ReturnType + +/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ +export interface StartupInfoInput { + cb: number + dwFlags: number + hStdInput: NativePtr + hStdOutput: NativePtr + hStdError: NativePtr +} + +/** Decoded PROCESS_INFORMATION (layout verified: size 24). */ +export interface ProcessInfoOutput { + hProcess: NativePtr | null + hThread: NativePtr | null + dwProcessId: number + dwThreadId: number +} + +export interface Win32Bindings { + // ---- process / token handles -------------------------------------------- + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number + closeHandle(handle: NativePtr): number + // ---- errors / diagnostics ------------------------------------------------ + getLastError(): number + formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number + // ---- memory -------------------------------------------------------------- + localAlloc(flags: number, bytes: number): NativePtr + localFree(memory: NativePtr): NativePtr + // ---- SIDs ---------------------------------------------------------------- + convertStringSidToSidW(stringSid: string, sid: NativePtr): number + convertSidToStringSidW(sid: NativePtr, stringSid: NativePtr): number + createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number + isValidSid(sid: NativePtr): number + getLengthSid(sid: NativePtr): number + copySid(length: number, destination: NativePtr, source: NativePtr): number + // ---- token information --------------------------------------------------- + getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + // ---- restricted token ---------------------------------------------------- + createRestrictedToken( + existing: NativePtr, flags: number, + disableCount: number, disableSids: null, + deletePrivilegeCount: number, privilegesToDelete: null, + restrictCount: number, restrictingSids: Buffer, + newToken: NativePtr, + ): number + // ---- ACL editing --------------------------------------------------------- + setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + setNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: null, group: null, dacl: NativePtr | null, sacl: null, + ): number + getNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, + ): number + // ---- environment / io ---------------------------------------------------- + getTempPathW(length: number, buffer: Buffer): number + createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + setHandleInformation(handle: NativePtr, mask: number, flags: number): number + createProcessAsUserW( + token: NativePtr, applicationName: null, commandLine: string, + processAttributes: null, threadAttributes: null, + inheritHandles: number, creationFlags: number, environment: null, + currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, + ): number + readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number + peekNamedPipe( + pipe: NativePtr, buffer: null, size: number, + bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number + resumeThread(thread: NativePtr): number + // ---- job object (runner kill-on-close) ----------------------------------- + createJobObjectW(attributes: null, name: null): NativePtr + setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // ---- console ------------------------------------------------------------- + // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): + // the runner survives console Ctrl+C so the child handles its own and the + // runner can clean up grants after the child exits. + setConsoleCtrlHandler(handler: null, add: number): number + getStdHandle(stdHandle: number): NativePtr +} + +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { + cb: 'uint32', + lpReserved: 'str16', + lpDesktop: 'str16', + lpTitle: 'str16', + dwX: 'uint32', + dwY: 'uint32', + dwXSize: 'uint32', + dwYSize: 'uint32', + dwXCountChars: 'uint32', + dwYCountChars: 'uint32', + dwFillAttribute: 'uint32', + dwFlags: 'uint32', + wShowWindow: 'uint16', + cbReserved2: 'uint16', + lpReserved2: koffi.pointer('uint8'), + hStdInput: PVOID, + hStdOutput: PVOID, + hStdError: PVOID, +}) + +export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { + hProcess: PVOID, + hThread: PVOID, + dwProcessId: 'uint32', + dwThreadId: 'uint32', +}) + +if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { + throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) +} +if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { + throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) +} + +/** Allocate one pointer-sized slot (for `T **` out-parameters). */ +export function allocPtrSlot(): NativePtr { + const value: unknown = koffi.alloc(PVOID, 1) + return value as NativePtr +} + +/** Allocate one uint32 slot. */ +export function allocUint32(): NativePtr { + const value: unknown = koffi.alloc('uint32', 1) + return value as NativePtr +} + +/** Write a uint32 value into a slot pointer. */ +export function encodeUint32(slot: NativePtr, value: number): void { + koffi.encode(slot, 'uint32', value) +} + +/** Decode the pointer stored in a pointer-sized slot (NULL becomes null). */ +export function decodePtr(slot: NativePtr): NativePtr | null { + const value: unknown = koffi.decode(slot, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** Decode a uint32 at a slot pointer. */ +export function decodeUint32(slot: NativePtr): number { + const value: unknown = koffi.decode(slot, 'uint32') + return value as number +} + +/** Decode a UTF-16 string at a pointer. */ +export function decodeStr16(ptr: NativePtr): string { + const value: unknown = koffi.decode(ptr, 'str16') + return value as string +} + +/** Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). */ +export function ptrAddress(ptr: NativePtr): bigint { + return koffi.address(ptr) +} + +/** Allocate a raw byte block (used for SID copies and variable-length arrays). */ +export function allocBytes(length: number): NativePtr { + const value: unknown = koffi.alloc('uint8', length) + return value as NativePtr +} + +/** Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). */ +export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { + const value: unknown = koffi.decode(buffer, offset, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** Allocate a zeroed STARTUPINFOW. */ +export function allocStartupInfo(): NativePtr { + const value: unknown = koffi.alloc(STARTUPINFOW, 1) + return value as NativePtr +} + +/** Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). */ +export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { + koffi.encode(startupInfo, STARTUPINFOW, fields) +} + +/** Allocate a zeroed PROCESS_INFORMATION. */ +export function allocProcessInfo(): NativePtr { + const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) + return value as NativePtr +} + +/** Decode a PROCESS_INFORMATION after CreateProcessAsUserW. */ +export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { + const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) + return value as ProcessInfoOutput +} + +let cached: Win32Bindings | undefined + +function bindings(): Win32Bindings { + if (cached !== undefined) return cached + const kernel32 = koffi.load('kernel32.dll') + const advapi32 = koffi.load('advapi32.dll') + + // Each binding shape is verified by verify/abi-probe.cpp against the real + // Windows headers and exercised end-to-end by tests/probe.spec.ts; the + // single cast keeps the per-binding noise out of this table. + const bind = (lib: ReturnType, name: string, result: Ptr | string, args: Array): unknown => + lib.func('__stdcall', name, result, args) + + cached = { + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), + closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), + getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]), + localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), + localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), + convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), + convertSidToStringSidW: bind(advapi32, 'ConvertSidToStringSidW', 'int', [PVOID, koffi.pointer('str16')]), + createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), + isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), + getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), + copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), + getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), + createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), + setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), + getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), + getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), + createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), + createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ + PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), + readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), + peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), + waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), + createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), + getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), + } as unknown as Win32Bindings + return cached +} + +/** Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). */ +export function win32(): Promise { + return Promise.resolve(bindings()) +} + +/** Turn a Win32 error code into readable text via FormatMessageW. */ +export function errorText(api: Win32Bindings, win32Code: number): string { + const buffer = Buffer.alloc(1024) + const length = api.formatMessageW( + abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, + null, win32Code, 0, buffer, buffer.length / 2, null, + ) + if (length === 0) return '' + return buffer.subarray(0, length * 2).toString('utf16le').trim() +} + +/** + * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately + * after the failed call so GetLastError is not clobbered by other Win32 calls. + */ +export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { + const win32Code = api.getLastError() + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} + +/** Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). */ +export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts new file mode 100644 index 0000000000..77655881ca --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -0,0 +1,280 @@ +/** + * Windows ACL write-restriction sandbox backend for the DeepSeek Harness + * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ + * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED + * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only + * this sandbox instance adds to the target directories' DACLs — the + * intersection check then allows writes exactly where that SID has a Write + * ACE, and nowhere else. Unlike the POC, every API failure throws with the + * API name and exact Win32 code; a child is NEVER spawned unrestricted. + * + * Known boundaries (inherent to restricted tokens, not this port): + * - writes are restricted; reads, network, and process visibility are NOT + * (WRITE_RESTRICTED intersects only write accesses); + * - console isolation is unavailable — children share the host console + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with + * STATUS_DLL_INIT_FAILED under the restriction); + * - the temp directory and every writable directory must be owned by the + * caller (owner-implicit WRITE_DAC); + * - grants are standing ACE mutations on real directories — revoke them via + * dispose() before the process exits (the POC's documented + * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use + * this module's revoke instead). + * @module @deepseek-ai/dsh-sandbox-windows-acl + */ + +import { randomInt } from 'node:crypto' +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +import { grantWrite, revokeWrite } from './acl.ts' +import { Win32Error } from './errors.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' +import * as abi from './win32-abi.ts' + +export { quoteArg } from './spawn.ts' +export { Win32Error } from './errors.ts' + +export interface AclSandboxOptions { + /** Directories the confined child may write into (must exist and be caller-owned). */ + writableDirs: readonly string[] + /** + * Temp directory to also grant; defaults to GetTempPathW() at init time. + * Pass null for read-only confinement: NO temp grant (strict zero write + * allowance — not even the NUL device is writable, see README). + */ + tempDir?: string | null + /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ + writeSid?: string +} + +export interface AclSandboxSpawnOptions { + /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ + command: string + /** Arguments, quoted per CommandLineToArgvW rules. */ + args?: readonly string[] + /** Working directory; defaults to the caller's cwd. */ + cwd?: string + /** + * 'pipe' (default): capture stdout/stderr via anonymous pipes. + * 'inherit': the child inherits the caller's stdio directly (runner usage — + * bytes flow straight through), always wrapped in a kill-on-close job so the + * child dies with the caller; stdout/stderr in the result are empty. + */ + stdio?: 'pipe' | 'inherit' +} + +export interface AclSandboxChildResult { + stdout: Buffer + stderr: Buffer + exitCode: number +} + +export interface AclSandboxChild { + /** Child process id. */ + pid: number + /** Resolve stdout/stderr and the exit code once the child exits. */ + wait(): Promise +} + +function randomWriteSid(): string { + return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` +} + +function getTempPath(api: Win32Bindings): string { + const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) + const length = api.getTempPathW(buffer.length / 2, buffer) + if (length === 0) throwLastError(api, 'GetTempPathW') + return buffer.subarray(0, length * 2).toString('utf16le') +} + +/** + * One write-restricted sandbox instance: token + orphan-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes whatever was granted + * and throws; `dispose()` revokes all grants and reports every cleanup + * failure. + */ +export class AclSandbox { + readonly writableDirs: string[] + readonly writeSid: string + private readonly tempDirOption: string | null | undefined + private tempDirResolved: string | null | undefined + private api: Win32Bindings | undefined + private token: NativePtr | undefined + private writeSidPtr: NativePtr | undefined + private grantedPaths: string[] = [] + + constructor(options: AclSandboxOptions) { + this.writableDirs = options.writableDirs.map((directory) => { + const absolute = resolve(directory) + if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`) + } + return absolute + }) + this.tempDirOption = options.tempDir + this.writeSid = options.writeSid ?? randomWriteSid() + } + + /** Resolved temp directory (available after init; null when temp grants are disabled). */ + get tempDir(): string | null | undefined { + return this.tempDirResolved + } + + /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + async init(): Promise { + if (this.api !== undefined) throw new Error('AclSandbox is already initialized') + const api = await win32() + + const currentToken = openCurrentProcessToken(api) + try { + const sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + const writeSidPtr = parsedSid + + const tempDir = this.tempDirOption === null + ? null + : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + if (tempDir !== null) { + if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { + throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) + } + this.tempDirResolved = tempDir + } + + for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { + grantWrite(api, path, writeSidPtr) + this.grantedPaths.push(path) + } + const logonSid = findLogonSid(api, currentToken) + const restricted = createRestrictedToken( + api, currentToken, logonSid, writeSidPtr, + { + world: makeWellKnownSid(api, abi.WinWorldSid), + authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), + interactive: makeWellKnownSid(api, abi.WinInteractiveSid), + local: makeWellKnownSid(api, abi.WinLocalSid), + }, + ) + this.token = restricted + if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + this.api = api + } catch (error) { + // Best-effort close on the failure path (last error already captured in `error`). + api.closeHandle(currentToken) + // Fail-closed cleanup: never leave standing grants behind a failed init. + const cleanupFailures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + ) + } + throw error + } + } + + /** + * Spawn a process under the restricted token. Fails closed: throws on every + * Win32 failure; the child is never created unrestricted. With + * `stdio: 'inherit'` the child shares the caller's stdio directly and is + * placed in a kill-on-close job (dies with the caller). Call dispose() only + * after all children have exited — revoking grants under a live child + * removes its remaining write allowance. + */ + spawn(options: AclSandboxSpawnOptions): AclSandboxChild { + const api = this.api + const token = this.token + if (api === undefined || token === undefined) throw new Error('AclSandbox is not initialized: call init() first') + const args = options.args ?? [] + const cwd = options.cwd ?? process.cwd() + + if (options.stdio === 'inherit') { + const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) + let exitCodePromise: Promise | undefined + return { + pid: native.pid, + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, + } + } + + const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise | undefined + return { + pid: native.pid, + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, + } + } + + /** Revoke all standing grants, free the SID, close the token; reports every cleanup failure. */ + dispose(): void { + const api = this.api + if (api === undefined) return + const failures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = api.localFree(writeSidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + } + const token = this.token + if (token !== undefined) { + try { + if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') + } catch (error) { + failures.push(error) + } + } + this.api = undefined + this.token = undefined + this.writeSidPtr = undefined + this.grantedPaths = [] + if (failures.length > 0) { + throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts new file mode 100644 index 0000000000..35ea265a4b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`. + * @module @deepseek-ai/dsh-sandbox-windows-acl/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-windows-acl-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond the fail-closed contracts it enforces at each + * Win32 call boundary. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts new file mode 100644 index 0000000000..02fd416f7f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -0,0 +1,135 @@ +/** + * The windows-acl confinement runner: the argv-prefix wrapper the sandbox + * seam spawns in place of the caller's command. It creates the + * WRITE_RESTRICTED token with the orphan-SID allowlist, spawns the wrapped + * argv under it with the CALLER'S stdio inherited (bytes flow straight + * through), mirrors the child's exit code, and revokes all grants on exit. + * + * Stable argv contract (the seam builds it; a native-exe replacement would + * keep the same contract): + * [node, runner.js, '--workspace', , '--temp', , + * '--mode', , '--', ] + * + * Modes: + * - workspace-write: the workspace and temp directories carry the orphan-SID + * Write grant; every other write is denied by the token intersection. + * - read-only: STRICT zero grants — no directory is writable, not even the + * NUL device (`> $null` fails with access denied); documented in README. + * + * Failure contract: every runner-side failure (bad args, missing + * directories, token/grant/spawn errors) prints `windows-acl-run: ` + * to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that + * signature. The child is NEVER spawned unrestricted. + * @module @deepseek-ai/dsh-sandbox-windows-acl/runner + */ + +import { existsSync, statSync } from 'node:fs' + +import { win32 } from './ffi.ts' +import { AclSandbox } from './index.ts' + +const RUNNER_SIGNATURE = 'windows-acl-run' +const RUNNER_FAILURE_EXIT = 127 + +class RunnerFailure extends Error {} + +/** Print the runner-failure signature line and unwind. */ +function fail(detail: string): never { + process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`) + throw new RunnerFailure(detail) +} + +interface ParsedArgs { + workspace: string + temp: string + mode: 'read-only' | 'workspace-write' + command: string + args: string[] +} + +function parseArgs(raw: string[]): ParsedArgs { + let workspace: string | undefined + let temp: string | undefined + let mode: string | undefined + let index = 0 + for (; index < raw.length; index++) { + const token = raw[index] + if (token === '--') { + index++ + break + } + index++ + const value = raw[index] + if (value === undefined) fail(`missing value after ${token}`) + switch (token) { + case '--workspace': workspace = value; break + case '--temp': temp = value; break + case '--mode': mode = value; break + default: fail(`unknown argument: ${token}`) + } + } + if (workspace === undefined) fail('missing --workspace') + if (temp === undefined) fail('missing --temp') + if (mode !== 'read-only' && mode !== 'workspace-write') fail(`unknown mode: ${String(mode)}`) + const argv = raw.slice(index) + const command = argv[0] + if (command === undefined) fail('missing command after --') + return { workspace, temp, mode, command, args: argv.slice(1) } +} + +function requireDirectory(label: string, path: string): void { + if (!existsSync(path) || !statSync(path).isDirectory()) { + fail(`${label} is not an existing directory: ${path}`) + } +} + +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)) + // Both directories are validated in both modes: a provider bug that passes + // a bogus root must fail loudly at the runner boundary, never mid-child. + requireDirectory('--workspace', parsed.workspace) + requireDirectory('--temp', parsed.temp) + + const api = await win32() + // Ignore this process's own CTRL+C: the confined child (same console) keeps + // handling its own; the runner must survive to revoke grants and mirror the + // child's exit code. + if (api.setConsoleCtrlHandler(null, 1) === 0) { + fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) + } + + const sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + }) + await sandbox.init() + + try { + const child = sandbox.spawn({ + command: parsed.command, + args: parsed.args, + stdio: 'inherit', + }) + const result = await child.wait() + return result.exitCode + } finally { + // Cleanup failures must not mask the child's exit code: report and keep going. + try { + sandbox.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } +} + +main().then( + (exitCode) => { + process.exitCode = exitCode + }, + (error: unknown) => { + if (!(error instanceof RunnerFailure)) { + process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`) + } + process.exitCode = RUNNER_FAILURE_EXIT + }, +) diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts new file mode 100644 index 0000000000..58571257a1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -0,0 +1,296 @@ +/** + * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with + * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then + * asynchronous pipe draining and exit waiting. Console isolation + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this + * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED + * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is + * pipe-based and unaffected; the child shares the host console. + * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn + */ + +import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Quote one argument per the CommandLineToArgvW parsing rules (backslash + * escaping only before quotes; a trailing backslash before the closing quote + * is doubled). + */ +export function quoteArg(argument: string): string { + if (argument === '') return '""' + if (!/[\s"]/u.test(argument)) return argument + let quoted = '"' + for (let index = 0; index < argument.length; index++) { + let backslashes = 0 + while (index < argument.length && argument.charAt(index) === '\\') { + backslashes++ + index++ + } + if (index < argument.length && argument.charAt(index) === '"') { + quoted += '\\'.repeat(backslashes * 2 + 1) + '"' + } else { + quoted += '\\'.repeat(backslashes) + (index < argument.length ? argument.charAt(index) : '') + } + } + return quoted + '"' +} + +/** Build the single command line CreateProcess parses from program + argv. */ +export function buildCommandLine(program: string, args: readonly string[]): string { + return [program, ...args].map(quoteArg).join(' ') +} + +interface PipePair { + read: NativePtr + write: NativePtr +} + +function createPipe(api: Win32Bindings): PipePair { + const readSlot = allocPtrSlot() + const writeSlot = allocPtrSlot() + if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') + const read = decodePtr(readSlot) + const write = decodePtr(writeSlot) + if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle') + return { read, write } +} + +function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', label) + } +} + +export interface SpawnedNative { + pid: number + process: NativePtr + stdoutRead: NativePtr + stderrRead: NativePtr +} + +/** + * Create a process under the restricted token with piped stdio. The child's + * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends + * are returned for draining. + */ +export function spawnSandboxed( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedNative { + const stdIn = createPipe(api) + const stdOut = createPipe(api) + const stdErr = createPipe(api) + // Child side of each pipe must be inheritable (POC lines 262-268). + setInheritable(api, stdIn.read, 'stdin read end') + setInheritable(api, stdOut.write, 'stdout write end') + setInheritable(api, stdErr.write, 'stderr write end') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn.read, + hStdOutput: stdOut.write, + hStdError: stdErr.write, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: required for redirection + 0, // no creation flags: suspended/no-window variants are unusable under the restriction + null, options.cwd, + startupInfo, processInfo, + ) + // Capture the failure before CloseHandle calls clobber GetLastError. + if (created === 0) throwLastError(api, 'CreateProcessAsUserW', `command: ${options.command}, cwd: ${options.cwd}`) + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + // Host-side cleanup: child handles are now duplicated in the child; the + // host closes its copies so ReadFile sees EOF when the child exits. + api.closeHandle(stdIn.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.write) + api.closeHandle(stdIn.write) + api.closeHandle(threadHandle) + + return { + pid: info.dwProcessId, + process: processHandle, + stdoutRead: stdOut.read, + stderrRead: stdErr.read, + } +} + +/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */ +export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { + const chunks: Buffer[] = [] + for (;;) { + const bytesReadSlot = allocUint32() + const totalAvailSlot = allocUint32() + const leftThisMessageSlot = allocUint32() + const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) + if (peeked === 0) { + const win32Code = api.getLastError() + if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF + throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) + } + const available = decodeUint32(totalAvailSlot) + if (available > 0) { + const chunk = Buffer.alloc(available) + const readSlot = allocUint32() + if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) { + throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) + } + chunks.push(chunk.subarray(0, decodeUint32(readSlot))) + } + await new Promise(resolve => setImmediate(resolve)) + } + api.closeHandle(handle) + return Buffer.concat(chunks) +} + +/** + * Wait for process exit and return its exit code. Call only after both drains + * have resolved — the drains finish when the child closed its pipe ends, i.e. + * the child has already exited, so this wait returns immediately. Calling it + * earlier would block the event loop and starve the drains (the pipe-buffer + * deadlock the POC comments warn about). + */ +export function waitForExit(api: Win32Bindings, process: NativePtr): number { + const waitResult = api.waitForSingleObject(process, abi.INFINITE) + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') + const exitCodeSlot = allocUint32() + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + api.closeHandle(process) + return decodeUint32(exitCodeSlot) +} + +/** + * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at + * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout + * verified by abi-probe.cpp). When the caller dies with the job handle open, + * Windows terminates every process in the job — the orphan-child backstop. + * The caller keeps the returned handle open for the child's lifetime. + */ +function createKillOnCloseJob(api: Win32Bindings): NativePtr { + const job = api.createJobObjectW(null, null) + if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') + const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) + information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET) + if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'SetInformationJobObject', win32Code) + } + return job +} + +export interface SpawnedInherited { + pid: number + process: NativePtr + /** Kill-on-close job the child was placed in; caller closes it after the child exits. */ + job: NativePtr +} + +/** + * Create a process under the restricted token whose stdio passes straight + * through to the caller's pipes. This is the runner shape: the harness spawns + * the runner with piped stdio, and the runner's confined child writes to + * those same pipes. + * + * Node clears the inheritability of its stdio handles at startup + * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit + * bit around the call (libuv instead duplicates the handles; re-enabling is + * equivalent here and cheaper) and pass them explicitly via + * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles + * ("The handle is invalid", verified the hard way). The child starts + * suspended so it can be assigned to a kill-on-close job before it runs. + */ +export function spawnSandboxedInherited( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedInherited { + const job = createKillOnCloseJob(api) + const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE) + const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE) + const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE) + if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) { + api.closeHandle(job) + throwLastError(api, 'GetStdHandle', 'null standard handle') + } + + const makeInheritable = (handle: NativePtr, label: string): void => { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) + } + } + const restoreInherit = (handle: NativePtr): void => { + // Best-effort hygiene: the runner spawns nothing else; failures here must + // not mask the child outcome, so the result is deliberately unchecked. + api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } + makeInheritable(stdIn, 'stdin') + makeInheritable(stdOut, 'stdout') + makeInheritable(stdErr, 'stderr') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn, + hStdOutput: stdOut, + hStdError: stdErr, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: the re-enabled std handles must be inheritable + abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution + null, options.cwd, + startupInfo, processInfo, + ) + restoreInherit(stdIn) + restoreInherit(stdOut) + restoreInherit(stdErr) + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + api.closeHandle(job) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + if (api.assignProcessToJobObject(job, processHandle) === 0) { + const win32Code = api.getLastError() + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(threadHandle) === 0xFFFFFFFF) throwLastError(api, 'ResumeThread', `pid ${info.dwProcessId}`) + api.closeHandle(threadHandle) + + return { pid: info.dwProcessId, process: processHandle, job } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts new file mode 100644 index 0000000000..e1c0f2dc2a --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -0,0 +1,138 @@ +/** + * Restricted-token construction: open the current process token, extract its + * logon SID, build the well-known SIDs, and call CreateRestrictedToken with + * the POC's restricting-SID allowlist. Every API call is checked; any failure + * throws with the API name and the exact Win32 code — the original POC ignored + * all of these and silently ran children with the FULL, unrestricted token. + * @module @deepseek-ai/dsh-sandbox-windows-acl/token + */ + +import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Open the current process's access token with the rights + * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token + * handle is obtained through a real OpenProcess handle because the + * GetCurrentProcess() pseudo-handle is not addressable through koffi). + */ +export function openCurrentProcessToken(api: Win32Bindings): NativePtr { + const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) + if (isNullPtr(processHandle)) throwLastError(api, 'OpenProcess', `pid ${process.pid}`) + + const tokenSlot = allocPtrSlot() + const opened = api.openProcessToken( + processHandle, + abi.TOKEN_QUERY | abi.TOKEN_DUPLICATE | abi.TOKEN_ADJUST_DEFAULT | abi.TOKEN_ASSIGN_PRIMARY, + tokenSlot, + ) + if (opened === 0) { + const win32Code = api.getLastError() + api.closeHandle(processHandle) // best-effort on the error path + throwWin32(api, 'OpenProcessToken', win32Code, `pid ${process.pid}`) + } + if (api.closeHandle(processHandle) === 0) throwLastError(api, 'CloseHandle', 'OpenProcess process handle') + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'OpenProcessToken', api.getLastError(), 'null token handle') + return token +} + +/** + * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute + * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and + * other per-logon objects; the POC extracts it the same way. + */ +export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenGroups, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenGroups size query') + if (needed < abi.TOKEN_GROUPS_OFFSET) throwWin32(api, 'GetTokenInformation', api.getLastError(), `implausible TokenGroups size ${needed}`) + + const groups = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenGroups, groups, groups.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenGroups') + } + const groupCount = groups.readUInt32LE(0) + for (let index = 0; index < groupCount; index++) { + const sidPtr = decodePtrAt(groups, abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE) + const attributes = groups.readUInt32LE(abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE + 8) + // >>> 0: JS bitwise & is signed 32-bit; SE_GROUP_LOGON_ID has bit 31 set. + const isLogonId = ((attributes & abi.SE_GROUP_LOGON_ID) >>> 0) === (abi.SE_GROUP_LOGON_ID >>> 0) + if (sidPtr === null || !isLogonId) continue + const sidLength = api.getLengthSid(sidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', `logon SID group ${index}`) + const copy = allocBytes(sidLength) + if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, 'CopySid', `logon SID group ${index}`) + return copy + } + throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) +} + +/** Create one well-known SID (68-byte buffer) and assert its validity. */ +export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { + const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) + const sizeSlot = allocUint32() + encodeUint32(sizeSlot, abi.SECURITY_MAX_SID_SIZE) + if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) { + throwLastError(api, 'CreateWellKnownSid', `type ${type}`) + } + if (api.isValidSid(sid) === 0) throwLastError(api, 'IsValidSid', `CreateWellKnownSid type ${type}`) + return sid +} + +/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ +function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { + const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) + sids.forEach((sid, index) => { + buffer.writeBigUInt64LE(ptrAddress(sid), abi.SID_AND_ATTRIBUTES_SIZE * index) + }) + return buffer +} + +export interface RestrictingSidSet { + world: NativePtr + authUser: NativePtr + interactive: NativePtr + local: NativePtr +} + +/** + * Create the write-restricted token. Ordering matters: EVERYONE first (the + * POC's note — the intersection check hits it on most objects), then the + * logon SID, Authenticated Users, INTERACTIVE, LOCAL, and finally the orphan + * write SID that forms the write allowlist. S-1-2-1 (console logon) is + * intentionally absent: see win32-abi.ts for the verified failure modes. + * FAILS CLOSED: any failure throws — never spawn unrestricted. + */ +export function createRestrictedToken( + api: Win32Bindings, + currentToken: NativePtr, + logonSid: NativePtr, + writeSid: NativePtr, + known: RestrictingSidSet, +): NativePtr { + const restrictingSids = buildRestrictingSids([ + known.world, + logonSid, + known.authUser, + known.interactive, + known.local, + writeSid, + ]) + const tokenSlot = allocPtrSlot() + const created = api.createRestrictedToken( + currentToken, + abi.DISABLE_MAX_PRIVILEGE | abi.LUA_TOKEN | abi.WRITE_RESTRICTED, + 0, null, // no SIDs disabled + 0, null, // no privileges deleted + restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE, + restrictingSids, + tokenSlot, + ) + if (created === 0) throwLastError(api, 'CreateRestrictedToken', `restricting SIDs: ${restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE}`) + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'CreateRestrictedToken', api.getLastError(), 'null token handle') + return token +} diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts new file mode 100644 index 0000000000..97e5322bbf --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -0,0 +1,136 @@ +/** + * Windows ABI constants for the ACL-sandbox backend. + * + * Every value was verified against the actual MinGW Windows headers on this + * machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at + * runtime by verify/abi-probe.cpp (same numbers; static_asserts passed). + * Regenerate the probe with: + * g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe + * + * The port intentionally excludes two pieces of the original POC + * (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified + * empirically on Windows 11 build 26200: + * - S-1-2-1 (console logon SID) in the restricting list: the POC created it + * via CreateWellKnownSid(WinLocalLogonSid) which fails here with + * ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes + * CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the + * correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child + * then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever + * CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used. + * - Console isolation: under this restriction scheme a hidden console is not + * attainable, so children share the host console (stdio redirection is + * pipe-based and unaffected). + * @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi + */ + +// ---- winnt.h --------------------------------------------------------------- + +// TOKEN_* access rights (winnt.h lines ~3928) +export const TOKEN_ASSIGN_PRIMARY = 0x0001 +export const TOKEN_DUPLICATE = 0x0002 +export const TOKEN_QUERY = 0x0008 +export const TOKEN_ADJUST_DEFAULT = 0x0080 + +// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +export const SE_GROUP_LOGON_ID = 0xC0000000 + +// Generic file access (winnt.h lines ~5893-5913): +// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES +// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +export const FILE_GENERIC_WRITE = 0x00120116 +// What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as +// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 + +// CreateRestrictedToken flags (winnt.h lines ~4284) +export const DISABLE_MAX_PRIVILEGE = 0x1 +export const LUA_TOKEN = 0x4 +export const WRITE_RESTRICTED = 0x8 + +// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +export const WinWorldSid = 1 +export const WinLocalSid = 2 +export const WinInteractiveSid = 11 +export const WinAuthenticatedUserSid = 17 + +// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +export const TokenGroups = 2 + +// SECURITY_INFORMATION (winnt.h line ~4293) +export const DACL_SECURITY_INFORMATION = 0x00000004 + +// PROCESS access rights (winnt.h lines ~4364) +export const PROCESS_QUERY_INFORMATION = 0x0400 + +// ---- accctrl.h ------------------------------------------------------------- + +// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +export const SE_FILE_OBJECT = 1 + +// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +export const TRUSTEE_IS_UNKNOWN = 0 +export const TRUSTEE_IS_SID = 0 +export const NO_MULTIPLE_TRUSTEE = 0 + +// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +export const GRANT_ACCESS = 1 +export const REVOKE_ACCESS = 4 + +// grfInheritance (accctrl.h lines ~137-142) +export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + +// ---- winbase.h ------------------------------------------------------------- + +export const STARTF_USESTDHANDLES = 0x00000100 +export const HANDLE_FLAG_INHERIT = 0x1 +export const INFINITE = 0xFFFFFFFF +export const MAX_PATH = 260 +// winbase.h line ~410: the confined child starts suspended so the runner can +// assign it to the kill-on-close job before any of its code runs. +export const CREATE_SUSPENDED = 0x4 +// winbase.h lines ~497-499: GetStdHandle selectors. +export const STD_INPUT_HANDLE = -10 +export const STD_OUTPUT_HANDLE = -11 +export const STD_ERROR_HANDLE = -12 + +// FormatMessageW flags (winbase.h lines ~1446-1469) +export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 + +// ---- error codes ----------------------------------------------------------- + +export const ERROR_SUCCESS = 0 +export const ERROR_INSUFFICIENT_BUFFER = 122 +export const ERROR_BROKEN_PIPE = 109 +export const ERROR_NO_DATA = 232 + +// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- + +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last +// job handle closes — the orphan-child backstop for the runner design. +export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +export const JobObjectExtendedLimitInformation = 9 +// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 +// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION +// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), +// verified by abi-probe. +export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 + +// ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- + +export const SECURITY_MAX_SID_SIZE = 68 +/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ +export const SID_AND_ATTRIBUTES_SIZE = 16 +/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */ +export const TOKEN_GROUPS_OFFSET = 8 +/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */ +export const EXPLICIT_ACCESS_W_SIZE = 48 +/** Trustee offset inside EXPLICIT_ACCESS_W. */ +export const TRUSTEE_W_OFFSET = 16 +/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ +export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +export const STARTUPINFOW_SIZE = 104 +export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts new file mode 100644 index 0000000000..e370316b79 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -0,0 +1,97 @@ +/** + * End-to-end probe of the ACL write-restriction sandbox, using the same + * probes as the POC verification harness: the confined child must be able to + * write into the granted target and temp directories, must be DENIED writing + * anywhere else, and (documented boundary) may still READ outside — the + * WRITE_RESTRICTED token intersects write accesses only. + * + * The escape target sits in its own scratch dir under the system temp + * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never + * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the + * whole real temp tree) and the writable dir is a separate mkdtemp directory + * that contains neither sibling. Nothing under the user profile is touched. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { AclSandbox } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let sandbox: AclSandbox + + beforeAll(async () => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + // tempDir is passed explicitly: GetTempPathW reads the native environment + // block, which host runtimes (vitest worker pools) may not keep in sync + // with process.env — and a real-temp grant would inherit over every + // temp subdirectory, including this test's scratch dir. + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp }) + await sandbox.init() + }) + + afterAll(() => { + sandbox.dispose() + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('allows writes only in granted directories and denies the escape write', async () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const child = sandbox.spawn({ + command: 'pwsh', + args: ['/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe], + cwd: writableDir, + }) + const result = await child.wait() + const output = result.stdout.toString('utf8') + result.stderr.toString('utf8') + + expect(result.exitCode, `child output:\n${output}`).toBe(0) + expect(output, `child output:\n${output}`).toContain('TARGET-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('TEMP-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('ESCAPE-WRITE: DENIED') + // Documented boundary: WRITE_RESTRICTED intersects write accesses only, + // so reads outside the allowlist still succeed. + expect(output, `child output:\n${output}`).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { + // A malformed SID makes ConvertStringSidToSidW fail; init must throw + // before any grant is applied and never spawn unrestricted. + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1' }) + await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts new file mode 100644 index 0000000000..f2737948ca --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -0,0 +1,58 @@ +/** + * The win32 chain's argv contract, denial dialect, and runner-failure rules, + * exercised through the REAL LocalSandboxProvider.confine() with an injected + * platform and runner argv prefix. Platform-independent assertions: they run + * in every CI lane (Windows included, where sandbox-local's own POSIX-only + * suites are excluded) — the end-to-end runner behavior lives in + * runner.spec.ts on win32 hosts. + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' + +const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } +const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + +async function setup(internals: LocalSandboxProvider['internals']) { + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = internals + return sandbox +} + +describe('windows-acl win32 chain (LocalSandboxProvider)', () => { + it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const sandbox = await setup({ + platform: 'win32', + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + probeWindowsAcl, + }) + const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + // A sole candidate is selected unprobed. + expect(probeWindowsAcl).not.toHaveBeenCalled() + }) + + it('read-only: same runner and contract, read-only mode flag', async () => { + const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts new file mode 100644 index 0000000000..f188423425 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -0,0 +1,107 @@ +/** + * End-to-end runner tests: spawn the REAL runner entry through tsx (exactly + * the argv shape dsh-sandbox-local's confine() builds), with piped stdio + * inherited through the runner into the confined child — the same chain a + * production confined execution walks. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const isWin32 = process.platform === 'win32' +const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +function runRunner(args: string[], timeoutMs = 30_000) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', runnerEntry, ...args], { + timeout: timeoutMs, + encoding: 'utf8', + }) +} + +describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + + beforeAll(() => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('workspace-write: the confined child writes granted directories only', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('TARGET-WRITE: OK') + expect(result.stdout).toContain('TEMP-WRITE: OK') + expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + `try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + // The NUL device is a securable object: strict zero grants deny it too. + 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', + // PowerShell's $null redirection discards without opening NUL — must keep working. + 'echo hi > $null;\'DOLLAR-NULL: OK\';', + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('TARGET-WRITE: DENIED') + expect(result.stdout).toContain('TEMP-WRITE: DENIED') + expect(result.stdout).toContain('NUL-WRITE: DENIED') + expect(result.stdout).toContain('DOLLAR-NULL: OK') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) + }, 30_000) + + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { + const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) + expect(result.status).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json new file mode 100644 index 0000000000..e882ed2d72 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sandbox/sandbox-windows-acl/tsdown.config.ts b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts new file mode 100644 index 0000000000..7de4ede1e1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +// The confinement runner builds as its own entry (path-loaded by +// dsh-sandbox-local's win32 chain), inlining the sandbox primitives while +// koffi stays an external native require — the same shape as +// directory-picker-native's worker entry. +export default defineConfig({ + entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', runner: 'lib/types/runner.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp new file mode 100644 index 0000000000..914326acdc --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -0,0 +1,177 @@ +// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows +// headers on this machine. These numbers are the source of truth for the +// koffi FFI definitions in the Node.js port. +#include +#include +#include +#include +#include + +#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr)) + +int wmain() +{ + P(sizeof(void*)); + P(sizeof(HANDLE)); + P(sizeof(DWORD)); + P(sizeof(WORD)); + P(sizeof(BOOL)); + + P(sizeof(STARTUPINFOW)); + P(offsetof(STARTUPINFOW, cb)); + P(offsetof(STARTUPINFOW, lpReserved)); + P(offsetof(STARTUPINFOW, lpDesktop)); + P(offsetof(STARTUPINFOW, lpTitle)); + P(offsetof(STARTUPINFOW, dwX)); + P(offsetof(STARTUPINFOW, dwY)); + P(offsetof(STARTUPINFOW, dwXSize)); + P(offsetof(STARTUPINFOW, dwYSize)); + P(offsetof(STARTUPINFOW, dwXCountChars)); + P(offsetof(STARTUPINFOW, dwYCountChars)); + P(offsetof(STARTUPINFOW, dwFillAttribute)); + P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, wShowWindow)); + P(offsetof(STARTUPINFOW, cbReserved2)); + P(offsetof(STARTUPINFOW, lpReserved2)); + P(offsetof(STARTUPINFOW, hStdInput)); + P(offsetof(STARTUPINFOW, hStdOutput)); + P(offsetof(STARTUPINFOW, hStdError)); + + P(sizeof(PROCESS_INFORMATION)); + P(offsetof(PROCESS_INFORMATION, hProcess)); + P(offsetof(PROCESS_INFORMATION, hThread)); + P(offsetof(PROCESS_INFORMATION, dwProcessId)); + P(offsetof(PROCESS_INFORMATION, dwThreadId)); + + P(sizeof(SECURITY_ATTRIBUTES)); + P(offsetof(SECURITY_ATTRIBUTES, nLength)); + P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor)); + P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle)); + + P(sizeof(TRUSTEE_W)); + P(offsetof(TRUSTEE_W, pMultipleTrustee)); + P(offsetof(TRUSTEE_W, MultipleTrusteeOperation)); + P(offsetof(TRUSTEE_W, TrusteeForm)); + P(offsetof(TRUSTEE_W, TrusteeType)); + P(offsetof(TRUSTEE_W, ptstrName)); + + P(sizeof(EXPLICIT_ACCESS_W)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode)); + P(offsetof(EXPLICIT_ACCESS_W, grfInheritance)); + P(offsetof(EXPLICIT_ACCESS_W, Trustee)); + + P(sizeof(SID_AND_ATTRIBUTES)); + P(offsetof(SID_AND_ATTRIBUTES, Sid)); + P(offsetof(SID_AND_ATTRIBUTES, Attributes)); + + P(sizeof(TOKEN_GROUPS)); + P(offsetof(TOKEN_GROUPS, GroupCount)); + P(offsetof(TOKEN_GROUPS, Groups)); + + P(sizeof(TOKEN_MANDATORY_LABEL)); + + P(sizeof(SID)); + P(SECURITY_MAX_SID_SIZE); + P(SID_MAX_SUB_AUTHORITIES); + P(SID_REVISION); + + P(TOKEN_ASSIGN_PRIMARY); + P(TOKEN_DUPLICATE); + P(TOKEN_QUERY); + P(TOKEN_ADJUST_DEFAULT); + + P(SE_GROUP_LOGON_ID); + P(SE_GROUP_INTEGRITY); + P(SE_GROUP_INTEGRITY_ENABLED); + + P(FILE_GENERIC_WRITE); + P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); + P(STANDARD_RIGHTS_WRITE); + + P(DISABLE_MAX_PRIVILEGE); + P(SANDBOX_INERT); + P(LUA_TOKEN); + P(WRITE_RESTRICTED); + + P((int)WinWorldSid); + P((int)WinLocalSid); + P((int)WinInteractiveSid); + P((int)WinAuthenticatedUserSid); + P((int)WinLocalLogonSid); + P((int)WinConsoleLogonSid); + + P((int)TokenUser); + P((int)TokenGroups); + P((int)TokenIntegrityLevel); + + P((int)SE_FILE_OBJECT); + P(DACL_SECURITY_INFORMATION); + + P((int)TRUSTEE_IS_UNKNOWN); + P((int)TRUSTEE_IS_SID); + P((int)NOT_USED_ACCESS); + P((int)GRANT_ACCESS); + P((int)REVOKE_ACCESS); + P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(OBJECT_INHERIT_ACE); + P(CONTAINER_INHERIT_ACE); + + P(CREATE_SUSPENDED); + P(CREATE_NO_WINDOW); + P(DETACHED_PROCESS); + P(CREATE_NEW_CONSOLE); + P(STARTF_USESTDHANDLES); + P(HANDLE_FLAG_INHERIT); + P(INFINITE); + + P(LMEM_FIXED); + P(LMEM_ZEROINIT); + P(LPTR); + + P(FORMAT_MESSAGE_ALLOCATE_BUFFER); + P(FORMAT_MESSAGE_FROM_SYSTEM); + P(FORMAT_MESSAGE_IGNORE_INSERTS); + P(MAX_PATH); + + P(ERROR_SUCCESS); + P(ERROR_INSUFFICIENT_BUFFER); + P(ERROR_NO_MORE_ITEMS); + P(ERROR_INVALID_PARAMETER); + P(ERROR_INVALID_SID); + P(ERROR_NONE_MAPPED); + P(ERROR_BROKEN_PIPE); + + // Job object (runner kill-on-close hardening) + P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION)); + P(sizeof(IO_COUNTERS)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit)); + P((int)JobObjectExtendedLimitInformation); + P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + + // static assertions for the values the koffi module will hardcode + static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size"); + static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); + static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); + static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); + static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); + static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); + static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); + static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "grant mask"); + static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); + static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); + static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); + static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); + static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); + static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); + static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); + printf("\nstatic_asserts passed\n"); + return 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6bd0e971..b55e1a2bcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -753,6 +753,36 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/pwsh-sandbox: + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../../sandbox/sandbox-windows-acl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/tool-bash: dependencies: schemastery: @@ -4457,6 +4487,9 @@ importers: packages/sandbox/sandbox-local: dependencies: + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../sandbox-windows-acl node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -4502,6 +4535,22 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/sandbox/sandbox-windows-acl: + dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../sandbox-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/sdk/create-sdk: dependencies: '@deepseek-ai/dsh-helper': diff --git a/tsconfig.host.json b/tsconfig.host.json index 79276dfe7e..8e6f3789b7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -164,10 +164,12 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/pwsh-sandbox" }, { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, + { "path": "./packages/sandbox/sandbox-windows-acl" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..4aaf7258e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,16 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +// Windows-only packages: their sources execute exclusively on win32 (koffi +// loads Win32 libraries), so the Linux coverage lane can never cover them. +// The Windows dev/CI lane exercises them through the probe/runner suites; the +// per-file 100% gate must not fail on their Linux-uncovered paths. +const windowsOnlyCoverageExclusions = process.platform !== 'win32' + ? [ + 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + ] + : [] + // Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites // self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -221,6 +231,7 @@ export default defineConfig({ 'packages/session-projection/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, + ...windowsOnlyCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome).