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:
Huanqi Cao
2026-08-08 01:24:33 +08:00
co-authored by Huo Yaoyuan
parent 4c2d20dbf7
commit f64ba40f43
38 changed files with 3230 additions and 94 deletions
+37 -11
View File
@@ -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
+24
View File
@@ -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).
+24
View File
@@ -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`
## 模型体验
### 隔离生效,拒绝以命令失败呈现
模型看到受限命令自身的 stderrWindows 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` 重定向不受影响(后端包有文档)。
+46
View File
@@ -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"
}
}
+120
View File
@@ -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 */
+184
View File
@@ -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 */
+115
View File
@@ -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)
})
+39
View File
@@ -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"
}
]
}
+13 -23
View File
@@ -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()
})
})
+17 -42
View File
@@ -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'
+2 -1
View File
@@ -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"
},
+75 -10
View File
@@ -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;
}
+49
View File
@@ -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':
+2
View File
@@ -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" },
+11
View File
@@ -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).