Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/bash.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/tool-catalog.md # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # packages/README.md # packages/bash/README.md # packages/bash/bash-local/src/index.ts # packages/bash/bash/README.md # packages/bash/bash/package.json # packages/bash/bash/src/index.ts # packages/bash/bash/src/types.ts # packages/bash/bash/tests/service.spec.ts # packages/bash/bash/tsconfig.json # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/bash/tool-bash/tsconfig.json # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/README.md # packages/core/agent-core/package.json # packages/core/agent-core/src/index.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/tools/tests/gen-tool-catalog.spec.ts # packages/hooks/hook-protocol/tests/runner.spec.ts # packages/ui/acp-agent/tests/acp-agent.spec.ts # packages/ui/stdio-agent/tests/stdio-agent.spec.ts # pnpm-lock.yaml # scripts/doc-budgets.manifest.json # scripts/gen-tool-catalog.ts
This commit is contained in:
33
packages/bash/bash-sandbox/README.md
Normal file
33
packages/bash/bash-sandbox/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
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/)) — the model-facing tool layer (`dsh-tool-bash`) is untouched; that swap is exactly what the seams exist for.
|
||||
|
||||
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.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
|
||||
| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
|
||||
| `danger-full-access` | No confinement; the provider is never consulted. Foreground results carry `sandbox: { mode, denied: false }`; background process handles carry no sandbox facts. |
|
||||
|
||||
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 execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`.
|
||||
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **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/).
|
||||
|
||||
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo.
|
||||
41
packages/bash/bash-sandbox/package.json
Normal file
41
packages/bash/bash-sandbox/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
303
packages/bash/bash-sandbox/src/index.ts
Normal file
303
packages/bash/bash-sandbox/src/index.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* `SandboxBashExecutor`: the sandbox-consuming implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Every spawned command is wrapped by
|
||||
* the `ctx.sandbox` provider (`@deepseek-ai/dsh-sandbox`) according to the
|
||||
* configured {@link SandboxMode}: the executor hands the provider the exact
|
||||
* `['bash', '-c', command]` argv it is about to spawn and spawns the wrapped
|
||||
* argv instead. WHICH platform runner confines it — and whether one is
|
||||
* usable at all (the provider fails CLOSED with a structured
|
||||
* `SANDBOX_UNAVAILABLE` error rather than passing the argv through) — is the
|
||||
* provider's concern (`@deepseek-ai/dsh-sandbox-local` first).
|
||||
*
|
||||
* Extends `LocalBashExecutor` so all process mechanics — spawn, process-group
|
||||
* kills, timeout escalation, output collection and spill files, background
|
||||
* tasks, the credential scrub — are the local implementation's, verbatim.
|
||||
* This package adds only the seam consumption and the result facts, which is
|
||||
* exactly the split the capability seam was designed for (a sandboxing
|
||||
* executor replaces `dsh-bash-local` without touching `dsh-tool-bash`, and
|
||||
* swapping the confinement backend never touches this package).
|
||||
*
|
||||
* A failed run whose stderr carries the selected backend's own denial
|
||||
* dialect (the signatures the provider stamps on every wrap) is classified
|
||||
* as a sandbox denial on `BashRunResult.sandbox`, and every confined result
|
||||
* also carries how completely the selected runner enforces the mode
|
||||
* (`sandbox.enforcement`, from the provider's wrap). A failure carrying the
|
||||
* backend's RUNNER-FAILURE signature instead means the sandbox itself broke
|
||||
* and the command never ran: the foreground path re-throws it as the
|
||||
* structured fail-closed `SANDBOX_UNAVAILABLE` error (late twin of the
|
||||
* provider's confine-time throw), a settled background task stamps
|
||||
* `sandbox.runnerFailed` — either way a broken sandbox can never read as a
|
||||
* failing command, and the command never slips through unconfined.
|
||||
*
|
||||
* Deny-only at the seam, escalation at the tool: a denial is a reported FACT
|
||||
* here, and the one-shot user-approved escalated retry of a denied action
|
||||
* (docs/rfc/implemented/feature/2026-07-06-sandbox.md) is driven by
|
||||
* `dsh-tool-bash` through `ctx.approval` — this executor's contribution is the
|
||||
* per-call `sandboxMode` override it honors in {@link resolve}: an escalated
|
||||
* call runs (and classifies, and reports) under ITS granted mode while every
|
||||
* neighboring call keeps its session's standing mode (or the configured
|
||||
* default when that session has no override).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { RunningBash } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
|
||||
* fail-safe default; an example that wants a workspace-writable agent opts in
|
||||
* explicitly). The runner choice is NOT configured here: which platform
|
||||
* backend confines the command is the `ctx.sandbox` provider's config.
|
||||
*/
|
||||
export interface Config extends LocalConfig {
|
||||
/** File-sandbox mode commands run under (default: `read-only`). */
|
||||
mode?: SandboxMode
|
||||
/**
|
||||
* Root directory `workspace-write` mode may write under (default: the
|
||||
* executor's default working directory — `cwd`, else `process.cwd()`).
|
||||
*/
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word (embedded single
|
||||
* quotes become `'\''`), so a wrapped argv element survives the outer
|
||||
* `bash -c` re-parse byte-for-byte.
|
||||
* @param text - the raw argv element to quote.
|
||||
* @returns the single-quoted shell word.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative sandbox-denial classifier: a run counts as denied only when it
|
||||
* FAILED (nonzero exit — a signal kill is not a denial) and its stderr
|
||||
* carries one of the SELECTED BACKEND's own denial signatures — the dialect
|
||||
* the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
|
||||
* `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
|
||||
* under Landlock's EACCES, `Operation not permitted` under Seatbelt's
|
||||
* EPERM). Matching the backend's dialect rather than a cross-backend union
|
||||
* keeps the classifier from claiming denials the active backend never
|
||||
* produces (bare EPERM text under a Linux runner names non-file boundaries —
|
||||
* mount, kill, ptrace — that fail the same way unsandboxed). Text inference
|
||||
* is the fallback signal until a runner provides a structured one (which
|
||||
* wins once it exists); it errs toward NOT claiming a denial, and its known
|
||||
* residual imprecision is non-sandbox text in the active dialect (an ssh
|
||||
* auth failure reads as a denial under Landlock, a refused `kill` under
|
||||
* Seatbelt).
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's denial dialect, case-insensitive
|
||||
* stderr substrings.
|
||||
* @returns whether the run's failure reads as a sandbox denial.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner-failure classifier: a failed run whose stderr carries the SELECTED
|
||||
* BACKEND's own runner-failure signature (`ConfinedArgv.
|
||||
* runnerFailureSignatures`: the runner's error prefix, which also matches
|
||||
* the shell's runner-not-found message) means the SANDBOX itself failed and
|
||||
* the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
|
||||
* error text can contain denial words (an unopenable grant root reports
|
||||
* `Permission denied`) — and surfaced as the fail-closed
|
||||
* `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
|
||||
* on a settled background task. Same conservative-text-inference stance and
|
||||
* residual imprecision as the denial classifier (a failing task that itself
|
||||
* prints the runner's prefix reads as a runner failure).
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's runner-failure signatures,
|
||||
* case-insensitive stderr substrings.
|
||||
* @returns whether the run's failure reads as the runner itself failing.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier core shared by foreground results and settled background
|
||||
* tasks: failed AND signature present. Lowercases BOTH sides — the seam
|
||||
* declares its signatures case-insensitive, and producers compose them from
|
||||
* runtime data of any case (an `argv0` path, `No such file or directory`).
|
||||
*/
|
||||
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()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
|
||||
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
|
||||
* the whole swap — the tool layer is untouched). Its configured mode is the
|
||||
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
|
||||
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
|
||||
* request, while an approved escalation may stamp a strictly wider mode for
|
||||
* one call. The tool's per-agent prompt section states that same effective
|
||||
* mode, and each run's `result.sandbox` reports what actually executed plus
|
||||
* enforcement completeness.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox']
|
||||
|
||||
// The sandbox-specific fields intersect the local executor's Config as an
|
||||
// inline schema call: the config catalog walks `static Config` statically.
|
||||
static override Config: z<Config> = z.intersect([
|
||||
LocalBashExecutor.Config,
|
||||
z.object({
|
||||
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
|
||||
workspaceRoot: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
/**
|
||||
* Per-task facts, keyed by task id from `start()` until the settle stamp
|
||||
* consumes them: the mode the task runs under (per-call — an escalated task
|
||||
* differs from its neighbors) plus its wrap facts. The seam returns facts
|
||||
* PER WRAP — a provider may legally vary enforcement or dialect between
|
||||
* calls — so overlapping background tasks must each classify against their
|
||||
* OWN wrap; a single latest-wrap field would let a later `start()` clobber
|
||||
* an earlier task's facts before it settles. A `danger-full-access` task
|
||||
* has NO entry (nothing confined it), which is what the settle stamp keys
|
||||
* off.
|
||||
*/
|
||||
private readonly processFacts = new Map<BashProcess, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// schemastery (static Config) already filled the defaulted fields — the
|
||||
// cast records that runtime fact (mirrors LocalBashExecutor's config
|
||||
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
|
||||
// fallback chain is real branching.
|
||||
this.mode = config.mode as SandboxMode
|
||||
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the effective mode onto the spec — the request's explicit override
|
||||
* (an approved escalation), else this executor's configured default — so
|
||||
* defaulting stays an explicit resolve step and `run()`/`start()` read the
|
||||
* spec, never the config.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// resolve() always stamps the mode; the cast records that invariant
|
||||
// (mirrors the constructor's config casts).
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial: the sandbox itself failed and the
|
||||
// command NEVER RAN — surface the same structured fail-closed error a
|
||||
// confine-time discovery throws (late detection, same outcome), with
|
||||
// the runner's own first stderr line as the cause. Returning it as a
|
||||
// task result would let a broken sandbox read as a failing command.
|
||||
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
|
||||
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
// Same stamped-by-resolve invariant as run().
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Sandbox facts are stamped at settle time by onProcessDone()
|
||||
// (denial classification runs against the settled task's collected
|
||||
// stderr). The map entry lands synchronously after spawn, strictly
|
||||
// before the earliest possible settle (a process exit reaches us no
|
||||
// sooner than the next tick).
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the sandbox facts BEFORE completion listeners run: the base
|
||||
* executor notifies from inside the task's settle path, so overriding the
|
||||
* notification point is what makes `task.sandbox` visible to `onTaskDone`
|
||||
* consumers and `done` awaiters alike. Each task classifies against the
|
||||
* facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
|
||||
* per-task map here — settle is the entry's end of life): with per-call
|
||||
* escalation, tasks under different modes settle side by side, so keying
|
||||
* anything off the configured default would misreport them. A
|
||||
* `danger-full-access` task has no map entry and carries no facts (nothing
|
||||
* confined it); a signal-killed task (null exit code) is never a denial,
|
||||
* mirroring the foreground classifier.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, running: RunningBash): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
const stderr = running.stderr.readFrom(0).text
|
||||
// Runner failure outranks denial (the command never ran; the runner's
|
||||
// own error text can contain denial words). A settled task has no
|
||||
// error channel left, so the fact IS the surface here — the foreground
|
||||
// path throws instead.
|
||||
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
enforcement: facts.enforcement,
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, running)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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` that `runBash` spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
*/
|
||||
private confine(command: string, mode: ConfinedSandboxMode): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureSignatures: confined.runnerFailureSignatures,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxBashExecutor
|
||||
101
packages/bash/bash-sandbox/tests/bwrap.e2e.ts
Normal file
101
packages/bash/bash-sandbox/tests/bwrap.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof under bwrap: the REAL
|
||||
* `LocalSandboxProvider` (nothing forced — bwrap is the ladder's first rung,
|
||||
* so a passing probe selects it) underneath the REAL `SandboxBashExecutor`,
|
||||
* driven through the executor's public run/start paths. Verifies the WORLD
|
||||
* (files exist or don't) plus the stamped result facts — in particular that
|
||||
* bwrap's EROFS denial text classifies as `denied: true` through the
|
||||
* wrap-carried dialect; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
|
||||
* host that denies unprivileged user namespaces.
|
||||
*
|
||||
* HOME-based dirs on purpose: bwrap's `/tmp` is an ephemeral mount, so only
|
||||
* paths outside it prove the workspace-root boundary.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const bwrapUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and EROFS text classifies as a denial', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
})
|
||||
})
|
||||
100
packages/bash/bash-sandbox/tests/landlock.e2e.ts
Normal file
100
packages/bash/bash-sandbox/tests/landlock.e2e.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
|
||||
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
|
||||
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
|
||||
* facts; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock; the
|
||||
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
const landlockUsable = probe.status === 0
|
||||
/** The kernel's enforcement level from the probe report — stamped facts below must match it. */
|
||||
const enforcement = /partially enforced/.test(probe.stdout ?? '') ? 'partial' : 'full'
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, the result carries denial + enforcement facts', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
})
|
||||
})
|
||||
318
packages/bash/bash-sandbox/tests/sandbox.spec.ts
Normal file
318
packages/bash/bash-sandbox/tests/sandbox.spec.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
|
||||
* `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
|
||||
* policy hand-off, fail-closed propagation, classification, and fact
|
||||
* stamping all deterministic without any real runner; the real-provider
|
||||
* integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
|
||||
* with plain unix permissions (a 0555 directory), which exercises the same
|
||||
* stderr signature the classifier keys on.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
|
||||
/** One recorded provider call: the argv handed over and the policy it rode with. */
|
||||
interface ConfineCall {
|
||||
argv: string[]
|
||||
policy: SandboxPolicy
|
||||
}
|
||||
|
||||
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
|
||||
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
|
||||
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = ['fake-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, runnerFailureSignatures: RUNNER_FAILURE })
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
* per test) and the executor under test on top of it.
|
||||
*/
|
||||
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
|
||||
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(SandboxBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
}
|
||||
|
||||
function output(text: string): CollectedOutput {
|
||||
return { text, truncated: false }
|
||||
}
|
||||
|
||||
function runResult(exitCode: number | null, stderr: string): BashRunResult {
|
||||
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
|
||||
}
|
||||
|
||||
describe('the provider hand-off', () => {
|
||||
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
|
||||
expect(result.stdout.text).toBe('a b c\'d\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(calls).toEqual([{
|
||||
argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
|
||||
policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
|
||||
}])
|
||||
})
|
||||
|
||||
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, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
|
||||
})
|
||||
|
||||
it('an explicit workspaceRoot wins over cwd', async () => {
|
||||
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
|
||||
})
|
||||
|
||||
it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
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', () => {
|
||||
it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
|
||||
const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
|
||||
const spec = bash.resolve({ command: 'echo hi' })
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'danger-full-access' })
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free' }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('start() passes through unwrapped and stamps nothing at settle', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'danger-full-access' })
|
||||
const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(task.readOutput().delta).toContain('free-bg')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(bash.sandboxMode).toBe('read-only')
|
||||
expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
|
||||
})
|
||||
|
||||
it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
|
||||
await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
|
||||
})
|
||||
|
||||
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('overlapping background tasks settle with their OWN modes (an escalated task next to a default one)', async () => {
|
||||
// With per-call policy, tasks under different modes are in flight at
|
||||
// once — anything keyed off the configured default would misreport the
|
||||
// escalated one at its settle stamp.
|
||||
const { bash } = await setup()
|
||||
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
|
||||
const plain = bash.start(bash.resolve({ command: 'true' }))
|
||||
await plain.done
|
||||
await escalated.done
|
||||
expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(task.readOutput().delta).toContain('bg-free')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDenial', () => {
|
||||
it('never classifies a clean exit or a signal kill as a denial', () => {
|
||||
expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
|
||||
expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
|
||||
expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
|
||||
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
|
||||
// Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
|
||||
// it unsandboxed too, and the mode vocabulary governs file effects only —
|
||||
// claiming a file denial here would tell the model the sandbox blocked
|
||||
// something it never governed.
|
||||
expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
|
||||
expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
|
||||
// The same stderr flips meaning with the backend: under Seatbelt, EPERM
|
||||
// text IS how the kernel refuses a governed file write; under bwrap's
|
||||
// EROFS-only dialect, `Permission denied` is ordinary DAC, not the
|
||||
// sandbox — per-wrap signatures are what keep both classifications honest.
|
||||
expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
|
||||
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
|
||||
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
|
||||
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
|
||||
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
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')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
|
||||
// The wrap's runner prefix on a failed run means the SANDBOX broke and
|
||||
// the command never ran — the late twin of the confine-time throw, with
|
||||
// the runner's own first stderr line carried as the cause.
|
||||
const { bash } = await setup()
|
||||
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
|
||||
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
|
||||
})
|
||||
|
||||
it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
|
||||
.rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
})
|
||||
|
||||
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
|
||||
// The seam returns facts PER WRAP — a legal provider may vary them
|
||||
// between calls. The slow task settles AFTER the quick one started, so a
|
||||
// latest-wrap field would classify its denial against the quick task's
|
||||
// dialect (missing it) and stamp the wrong enforcement.
|
||||
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
|
||||
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
|
||||
{ enforcement: 'full', denialSignatures: ['read-only file system'] },
|
||||
]
|
||||
let call = 0
|
||||
const { bash } = await setup({}, (argv) => {
|
||||
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
|
||||
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
|
||||
})
|
||||
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
|
||||
const quick = bash.start(bash.resolve({ command: 'true' }))
|
||||
await quick.done
|
||||
await slow.done
|
||||
expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('a signal-killed task is never a denial (null exit code)', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
|
||||
// Let the stderr land before the kill so the classifier sees the
|
||||
// signature and must still refuse it on the null exit code alone.
|
||||
await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
|
||||
task.kill()
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('disposal kills wrapped background tasks (inherited HMR safety)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 30' }))
|
||||
await ctx.fiber.dispose()
|
||||
expect(task.status).toBe('killed')
|
||||
})
|
||||
})
|
||||
101
packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
Normal file
101
packages/bash/bash-sandbox/tests/seatbelt.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof on macOS: the REAL `LocalSandboxProvider`
|
||||
* (Linux rungs forced off, so `sandbox-exec`/Seatbelt confines) underneath
|
||||
* the REAL `SandboxBashExecutor`, driven through the executor's public
|
||||
* run/start paths. Verifies the WORLD (files exist or don't) plus the
|
||||
* stamped result facts — in particular that Seatbelt's EPERM denial text
|
||||
* classifies as `denied: true` through the wrap-carried dialect; the
|
||||
* backend-only confinement proofs live with `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — every non-macOS host, or
|
||||
* a macOS whose `sandbox-exec` refuses the profile.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const seatbeltUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and EPERM text classifies as a denial', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
// HOME-based dirs on purpose: workspace-write grants /tmp and the
|
||||
// per-user temp dir wholesale, so only paths outside both prove the
|
||||
// workspace-root boundary.
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
})
|
||||
})
|
||||
36
packages/bash/bash-sandbox/tsconfig.json
Normal file
36
packages/bash/bash-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-local"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user