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:
@@ -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 */
|
||||
|
||||
|
||||
6
packages/bash/pwsh-sandbox/README.i18n.yaml
Normal file
6
packages/bash/pwsh-sandbox/README.i18n.yaml
Normal file
@@ -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
packages/bash/pwsh-sandbox/README.md
Normal file
24
packages/bash/pwsh-sandbox/README.md
Normal 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
packages/bash/pwsh-sandbox/README.zh.md
Normal file
24
packages/bash/pwsh-sandbox/README.zh.md
Normal 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`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 隔离生效,拒绝以命令失败呈现
|
||||
|
||||
模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`),与 Landlock 授予 `/tmp` 同语义——按运行创建私有临时目录需要 runner 改写环境块,留待后续。
|
||||
- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。
|
||||
46
packages/bash/pwsh-sandbox/package.json
Normal file
46
packages/bash/pwsh-sandbox/package.json
Normal 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
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal 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
packages/bash/pwsh-sandbox/src/index.ts
Normal file
184
packages/bash/pwsh-sandbox/src/index.ts
Normal 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
|
||||
30
packages/bash/pwsh-sandbox/src/invariant.ts
Normal file
30
packages/bash/pwsh-sandbox/src/invariant.ts
Normal file
@@ -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
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
115
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal 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)
|
||||
})
|
||||
320
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
320
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
@@ -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
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user