mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(sandbox): spawn confined argv directly (round 1)
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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/bash-sandbox/README.md
|
||||
README.md: 851bedea3589efe164cf49b7e6db1a5f3d64df24
|
||||
README.zh.md: f4eb13dd11f8a22dd0a0d594a615567571b1ccde
|
||||
README.md: c1a6d6ae19f6d5ec95087d32c7d5d18d5f28b7f5
|
||||
README.zh.md: a6bcc812e35c842c7d3880a41814a872f3dbca14
|
||||
|
||||
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never command failures.** Foreground and background execution use the same structured classifier: a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
|
||||
- **Runner failures are sandbox failures, never command failures.** A rejected spawn of the provider argv is out-of-band proof that the confined launch never started: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
@@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the matched fatal stderr line as its detail and preserves the original stderr collection.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A provider-argv spawn rejection supplies the original spawn error as detail; a settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台与后台执行使用同一个结构化分类器:先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** spawn 提供方 argv 遭拒,是受限启动从未开始的带外证据:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。spawn 提供方 argv 遭拒时,以原始 spawn 错误作为详细信息;已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Internal shell-quoting and sandbox-result classification helpers.
|
||||
* Internal sandbox-result classification helpers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
||||
*/
|
||||
@@ -13,15 +13,6 @@ interface RunnerFailureMatch {
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word.
|
||||
* @param text - raw argv element to preserve through the outer shell parse.
|
||||
* @returns the quoted shell word.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's denial dialect.
|
||||
* @param result - settled foreground run.
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -22,7 +23,7 @@ import type {
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
@@ -90,7 +91,14 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
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()
|
||||
throw new SandboxUnavailableError(mode, String(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)
|
||||
@@ -106,7 +114,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const proc = this.startArgv(spec, confined.argv)
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules })
|
||||
return proc
|
||||
@@ -116,12 +124,14 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* 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): void {
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// Runner failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnError !== undefined
|
||||
|| classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
@@ -129,30 +139,19 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr)
|
||||
super.onProcessDone(proc, stderr, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param command - shell source for the confined inner `bash -c`.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(command: string, policy: SandboxPolicy): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureRules: confined.runnerFailureRules,
|
||||
}
|
||||
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Deterministic real-process proofs for runner classification: the real local
|
||||
* provider and sandbox bash executor exercise an outer-shell launch failure
|
||||
* provider and sandbox bash executor exercise direct runner-spawn failures
|
||||
* and a POSIX fake Landlock launcher that prints its notice before exec.
|
||||
*/
|
||||
|
||||
@@ -9,14 +9,18 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
LAUNCHER_FAILURE_EXIT,
|
||||
LAUNCHER_FATAL_PREFIX,
|
||||
PARTIAL_ENFORCEMENT_NOTICE,
|
||||
} from 'node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const FATAL = 'landlock-run: landlock ruleset error: Invalid argument'
|
||||
const FATAL = `${LAUNCHER_FATAL_PREFIX}landlock ruleset error: Invalid argument`
|
||||
|
||||
const contexts: Context[] = []
|
||||
const tempDirs: string[] = []
|
||||
@@ -27,26 +31,26 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
|
||||
async function fakeLauncher(fatal: boolean): Promise<string> {
|
||||
async function fakeLauncher(fatalExit?: number): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
|
||||
tempDirs.push(dir)
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
const fatalBranch = fatal ? `printf '%s\\n' '${FATAL}' >&2\nexit 125\n` : ''
|
||||
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
|
||||
await writeFile(launcher, `#!/bin/sh
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ro|--rw) shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;;
|
||||
*) printf '%s\\n' '${LAUNCHER_FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\\n' '${NOTICE}' >&2
|
||||
printf '%s\\n' '${PARTIAL_ENFORCEMENT_NOTICE}' >&2
|
||||
${fatalBranch}exec "$@"
|
||||
`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
async function setup(fatal = false): Promise<SandboxBashExecutor> {
|
||||
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
@@ -55,7 +59,7 @@ async function setup(fatal = false): Promise<SandboxBashExecutor> {
|
||||
platform: 'linux',
|
||||
probeBwrap: () => false,
|
||||
probeLandlock: () => 'partial',
|
||||
landlockLauncher: await fakeLauncher(fatal),
|
||||
landlockLauncher: await fakeLauncher(fatalExit),
|
||||
}
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
@@ -63,60 +67,96 @@ async function setup(fatal = false): Promise<SandboxBashExecutor> {
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe('partial Landlock runner-failure classification', () => {
|
||||
it.skipIf(process.platform === 'win32')('classifies a genuinely missing configured runner through the outer bash exec rule', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-missing-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const missingRunner = join(dir, 'missing-runner')
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: [missingRunner],
|
||||
runnerFailureSignatures: ['configured-runner: fatal'],
|
||||
})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: [runner],
|
||||
runnerFailureSignatures: ['configured-runner: fatal'],
|
||||
})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
const error = await ctx.bash.run(ctx.bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
describe('partial Landlock runner-failure classification', () => {
|
||||
it.each(['missing', 'unexecutable'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, `${kind}-runner`)
|
||||
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(missingRunner)
|
||||
expect((error as Error).message).toContain(runner)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => {
|
||||
const bash = await setup()
|
||||
for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) {
|
||||
const result = await bash.run(bash.resolve({ command }))
|
||||
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
|
||||
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
|
||||
async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
|
||||
const bash = await setup(exitCode)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n${FATAL}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
|
||||
const bash = await setup(true)
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
|
||||
expect((error as Error).message).not.toContain(NOTICE)
|
||||
expect((error as Error).message).not.toContain(PARTIAL_ENFORCEMENT_NOTICE)
|
||||
})
|
||||
|
||||
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
|
||||
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\nchild: Permission denied\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('applies the same evidence rule to notice-only background exits', async () => {
|
||||
const bash = await setup()
|
||||
for (const command of ['false', 'exit 125']) {
|
||||
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
|
||||
const task = bash.start(bash.resolve({ command }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -125,11 +165,11 @@ describe('partial Landlock runner-failure classification', () => {
|
||||
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE)
|
||||
})
|
||||
|
||||
it('makes a background fatal line outrank denial text after the notice', async () => {
|
||||
const bash = await setup(true)
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({
|
||||
@@ -139,7 +179,7 @@ describe('partial Landlock runner-failure classification', () => {
|
||||
runnerFailed: true,
|
||||
})
|
||||
const output = task.readOutput().delta
|
||||
expect(output).toContain(NOTICE)
|
||||
expect(output).toContain(PARTIAL_ENFORCEMENT_NOTICE)
|
||||
expect(output).toContain(FATAL)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -16,7 +16,7 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
@@ -90,15 +90,49 @@ describe('the provider hand-off', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
|
||||
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
|
||||
// the sentinel only prints if the executor spawned the WRAPPED argv.
|
||||
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
|
||||
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
|
||||
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
|
||||
const hook = join(dir, 'hook.sh')
|
||||
const order = join(dir, 'order.txt')
|
||||
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
|
||||
const runnerScript = [
|
||||
'const { appendFileSync } = require("node:fs");',
|
||||
'const { spawnSync } = require("node:child_process");',
|
||||
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
|
||||
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
|
||||
'process.exit(child.status ?? 125);',
|
||||
].join('')
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [process.execPath, '-e', runnerScript, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: { DSH_ORDER_FILE: order },
|
||||
}))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
@@ -120,9 +154,6 @@ describe('the provider hand-off', () => {
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
|
||||
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail closed', () => {
|
||||
@@ -132,6 +163,14 @@ describe('fail closed', () => {
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('preserves an already-aborted foreground call as cancellation', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller cancelled before spawn')
|
||||
controller.abort(reason)
|
||||
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
@@ -252,19 +291,6 @@ describe('classifyRunnerFailure', () => {
|
||||
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
|
||||
it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => {
|
||||
const rules = [{
|
||||
allowedExitCodes: [126, 127],
|
||||
fatalSignatures: ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'],
|
||||
}]
|
||||
expect(classifyRunnerFailure(127, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)?.detail)
|
||||
.toBe('bash: /Opt/Runners/bwrap: No such file or directory')
|
||||
expect(classifyRunnerFailure(126, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND', rules)?.detail)
|
||||
.toBe('BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND')
|
||||
expect(classifyRunnerFailure(125, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'bash: /other/bwrap: No such file or directory', rules)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
|
||||
@@ -297,6 +323,18 @@ describe('classifyRunnerFailure', () => {
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: ['env', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
|
||||
@@ -324,7 +362,12 @@ describe('background sandbox facts', () => {
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const hook = join(workdir, 'bash-env-hook.sh')
|
||||
const insideProbe = join(workdir, 'hook-ran.txt')
|
||||
const outsideProbe = join(outside, 'escaped.txt')
|
||||
await writeFile(hook, [
|
||||
'printf hook > "$DSH_BASH_ENV_INSIDE"',
|
||||
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
|
||||
'',
|
||||
].join('\n'))
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: {
|
||||
DSH_BASH_ENV_INSIDE: insideProbe,
|
||||
DSH_BASH_ENV_OUTSIDE: outsideProbe,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
|
||||
expect(existsSync(outsideProbe)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
|
||||
Reference in New Issue
Block a user