fix(sandbox): round 5 harden spawn attribution
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: d1f2554776d2e9b3a2a5fc1742c12ec69a5d4a97
|
||||
README.zh.md: 3aab06145c00e9df4bd06628129cf043769ffb49
|
||||
README.md: 0fe8feaa0efda4111e997292a9741fffb6b55635
|
||||
README.zh.md: 43f6a209473839df7cbcfbefe4d11e29ff3de4b0
|
||||
|
||||
@@ -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.** Before a process starts, a spawn rejection is attributed to the runner only when its structured code and an independent lookup both show that the provider's executable is missing or unusable: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. Other spawn rejections retain the local executor's command-start failure semantics. 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.
|
||||
- **Runner failures are sandbox failures, never command failures.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and the error carries Node spawn provenance for provider argv[0] plus an executable-class code. This covers missing, non-executable, and unloadable runners, including a script whose shebang interpreter is unavailable; an invalid or unusable workdir, resource failure, unrelated syscall, or unstructured rejection retains the local executor's command-start failure semantics. Foreground execution and a synchronous background start throw `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous 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). A provider-executable spawn failure supplies the original spawn error as detail; a rejection without runner-specific evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line 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 runner-attributable spawn failure supplies the original spawn error as detail; a rejection without executable-class argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -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 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当 spawn 拒绝的结构化错误码与一次独立查找均表明提供方可执行文件缺失或不可用时,才会将该拒绝归因于 runner:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true` 和 `denied: false`。其他 spawn 拒绝保留本地执行器的命令启动失败语义。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且错误带有 Node 为提供方 argv[0] 报告的 spawn 来源信息和可执行文件类错误码时,才会将拒绝归因于 runner。这样可以识别缺失、不可执行或无法加载的 runner,包括 shebang 解释器不可用的脚本;无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行与同步的后台启动会抛出 `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` 定义。提供方可执行文件的 spawn 失败会以原始 spawn 错误作为详细信息;没有 runner 特定证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 argv[0] 可执行文件类证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -5,17 +5,16 @@
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { delimiter, resolve } from 'node:path'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Spawn codes that can describe an unavailable executable. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])
|
||||
|
||||
/** Whether one resolved path is a regular executable file. */
|
||||
function isExecutableFile(path: string): boolean {
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
@@ -24,34 +23,27 @@ function isExecutableFile(path: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Require positive runner evidence instead of treating every spawn rejection
|
||||
* as sandbox-owned. Node uses the same ENOENT/EACCES channel for unrelated
|
||||
* launch failures, so the provider executable must also be absent or unusable.
|
||||
* Attribute only executable-class failures with Node spawn provenance after
|
||||
* independently ruling out the caller-owned cwd. With a usable cwd, these
|
||||
* codes describe resolution, permissions, or loading of the provider's
|
||||
* argv[0], including a script whose shebang interpreter is unavailable.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the spawn cwd, used to resolve relative executable paths.
|
||||
* @param searchPath - the spawn environment's PATH value.
|
||||
* @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,
|
||||
searchPath: string | undefined,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const code = (error as { code?: unknown }).code
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code) || runnerProgram === undefined) return false
|
||||
|
||||
const isPath = runnerProgram.includes('/') || runnerProgram.includes('\\')
|
||||
const pathEntries = isPath ? [''] : searchPath?.split(delimiter) ?? []
|
||||
if (pathEntries.length === 0) return false
|
||||
return pathEntries.every((entry) => {
|
||||
const candidate = isPath
|
||||
? resolve(workdir, runnerProgram)
|
||||
: resolve(workdir, entry.length > 0 ? entry : '.', runnerProgram)
|
||||
return !isExecutableFile(candidate)
|
||||
})
|
||||
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' || (syscall !== 'spawn' && !syscall.startsWith('spawn '))) return false
|
||||
if (path !== undefined && (typeof path !== 'string' || (path.length > 0 && path !== runnerProgram))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
|
||||
@@ -61,7 +61,6 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
searchPath: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
@@ -100,7 +99,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
} 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, spec.env?.PATH ?? process.env.PATH)) {
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
@@ -118,9 +117,18 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const proc = this.startArgv(spec, confined.argv)
|
||||
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,
|
||||
@@ -128,7 +136,6 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
searchPath: spec.env?.PATH ?? process.env.PATH,
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
@@ -145,7 +152,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
// 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, facts.searchPath)
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
|
||||
@@ -79,11 +79,14 @@ async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecuto
|
||||
}
|
||||
|
||||
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) => {
|
||||
it.each(['missing', 'unexecutable', 'missing-interpreter'] 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 })
|
||||
if (kind === 'missing-interpreter') {
|
||||
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
}
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
|
||||
@@ -34,6 +34,13 @@ const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
|
||||
|
||||
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
|
||||
const RUNNER_FORMS = [
|
||||
['absolute', process.execPath],
|
||||
['bare', 'node'],
|
||||
['relative', './sandbox-runner'],
|
||||
] as const
|
||||
|
||||
/** 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: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
|
||||
@@ -173,10 +180,15 @@ describe('fail closed', () => {
|
||||
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it.each(['read-only', 'danger-full-access'] as const)(
|
||||
'keeps an invalid workdir as an ordinary foreground spawn failure in %s mode',
|
||||
async (mode) => {
|
||||
const { bash } = await setup({ mode })
|
||||
it.each(RUNNER_FORMS)(
|
||||
'keeps an invalid workdir ordinary with the %s provider-runner form',
|
||||
async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
@@ -188,6 +200,63 @@ describe('fail closed', () => {
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
|
||||
const { bash } = await setup({ mode: 'danger-full-access' })
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies synchronous ENOEXEC as runner loading failure in run() and start()', async () => {
|
||||
const runner = join(spillDir, 'malformed-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn', path: runner })
|
||||
})
|
||||
|
||||
await expect(bash.run(bash.resolve({ command: 'true' })))
|
||||
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(bash.resolve({ command: 'true' })))
|
||||
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
|
||||
const runner = './sandbox-runner'
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
const workdir = join(parent, 'missing')
|
||||
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
|
||||
try {
|
||||
let thrown: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true', workdir }))
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBe(failure)
|
||||
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
@@ -290,18 +359,44 @@ describe('classifyDenial', () => {
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
it('requires both an executable-class error and an independently unavailable provider executable', () => {
|
||||
const missingRunner = join(spillDir, 'definitely-missing-runner')
|
||||
const enoent = Object.assign(new Error('spawn failed'), { code: 'ENOENT' })
|
||||
const emfile = Object.assign(new Error('spawn failed'), { code: 'EMFILE' })
|
||||
it.each(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])(
|
||||
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
expect(isRunnerSpawnFailure(enoent, missingRunner, process.cwd(), process.env.PATH)).toBe(true)
|
||||
expect(isRunnerSpawnFailure(enoent, process.execPath, process.cwd(), process.env.PATH)).toBe(false)
|
||||
expect(isRunnerSpawnFailure(enoent, spillDir, process.cwd(), process.env.PATH)).toBe(true)
|
||||
expect(isRunnerSpawnFailure(emfile, missingRunner, process.cwd(), process.env.PATH)).toBe(false)
|
||||
expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, '')).toBe(true)
|
||||
expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, undefined)).toBe(false)
|
||||
expect(isRunnerSpawnFailure(enoent, undefined, spillDir, process.env.PATH)).toBe(false)
|
||||
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
|
||||
const missingWorkdir = join(spillDir, 'missing-workdir')
|
||||
for (const [, runner] of RUNNER_FORMS) {
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
|
||||
}
|
||||
const fileWorkdir = join(spillDir, 'not-a-workdir')
|
||||
writeFileSync(fileWorkdir, '')
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
|
||||
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
|
||||
const missingRunner = join(spillDir, 'definitely-missing-runner')
|
||||
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
|
||||
Object.assign(new Error('spawn failed'), { code, syscall, path })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', undefined), missingRunner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -386,23 +481,30 @@ describe('result facts', () => {
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('keeps an invalid-workdir spawn rejection ordinary and releases accounting', async () => {
|
||||
const { bash } = await setup()
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
|
||||
|
||||
Reference in New Issue
Block a user