refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View 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/shell/pwsh-sandbox/README.md
README.md: 533b89b533084843a17153f3f3da39f21d271b04
README.zh.md: 370cc6fd4a3961d6f79c5c8829ac5c42e093c2a5

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pwsh-sandbox
English | [中文](README.zh.md)
Sandbox-consuming PowerShell implementation of the [`ctx.shell` executor seam](../shell/): 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
#### What 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.
#### Token effect
No model-visible text beyond the command's stderr and the tool layer's standard denial surface.
#### KV Cache effect
None directly; the denial surface belongs to the tool layer.
## 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`.
- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning.
- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL.

View File

@@ -0,0 +1,34 @@
# @deepseek-ai/dsh-pwsh-sandbox
[English](README.md) | 中文
沙盒消费型的 [`ctx.shell` 执行器 seam](../shell/) 的 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.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 工具完全一致。
#### Token 影响
除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。
#### KV Cache 影响
无直接影响;拒绝呈现面属于工具层。
## 已知限制与后续工作
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent(智能体)的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权,runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。
- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源;PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。

View File

@@ -0,0 +1,52 @@
{
"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-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/pwsh-sandbox"
},
"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-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-shell": "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-subprocess-local": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

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 { ShellRunResult } from '@deepseek-ai/dsh-shell'
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: ShellRunResult, 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 */

View File

@@ -0,0 +1,189 @@
/**
* 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 layer owns the escalation approval flow
* through `ctx.approval`; this executor reports the sandbox facts the tool
* renders.
* @module @deepseek-ai/dsh-pwsh-sandbox
*/
import { Context } from '@deepseek-ai/cordis'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
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.shell` in place of the local pwsh executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the
* sandbox denial rendering and escalation surface (see the
* pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's
* resolved policy; direct calls fall back to deployment policy.
* `result.sandbox` reports the mode, enforcement, and denial facts the tool
* renders.
*/
/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */
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<ShellProcess, {
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: ShellExecRequest): ShellExecSpec {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
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: ShellRunResult
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: ShellExecSpec): ShellProcess {
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: ShellProcess
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: ShellProcess, 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: ShellExecSpec, policy: SandboxPolicy): ConfinedArgv {
return this.ctx.sandbox.confine(this.argv(spec), policy)
}
}
/* jscpd:ignore-end */
export default SandboxPwshExecutor

View 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 '@deepseek-ai/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 */

View File

@@ -0,0 +1,117 @@
/**
* 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 on ordinary user-owned paths: read-only denies
* writes, workspace-write allows its promised roots while denying escape
* writes, and the partial-enforcement/denial 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 '@deepseek-ai/cordis'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
const isWin32 = process.platform === 'win32'
function pwshAvailable(): boolean {
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
}
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
let scratchRoot!: string
let writableDir!: string
let outsideTempDir!: string
let secretFile!: string
let escapeFile!: string
let executor!: SandboxPwshExecutor
beforeAll(async () => {
// The workspace escape sits under the profile. A separate directory under
// the ambient temp root proves that the root itself is not granted: the
// runner creates its own private child and rewrites TMP/TEMP to it.
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
writableDir = join(scratchRoot, 'writable')
mkdirSync(writableDir)
outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-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(LocalSubprocessRuntime)
await ctx.plugin(SandboxPwshExecutor, {})
executor = ctx.shell as SandboxPwshExecutor
})
afterAll(() => {
rmSync(scratchRoot, { recursive: true, force: true })
rmSync(outsideTempDir, { recursive: true, force: true })
})
it('read-only: ordinary path writes denied, reads fine, partial and 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 '${outsideTempDir}\\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: 'partial' })
// 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: 'partial' })
}, 60_000)
it('workspace-write: workspace and private temp writable, ambient temp and escape denied', 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 (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
`try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-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'};`,
"'TEMP-PATH: ' + $env:TEMP",
].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('AMBIENT-TEMP-WRITE: DENIED')
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(join(outsideTempDir, 'ww-write.txt'))).toBe(false)
expect(existsSync(escapeFile)).toBe(false)
const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim()
expect(privateTemp).toBeDefined()
expect(privateTemp?.startsWith(tmpdir())).toBe(true)
expect(existsSync(privateTemp ?? '')).toBe(false)
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' })
}, 60_000)
})

View File

@@ -0,0 +1,326 @@
/**
* 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 '@deepseek-ai/cordis'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts'
// The same probe pwsh-local's suites and the vitest coverage exemption use:
// spawnSync never throws on a missing binary (it reports status null), and
// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth.
function pwshAvailable(): boolean {
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
}
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 throwingSubprocessRuntime(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 = LocalSubprocessRuntime,
): 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 LocalSubprocessRuntime) {
ctx.subprocess.internals = { spillDir }
}
await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 })
return { executor: ctx.shell 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()
})
it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => {
const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules))
.toEqual({ detail: 'windows-acl-run: missing --workspace' })
})
})
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: '] }],
}), throwingSubprocessRuntime(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, throwingSubprocessRuntime(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: '] }],
}), throwingSubprocessRuntime(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, throwingSubprocessRuntime(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)
})

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": "../../shell/shell"
},
{
"path": "../../shell/pwsh-local"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}