mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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 <huoyaoyuan@hotmail.com>
This commit is contained in:
co-authored by
Huo Yaoyuan
parent
4c2d20dbf7
commit
f64ba40f43
@@ -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<BashRunResult> {
|
||||
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<BashRunResult> {
|
||||
// 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 */
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 <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).
|
||||
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <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` 重定向不受影响(后端包有文档)。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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<BashProcess, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
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<BashRunResult> {
|
||||
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
|
||||
@@ -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 */
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string, readonly SelectedRunner['runner'][]> = {
|
||||
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<SelectedRunner['runner'], SandboxEnforcement> =
|
||||
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<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
|
||||
|
||||
@@ -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: <detail>` 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<SelectedRunner['runner'], readonly RunnerFailureRule[]>
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 <dir> --temp <dir> --mode <read-only|workspace-write> -- <argv...>
|
||||
```
|
||||
|
||||
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: <detail>` 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 <dir> /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.
|
||||
@@ -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 <dir> --temp <dir> --mode <read-only|workspace-write> -- <argv...>
|
||||
```
|
||||
|
||||
runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 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 <dir> /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 设计。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof koffi.pointer>
|
||||
|
||||
/** 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<typeof koffi.load>, name: string, result: Ptr | string, args: Array<Ptr | string>): 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<Win32Bindings> {
|
||||
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))
|
||||
}
|
||||
@@ -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<AclSandboxChildResult>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<number> | 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<number> | 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)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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', <dir>, '--temp', <dir>,
|
||||
* '--mode', <read-only|workspace-write>, '--', <argv...>]
|
||||
*
|
||||
* 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: <detail>`
|
||||
* 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<number> {
|
||||
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
|
||||
},
|
||||
)
|
||||
@@ -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<Buffer> {
|
||||
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<void>(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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: '] }])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 <Windows.h>
|
||||
#include <sddl.h>
|
||||
#include <AclAPI.h>
|
||||
#include <cstdio>
|
||||
#include <cstddef>
|
||||
|
||||
#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;
|
||||
}
|
||||
Generated
+49
@@ -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':
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user