mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(sandbox): the confinement seam and the per-platform native runner chains
ctx.sandbox (dsh-sandbox): confine(argv, policy) returns the argv to spawn instead — wrapped so the process and its children run confined — plus the enforcement completeness and the backend denial/runner-failure dialects; no usable backend throws the fail-closed SANDBOX_UNAVAILABLE. Policy rides per call. dsh-sandbox-local selects by platform and caches the verdict: multi-candidate chains probe FUNCTIONALLY in preference order (Linux: bwrap → the registry-installed node-addon-landlock-run launcher), a sole candidate is selected unprobed (darwin: sandbox-exec/Seatbelt) and fails closed at execution via runnerFailureSignatures; win32 is a reserved empty chain. Profile parity is honest per backend (documented temp-area and ABI differences; enforcement full|partial is a structured result fact). CI: the sandbox-e2e matrix proves real-kernel confinement per rung (bwrap, Landlock per architecture through the registry-installed launcher, Seatbelt), failing on a silent all-skip; the packed-install rehearsal installs the launcher family from the registry and asserts the binary executable apart from kernel enforcement.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# Sandbox CI: the keyless real-kernel confinement proofs. A separate workflow
|
||||
# from ci.yml because the axis is different — these jobs fan out over
|
||||
# OS×runner (kernel capabilities), not node versions. The Landlock launcher
|
||||
# arrives from the registry with `pnpm install` (the npm package family
|
||||
# `node-addon-landlock-run`, built and released from its own repository), so
|
||||
# these legs exercise the true consumer path — nothing is compiled here.
|
||||
name: Sandbox
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Keyless real-kernel sandbox proofs (sandbox RFC § Testing): each ladder
|
||||
# rung is only provable on a host where it enforces, so this job fans out
|
||||
# an OS×runner matrix — bwrap and Landlock on Linux (separate legs: the
|
||||
# Landlock files force the bwrap rung off, so each leg proves exactly one
|
||||
# rung; Landlock twice, once per architecture, each confining through the
|
||||
# registry-installed launcher), Seatbelt on macOS (sandbox-exec ships with
|
||||
# the OS). One node
|
||||
# version only: kernel confinement does not vary by node, and ci.yml's
|
||||
# node matrix already covers the node axis.
|
||||
#
|
||||
# The e2e files self-skip where their runner is absent, so a leg that lost
|
||||
# its runner (no bwrap, kernel without Landlock, macOS without
|
||||
# sandbox-exec) would otherwise pass as a false green — the same trap
|
||||
# e2e.yml's key preflight guards against. Each leg therefore asserts BOTH
|
||||
# its platform files actually ran: `Test Files 2 passed (2)`, no skips.
|
||||
sandbox-e2e:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
runner: bwrap
|
||||
- os: ubuntu-24.04
|
||||
runner: landlock
|
||||
- os: ubuntu-24.04-arm
|
||||
runner: landlock
|
||||
- os: macos-latest
|
||||
runner: seatbelt
|
||||
name: sandbox e2e (${{ matrix.runner }}, ${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The bwrap rung needs bubblewrap on PATH and unprivileged user
|
||||
# namespaces. Ubuntu 24.04 gates the latter behind an AppArmor knob;
|
||||
# lift it best-effort — on images where the knob is absent the
|
||||
# functional probe (and the run-guard below) is the arbiter anyway.
|
||||
- name: Install bubblewrap (unrestrict userns)
|
||||
if: matrix.runner == 'bwrap'
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -yq bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
|
||||
|| echo "apparmor userns knob absent — the functional probe decides"
|
||||
|
||||
# The unit suite runs on ubuntu in `checks`; this is the one darwin leg
|
||||
# in the workflow, so run it here too — the platform-dependent unit
|
||||
# expectations (Seatbelt path canonicalization: /tmp IS /private/tmp)
|
||||
# take their darwin branch only on this runner.
|
||||
- name: Unit tests (darwin parity)
|
||||
if: matrix.runner == 'seatbelt'
|
||||
run: pnpm run test
|
||||
|
||||
- name: Sandbox e2e (real kernel confinement, world-verified)
|
||||
# NO_COLOR: vitest force-enables ANSI color under GITHUB_ACTIONS even
|
||||
# without a TTY, which would thread escape codes through the summary
|
||||
# line the run-guard greps.
|
||||
env:
|
||||
NO_COLOR: 1
|
||||
run: |
|
||||
set -u +e -o pipefail
|
||||
out=$(pnpm exec vitest run --config vitest.e2e.config.ts \
|
||||
packages/sandbox/sandbox-local/tests/${{ matrix.runner }}.e2e.ts \
|
||||
packages/bash/bash-sandbox/tests/${{ matrix.runner }}.e2e.ts 2>&1); status=$?
|
||||
echo "$out"
|
||||
[ "$status" -eq 0 ]
|
||||
# Both platform files must have RUN — a self-skip (runner missing on
|
||||
# the very platform that exists to prove it) is a failure, not a pass.
|
||||
echo "$out" | grep -qE 'Test Files[[:space:]]+2 passed \(2\)'
|
||||
|
||||
# Publish-path rehearsal, Landlock legs only (the pack gates need built
|
||||
# lib/). The e2e packs the workspace closure, installs the tarballs
|
||||
# into a throwaway consumer — npm pulling `node-addon-landlock-run`
|
||||
# and its platform package from the registry, the true consumer path —
|
||||
# and confines through the INSTALLED launcher, asserting it executable
|
||||
# apart (a mode-stripped binary must not masquerade as a non-enforcing
|
||||
# kernel). Same no-silent-skip guard as above.
|
||||
- name: Build packages (lib/ for the pack rehearsal)
|
||||
if: matrix.runner == 'landlock'
|
||||
run: pnpm run build
|
||||
|
||||
- name: Packed-distribution e2e (pack → install → confine)
|
||||
if: matrix.runner == 'landlock'
|
||||
env:
|
||||
NO_COLOR: 1
|
||||
run: |
|
||||
set -u +e -o pipefail
|
||||
out=$(pnpm exec vitest run --config vitest.e2e.config.ts \
|
||||
packages/sandbox/sandbox-local/tests/packed-install.e2e.ts 2>&1); status=$?
|
||||
echo "$out"
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$out" | grep -qE 'Test Files[[:space:]]+1 passed \(1\)'
|
||||
@@ -46,6 +46,9 @@ flowchart LR
|
||||
pkg_bash_local["bash-local"]
|
||||
pkg_hooks_claude["hooks-claude"]
|
||||
pkg_hooks_codex["hooks-codex"]
|
||||
pkg_sandbox["sandbox"]
|
||||
svc_sandbox["ctx.sandbox<br/>Process-sandbox seam"]
|
||||
pkg_sandbox_local["sandbox-local"]
|
||||
pkg_approval["approval"]
|
||||
svc_approval["ctx.approval<br/>Approval seam"]
|
||||
pkg_code_runtime["code-runtime"]
|
||||
@@ -90,6 +93,8 @@ flowchart LR
|
||||
pkg_llm_deepseek --> svc_llm
|
||||
pkg_llm_pi_ai --> svc_llm
|
||||
pkg_llm_replay --> svc_llm
|
||||
pkg_sandbox --> svc_sandbox
|
||||
pkg_sandbox_local --> svc_sandbox
|
||||
pkg_session --> svc_sessions
|
||||
pkg_session_persistence --> svc_sessionPersistence
|
||||
pkg_session_persistence_jsonl --> svc_sessionPersistence
|
||||
@@ -165,6 +170,7 @@ flowchart LR
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |
|
||||
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | - | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
|
||||
| `ctx.approval` | `seam` | [`approval`](../packages/approval/approval) | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
|
||||
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
|
||||
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. |
|
||||
|
||||
@@ -430,6 +430,42 @@ export interface Config {
|
||||
|
||||
Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/repeat-tool-guard/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-sandbox-local`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Override the sandbox runner argv (the bwrap-shaped profile arguments are
|
||||
* appended). A NON-EMPTY argv is the operator's assertion that this runner
|
||||
* exists and FULLY enforces the profile (confinement reports
|
||||
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
|
||||
* — carries both Linux file-denial dialects as its denial signatures) —
|
||||
* the runner chain and its probes are skipped,
|
||||
* and a broken runner fails loudly at spawn time like any missing command.
|
||||
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
|
||||
* built-in platform chains — Linux `bwrap` then the Landlock launcher
|
||||
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
|
||||
* selected without a probe). Used for custom/alternative runners and
|
||||
* for deterministic fake runners in keyless test tiers.
|
||||
*/
|
||||
runnerCommand?: string[]
|
||||
/**
|
||||
* Per-probe timeout in milliseconds for the chain's functional probes
|
||||
* (default: 5000; must be a positive finite number — Node treats a 0
|
||||
* `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A
|
||||
* probe that exceeds it reads as an unusable rung, so a
|
||||
* host slow enough to trip the default — cold NFS mounts, heavily loaded
|
||||
* CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no
|
||||
* config escape. Bounds ONE probe, and the chain walk runs each at most once
|
||||
* per provider lifetime.
|
||||
*/
|
||||
probeTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/sandbox/sandbox-local/src/index.ts:36`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-jsonl`
|
||||
|
||||
Requires: `sessions`
|
||||
@@ -985,6 +1021,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
|
||||
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
|
||||
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
|
||||
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
|
||||
- `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts))
|
||||
|
||||
|
||||
@@ -157,6 +157,22 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `ctx.sandbox` — `SandboxProvider` (abstract seam)
|
||||
|
||||
Abstract process-sandbox service. Subclass, implement confine, and load the subclass as a plugin — it registers as `ctx.sandbox` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every implementation must honor:
|
||||
|
||||
- confine either returns an argv whose runner ENFORCES the policy or fails closed — at `confine` time with SandboxUnavailableError (no backend for this host), or at EXECUTION time by the runner itself refusing to run the command (exiting without exec'ing it, identified by ConfinedArgv.runnerFailureSignatures). A silent unconfined passthrough is never a legal outcome on either path.
|
||||
- Probing exists to ARBITRATE between multiple candidate backends and may be skipped when a platform has exactly one: the sole candidate is selected directly and the runner's exec-time fail-closed refusal carries the safety property. When probing does run, it is functional (actually enforcing a profile, not a version check), at most once per provider lifetime; `confine` itself spawns nothing beyond that one-time probing.
|
||||
- The returned ConfinedArgv.enforcement states the backend's actual completeness for THIS host; `partial` is reported, never silently upgraded to `full`.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
|
||||
```
|
||||
|
||||
Source: [`packages/sandbox/sandbox/src/index.ts:180`](../../packages/sandbox/sandbox/src/index.ts)
|
||||
|
||||
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
|
||||
|
||||
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
@@ -98,6 +98,10 @@ flowchart TD
|
||||
subgraph group_guard["packages/guard"]
|
||||
pkg_repeat_tool_guard["repeat-tool-guard"]
|
||||
end
|
||||
subgraph group_sandbox["packages/sandbox"]
|
||||
pkg_sandbox["sandbox"]
|
||||
pkg_sandbox_local["sandbox-local"]
|
||||
end
|
||||
subgraph group_workflow["packages/workflow"]
|
||||
pkg_tool_workflow["tool-workflow"]
|
||||
pkg_workflow["workflow"]
|
||||
@@ -116,6 +120,7 @@ flowchart TD
|
||||
pkg_fs --> pkg_brand
|
||||
pkg_fs --> pkg_llm
|
||||
pkg_web --> pkg_llm
|
||||
pkg_sandbox --> pkg_llm
|
||||
pkg_agent --> pkg_brand
|
||||
pkg_agent --> pkg_llm
|
||||
pkg_agent --> pkg_session
|
||||
@@ -134,6 +139,8 @@ flowchart TD
|
||||
pkg_session_persistence --> pkg_session
|
||||
pkg_llm_replay --> pkg_llm
|
||||
pkg_llm_replay --> pkg_session
|
||||
pkg_sandbox_local --> pkg_llm
|
||||
pkg_sandbox_local --> pkg_sandbox
|
||||
pkg_compact_basic --> pkg_agent
|
||||
pkg_compact_basic --> pkg_compact
|
||||
pkg_compact_basic --> pkg_llm
|
||||
@@ -288,6 +295,7 @@ flowchart TD
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
@@ -299,6 +307,7 @@ flowchart TD
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
|
||||
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
|
||||
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"exclude": ["duplicates"],
|
||||
"ignoreWorkspaces": ["vendor/*"],
|
||||
"ignoreBinaries": ["bwrap", "sandbox-exec"],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": [
|
||||
@@ -14,6 +15,10 @@
|
||||
],
|
||||
"project": ["scripts/**/*.ts", "examples/**/*.ts"]
|
||||
},
|
||||
"packages/sandbox/sandbox-local": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
},
|
||||
"packages/*/*": {
|
||||
"entry": ["tests/**/*.spec.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Co
|
||||
|
||||
## Hierarchy
|
||||
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
@@ -12,6 +12,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`approval/`](approval/README.md) | One-shot permission decisions | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# sandbox/ — process-sandbox capability family
|
||||
|
||||
The confinement half of the [capability-seam split](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) | `ctx.sandbox` |
|
||||
| `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) |
|
||||
|
||||
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/proposed/feature/2026-07-06-sandbox.md).
|
||||
|
||||
The staged first consumer is the sandboxed bash executor (it hands over the exact `['bash', '-c', command]` argv it is about to spawn). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
|
||||
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-sandbox-local
|
||||
|
||||
Local implementation of the [`@deepseek-ai/dsh-sandbox`](../sandbox/) seam: wraps a caller's argv in a platform confinement runner. Selection is BY PLATFORM, resolved once and cached: each platform names its runner chain, a chain of one is selected directly (probing arbitrates between candidates — a sole candidate leaves nothing to arbitrate), and a chain of several is probed functionally in preference order. Linux: [`bwrap`](https://github.com/containers/bubblewrap) when its probe passes, else the [`landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) Landlock launcher (kernel confinement that needs no userns/mount privileges — see the [sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md) for the prebuilt-binary decision and profile-parity notes); darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed. A platform with no chain means `confine()` FAILS CLOSED with the seam's structured `SANDBOX_UNAVAILABLE` error (win32 today: a reserved, deliberately empty chain awaiting an AppContainer-family runner); an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap's `runnerFailureSignatures` let the consumer classify that as a sandbox failure rather than a task failure. Never a silent unconfined passthrough on any path.
|
||||
|
||||
Policy is per call (`SandboxPolicy`: mode + workspace root); the provider holds only the mechanism and the cached ladder verdict. Every wrap reports the selected runner's `enforcement` (`full`, or `partial` on an older Landlock ABI that governs only a subset of accesses — read from the launcher's `--probe` report line) and its `denialSignatures` — the stderr dialect that rung's kernel speaks on a denied file effect (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt), which stderr-inferring consumers match instead of a cross-runner union. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile: the ladder and probes are skipped (the wrap carries both Linux denial dialects, the mechanism being unknown) — also the deterministic fake-runner seam for keyless test tiers. Its runner-failure dialect is the OUTER shell's argv0-scoped failure shapes (`exec: <argv0>: not found`, `<argv0>: No such file or directory`, `<argv0>: Permission denied`) — the consumer re-joins the wrap through `bash -c 'exec …'`, so a missing or unexecutable configured runner classifies as a sandbox failure (fail closed at execution), never as a failing command or a denial. `probeTimeoutMs` (default 5000) bounds each functional probe, the escape hatch for hosts slow enough that a timed-out probe would otherwise misread as `SANDBOX_UNAVAILABLE`.
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
|
||||
|
||||
The Landlock launcher comes from the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) — an entry package (this package's one runtime dependency) plus per-platform binary packages selected by npm's `os`/`cpu` fields, built and released from [its own repository](https://github.com/deepseek-harness/node-addon-landlock-run). The entry package owns the launcher's CLI contract: `launcherPath()` resolution (a host with no platform package yields a never-existing path whose probe fails exactly like an unenforcing kernel), the functional `probe()`, and `grantArgs()` flag spelling — versioned together with the binary, so probe-report parsing can never drift against it. This provider keeps only the policy side: the mode → grants mapping (`landlockProfileArgs`) and the ladder. The consumer path is rehearsed by `tests/packed-install.e2e.ts`: pack THIS package's closure, install into a throwaway consumer with the launcher family coming from the registry, assert the installed binary executable (a stripped mode bit must not masquerade as a non-enforcing kernel), and confine through it under plain `node`.
|
||||
|
||||
Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, `tests/seatbelt.e2e.ts`), each self-skipping where its runner is absent; CI's `sandbox-e2e` matrix runs all of them against real kernels (bwrap plus one Landlock leg per architecture on Linux, Seatbelt on macOS) and fails on a silent all-skip.
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
```
|
||||
|
||||
The staged first consumer is the sandboxed bash executor.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox-local",
|
||||
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed",
|
||||
"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-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* `LocalSandboxProvider`: the local implementation of the
|
||||
* `@deepseek-ai/dsh-sandbox` seam. Wraps a caller's argv in a platform
|
||||
* confinement runner selected BY PLATFORM: each platform names its runner
|
||||
* chain ({@link PLATFORM_CHAINS}), a chain of one is selected directly (no
|
||||
* probe — there is nothing to arbitrate), and a chain of several is probed
|
||||
* FUNCTIONALLY in preference order (build and enforce a real profile once,
|
||||
* not `--version`), the verdict cached for the provider's lifetime. Linux:
|
||||
* `bwrap`, else the `landlock-run` Landlock launcher (kernel confinement
|
||||
* that needs no userns/mount privileges; distributed as the npm package
|
||||
* family `node-addon-landlock-run` — the decision recorded in
|
||||
* docs/rfc/proposed/feature/2026-07-06-sandbox.md); darwin: macOS
|
||||
* `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed.
|
||||
* When the platform has no chain or no candidate passes,
|
||||
* {@link LocalSandboxProvider.confine} FAILS CLOSED with the seam's
|
||||
* structured `SANDBOX_UNAVAILABLE` error instead of passing the argv
|
||||
* through unconfined; an unusable runner selected WITHOUT a probe fails
|
||||
* closed at execution time instead (it refuses to run the command), which
|
||||
* the wrap's `runnerFailureSignatures` let consumers classify as a sandbox
|
||||
* failure rather than a task failure.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { grantArgs as landlockGrantArgs, LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Override the sandbox runner argv (the bwrap-shaped profile arguments are
|
||||
* appended). A NON-EMPTY argv is the operator's assertion that this runner
|
||||
* exists and FULLY enforces the profile (confinement reports
|
||||
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
|
||||
* — carries both Linux file-denial dialects as its denial signatures) —
|
||||
* the runner chain and its probes are skipped,
|
||||
* and a broken runner fails loudly at spawn time like any missing command.
|
||||
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
|
||||
* built-in platform chains — Linux `bwrap` then the Landlock launcher
|
||||
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
|
||||
* selected without a probe). Used for custom/alternative runners and
|
||||
* for deterministic fake runners in keyless test tiers.
|
||||
*/
|
||||
runnerCommand?: string[]
|
||||
/**
|
||||
* Per-probe timeout in milliseconds for the chain's functional probes
|
||||
* (default: 5000; must be a positive finite number — Node treats a 0
|
||||
* `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A
|
||||
* probe that exceeds it reads as an unusable rung, so a
|
||||
* host slow enough to trip the default — cold NFS mounts, heavily loaded
|
||||
* CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no
|
||||
* config escape. Bounds ONE probe, and the chain walk runs each at most once
|
||||
* per provider lifetime.
|
||||
*/
|
||||
probeTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The `bwrap` profile arguments for one policy. The whole host tree is bound
|
||||
* read-only; a fresh `/dev` keeps `>/dev/null` redirects working and a fresh
|
||||
* `/proc` keeps process-inspecting tools working. `workspace-write`
|
||||
* additionally mounts an ephemeral writable `/tmp` and rebinds the workspace
|
||||
* root read-write (bind order matters: later binds overlay earlier ones).
|
||||
* Deliberately NO `--unshare-pid` (it would break the process-group kill
|
||||
* semantics shell consumers rely on) and NO network unsharing (the seam's
|
||||
* mode vocabulary promises file effects only).
|
||||
* @param policy - the file-effect policy to express as bwrap arguments.
|
||||
* @returns the bwrap profile arguments (before the trailing `--` + argv).
|
||||
*/
|
||||
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
args.push('--tmpfs', '/tmp')
|
||||
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* The `landlock-run` grant arguments for one policy — the bwrap
|
||||
* profile's file-effect semantics expressed as a Landlock allow-list
|
||||
* (Landlock cannot mount, so there are no fresh/ephemeral filesystems). The
|
||||
* whole tree is readable and executable; of `/dev`, ONLY `/dev/null` is
|
||||
* writable — a whole-`/dev` grant would expose real host paths beneath it
|
||||
* (`/dev/shm`, a shared tmpfs) to persistent writes, which `read-only`
|
||||
* promises never happen. bwrap can hand out a fresh ephemeral `/dev`; on the
|
||||
* host's own `/dev` the write grant must be node-by-node, and `>/dev/null`
|
||||
* is the one redirects need. `workspace-write` adds the HOST `/tmp` (shared
|
||||
* and persistent, where bwrap's is ephemeral — the honest difference,
|
||||
* recorded in the sandbox RFC's runner notes) plus the workspace
|
||||
* root read-write. The flag spelling belongs to `node-addon-landlock-run`'s
|
||||
* `grantArgs`; this function owns only the policy → grants mapping.
|
||||
* @param policy - the file-effect policy to express as launcher grants.
|
||||
* @returns the launcher grant arguments (before `--` + argv).
|
||||
*/
|
||||
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const readWrite = ['/dev/null']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
readWrite.push('/tmp', policy.workspaceRoot)
|
||||
}
|
||||
return landlockGrantArgs({ readOnly: ['/'], readWrite })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a granted root to the path the kernel actually sees. Seatbelt path
|
||||
* filters match the CANONICAL path (symlinks resolved), and the roots this
|
||||
* profile grants are symlinked on every macOS: `/tmp` is `/private/tmp` and
|
||||
* the user temp dir lives under `/var` → `/private/var` — an as-spelled
|
||||
* grant would match nothing.
|
||||
*/
|
||||
function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// realpathSync failed: the path (or a prefix) is missing or unreadable.
|
||||
// Grant the spelling as-is — an unresolvable root matches nothing until
|
||||
// it exists, which is the conservative outcome, and inventing a fallback
|
||||
// resolution here would grant a path the caller never named.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote one path as an SBPL string literal (backslashes and double quotes escaped). */
|
||||
function sbplString(path: string): string {
|
||||
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* The `sandbox-exec` arguments for one policy: `-p` plus a Seatbelt (SBPL)
|
||||
* profile with the same file-effect semantics as the other dialects, built
|
||||
* as allow-default → `(deny file-write*)` → write allow-list (later rules
|
||||
* win), so exactly the mode's promised file effects are governed — network
|
||||
* and process visibility stay unrestricted, which is all the seam's mode
|
||||
* vocabulary claims. Of `/dev`, ONLY the `/dev/null` literal is writable
|
||||
* (the same node-not-directory reasoning as the Landlock grant).
|
||||
* `workspace-write` adds the workspace root, the host `/tmp`, and the
|
||||
* per-user darwin temp dir (`os.tmpdir()`, launchd's `TMPDIR`, inherited by
|
||||
* the confined child) — on darwin that directory IS the platform's `/tmp`
|
||||
* for every mkstemp-family tool, so omitting it would deny the mode's
|
||||
* promised temp area. All granted roots are canonicalized because Seatbelt
|
||||
* matches resolved paths ({@link canonicalPath}); duplicates after
|
||||
* resolution collapse.
|
||||
* @param policy - the file-effect policy to express as an SBPL profile.
|
||||
* @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv).
|
||||
*/
|
||||
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
|
||||
if (policy.mode === 'workspace-write') {
|
||||
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
|
||||
}
|
||||
return ['-p', forms.join(' ')]
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional `bwrap` probe: can it actually build the read-only profile on
|
||||
* this host? (`--version` alone would miss a disabled unprivileged user
|
||||
* namespace.) Synchronous by design — it runs once, lazily, before the first
|
||||
* confined wrap, and the chain's verdict is cached for the provider's
|
||||
* lifetime. `timeoutMs` bounds the probe (the `probeTimeoutMs` config).
|
||||
* The Landlock rung needs no such helper: resolution (`launcherPath`) and
|
||||
* the functional probe (`probe`) come from `node-addon-landlock-run`, the
|
||||
* package family that ships the launcher binary itself, so the probe-report
|
||||
* parsing can never drift against the binary.
|
||||
*/
|
||||
function defaultProbeBwrap(timeoutMs: number): boolean {
|
||||
const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
|
||||
timeout: timeoutMs,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional Seatbelt probe: apply the real `read-only` profile through
|
||||
* `sandbox-exec -p` and run `true` under it — exit 0 means the kernel
|
||||
* accepted and enforced the profile (`sandbox-exec` exits non-zero when
|
||||
* `sandbox_init` refuses it). A missing `sandbox-exec` (every non-macOS
|
||||
* host) fails the spawn and probes `unusable`, exactly like the other
|
||||
* rungs' absent binaries. Apple marks the CLI deprecated but ships it on
|
||||
* every macOS; if it ever disappears, this probe is what fails closed.
|
||||
*/
|
||||
function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean {
|
||||
const probe = spawnSync(seatbeltExec, [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
|
||||
timeout: timeoutMs,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/** Test seam: inject probe verdicts / a fake launcher / a platform without real runners. */
|
||||
export interface SandboxInternals {
|
||||
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
|
||||
platform?: string
|
||||
/** Replaces the platform's chain wholesale (walk mechanics — e.g. probing a rung the product chains only reach unprobed). */
|
||||
chain?: readonly SelectedRunner['runner'][]
|
||||
/** Replaces the functional `bwrap` probe (the Linux chain's first rung). */
|
||||
probeBwrap?: () => boolean
|
||||
/** Replaces the functional Landlock launcher probe (the Linux chain's second rung). */
|
||||
probeLandlock?: (launcher: string) => SandboxEnforcement | 'unusable'
|
||||
/** Replaces the functional Seatbelt probe (the darwin chain's sole rung — only consulted if that chain ever grows). */
|
||||
probeSeatbelt?: (seatbeltExec: string) => boolean
|
||||
/** Replaces the resolved `landlock-run` launcher path (a fake launcher script). */
|
||||
landlockLauncher?: string
|
||||
/** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */
|
||||
seatbeltExec?: string
|
||||
}
|
||||
|
||||
/** The chain's verdict: which runner confines, and how completely it enforces. */
|
||||
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement }
|
||||
|
||||
/**
|
||||
* The runner chain per platform — selection is BY PLATFORM first, probes
|
||||
* second: a platform's chain is probed in preference order only when it has
|
||||
* MORE than one candidate (probing arbitrates; it does not re-validate a
|
||||
* choice that has no alternative). A platform with no chain fails closed at
|
||||
* `confine()`. Linux prefers `bwrap` (its mount profile is closest to the
|
||||
* mode vocabulary) over the Landlock launcher; darwin has exactly one
|
||||
* candidate, selected without any probe.
|
||||
*/
|
||||
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
|
||||
linux: ['bwrap', 'landlock'],
|
||||
darwin: ['seatbelt'],
|
||||
// Reserved slot, deliberately empty: Windows support fills it with a
|
||||
// confinement runner (AppContainer / restricted-token family, shipped from
|
||||
// its own repository on the landlock-run template) plus a
|
||||
// SelectedRunner['runner'] union member — the switches' assertNever guards
|
||||
// then walk the implementer to every site. An empty chain fails closed at
|
||||
// confine(), identical to an unlisted platform: reserving the slot never
|
||||
// weakens the fail-closed end.
|
||||
win32: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcement completeness a rung claims when selected WITHOUT a probe (a
|
||||
* chain of one). `bwrap` and Seatbelt govern every promised file effect by
|
||||
* construction, so the claim is a profile fact; `landlock` is listed for the
|
||||
* table's totality but is unreachable unprobed today (the Linux chain has
|
||||
* two rungs, so it is only ever selected through its probe, whose report is
|
||||
* what distinguishes full from per-ABI-partial — and the launcher additionally
|
||||
* self-reports partial enforcement on stderr at every confined run).
|
||||
*/
|
||||
const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = {
|
||||
bwrap: 'full',
|
||||
landlock: 'full',
|
||||
seatbelt: 'full',
|
||||
}
|
||||
|
||||
/**
|
||||
* A probe bound must be a positive finite number: Node treats
|
||||
* `spawnSync({ timeout: 0 })` as NO timeout, so an unvalidated 0 would
|
||||
* silently mean "unbounded" — the opposite of what the field promises.
|
||||
*/
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`sandbox-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The denial dialect each runner's kernel speaks — the case-insensitive
|
||||
* stderr substrings a denied file effect produces under it, carried on every
|
||||
* wrap (the seam's `ConfinedArgv.denialSignatures`). Kernel facts, not
|
||||
* tunables: bwrap denies through its read-only bind mounts (EROFS), Landlock
|
||||
* refuses with EACCES, Seatbelt with EPERM — whose text is also what
|
||||
* non-file EPERM boundaries print, the residual imprecision the consumer's
|
||||
* conservative classifier documents. An operator-configured `runnerCommand`
|
||||
* has an unknown kernel mechanism, so its wraps carry both Linux file-denial
|
||||
* dialects; bare EPERM stays excluded there (it names non-file boundaries
|
||||
* the mode vocabulary does not govern).
|
||||
*/
|
||||
const DENIAL_SIGNATURES = {
|
||||
bwrap: ['read-only file system'],
|
||||
landlock: ['permission denied'],
|
||||
seatbelt: ['operation not permitted'],
|
||||
runnerCommand: ['read-only file system', 'permission denied'],
|
||||
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
|
||||
|
||||
/**
|
||||
* How each runner's OWN failure identifies itself on stderr (the seam's
|
||||
* `ConfinedArgv.runnerFailureSignatures`): every runner prefixes its error
|
||||
* lines with its program name, and the shell's runner-not-found message
|
||||
* carries the same `name: ` shape (`bash: bwrap: command not found`,
|
||||
* `bash: …/bin/landlock-run: No such file or directory`) — so one substring
|
||||
* per runner covers both "runner broke" and "runner missing". Consumers
|
||||
* match these BEFORE the denial dialect: a runner's error text can contain
|
||||
* denial words (an unopenable grant root reports `Permission denied`), and
|
||||
* a runner failure means the command never ran at all.
|
||||
*/
|
||||
const RUNNER_FAILURE_SIGNATURES = {
|
||||
bwrap: ['bwrap: '],
|
||||
landlock: [`${LAUNCHER_BIN}: `],
|
||||
seatbelt: ['sandbox-exec: '],
|
||||
} as const satisfies Record<SelectedRunner['runner'], readonly string[]>
|
||||
|
||||
/**
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
|
||||
* apart from the cached chain verdict — it spawns nothing but the one-time
|
||||
* probes, so there is no disposal work beyond cordis' own.
|
||||
*/
|
||||
export class LocalSandboxProvider extends SandboxProvider {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
static Config: z<Config> = z.object({
|
||||
runnerCommand: z.array(z.string()).default([]),
|
||||
probeTimeoutMs: z.natural().default(5_000),
|
||||
})
|
||||
|
||||
/** Test seam (mirrors the bash executors' `internals`). */
|
||||
internals: SandboxInternals = {}
|
||||
|
||||
private readonly runnerCommand: string[] | undefined
|
||||
private readonly probeTimeoutMs: number
|
||||
/** Cached chain verdict; undefined until the first confined wrap needs it. */
|
||||
private selectedRunner: SelectedRunner | 'unavailable' | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// The schema (static Config) defaults both fields — the casts record
|
||||
// those runtime facts. An empty runnerCommand means "not configured":
|
||||
// use the platform chain.
|
||||
const runner = config.runnerCommand as string[]
|
||||
this.runnerCommand = runner.length > 0 ? runner : undefined
|
||||
this.probeTimeoutMs = config.probeTimeoutMs as number
|
||||
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `argv` in the selected runner's invocation for `policy` — the
|
||||
* configured `runnerCommand` when present (the operator's assertion, no
|
||||
* probe), else the platform chain's runner speaking its own profile
|
||||
* dialect. Every wrap carries the runner's enforcement completeness, its
|
||||
* denial dialect, and its runner-failure signatures.
|
||||
* @param argv - the exact argv the caller is about to spawn.
|
||||
* @param policy - the file-effect policy this execution runs under.
|
||||
* @returns the wrapped argv plus the selected backend's enforcement
|
||||
* completeness, denial signatures, and runner-failure signatures;
|
||||
* throws the fail-closed `SANDBOX_UNAVAILABLE` error when the platform
|
||||
* has no usable runner.
|
||||
*/
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
if (this.runnerCommand !== undefined) {
|
||||
const argv0 = this.runnerCommand[0] as string
|
||||
return {
|
||||
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
|
||||
// The configured runner's own failure dialect is unknown (as is its
|
||||
// kernel mechanism), but the consumer never spawns the wrap directly
|
||||
// — it re-joins it through an outer `bash -c 'exec …'` — so a
|
||||
// missing or unexecutable runner fails with the OUTER shell's
|
||||
// argv0-scoped shapes, and those we do know. Scoping every shape to
|
||||
// argv0 keeps in-command errors out (a bare `exec:`/`Permission
|
||||
// denied` prefix would claim tool output; `exec: <argv0>: not
|
||||
// found` cannot). The residual collision — a command invoking a
|
||||
// file named exactly like the runner and hitting the same errno —
|
||||
// is the classifier's documented conservative-inference trade.
|
||||
runnerFailureSignatures: [
|
||||
`exec: ${argv0}: not found`,
|
||||
`${argv0}: No such file or directory`,
|
||||
`${argv0}: Permission denied`,
|
||||
],
|
||||
}
|
||||
}
|
||||
const selected = this.selectRunner(policy.mode)
|
||||
return {
|
||||
argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv],
|
||||
enforcement: selected.enforcement,
|
||||
denialSignatures: DENIAL_SIGNATURES[selected.runner],
|
||||
runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner],
|
||||
}
|
||||
}
|
||||
|
||||
/** The selected rung's runner invocation (program + profile arguments) for one policy. */
|
||||
private runnerArgv(runner: SelectedRunner['runner'], policy: SandboxPolicy): string[] {
|
||||
switch (runner) {
|
||||
case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)]
|
||||
case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)]
|
||||
case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)]
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which runner confines commands, once, for the provider's
|
||||
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
|
||||
* candidate selected directly, multiple candidates arbitrated by
|
||||
* functional probes in chain order. Fail closed when the platform has no
|
||||
* chain or no candidate passes — the command never runs.
|
||||
*/
|
||||
private selectRunner(mode: ConfinedSandboxMode): SelectedRunner {
|
||||
this.selectedRunner ??= this.chainVerdict()
|
||||
if (this.selectedRunner === 'unavailable') throw new SandboxUnavailableError(mode)
|
||||
return this.selectedRunner
|
||||
}
|
||||
|
||||
/** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */
|
||||
private chainVerdict(): SelectedRunner | 'unavailable' {
|
||||
const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []
|
||||
const [first, ...rest] = chain
|
||||
if (first === undefined) return 'unavailable'
|
||||
// One candidate = nothing to arbitrate: select it without probing. Its
|
||||
// runner fails closed at EXECUTION time if unusable (refuses to run the
|
||||
// command), and the wrap's runnerFailureSignatures let the consumer
|
||||
// classify that as a sandbox failure — never a silent unconfined run,
|
||||
// never a plain task failure.
|
||||
if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] }
|
||||
for (const runner of chain) {
|
||||
const enforcement = this.probeRunner(runner)
|
||||
if (enforcement !== 'unusable') return { runner, enforcement }
|
||||
}
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
/** One rung's functional probe (each at most once, via the chain walk). */
|
||||
private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' {
|
||||
// bwrap's mount profile and Seatbelt's deny-file-write* profile govern
|
||||
// every promised file effect by construction, so their passing probes
|
||||
// are always full enforcement; only the Landlock launcher's probe report
|
||||
// distinguishes full from per-ABI-partial.
|
||||
switch (runner) {
|
||||
case 'bwrap': {
|
||||
const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs))
|
||||
return probe() ? 'full' : 'unusable'
|
||||
}
|
||||
case 'landlock': {
|
||||
const probe = this.internals.probeLandlock ?? (launcher => defaultProbeLandlock(launcher, { timeoutMs: this.probeTimeoutMs }))
|
||||
return probe(this.landlockLauncher())
|
||||
}
|
||||
case 'seatbelt': {
|
||||
const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs))
|
||||
return probe(this.seatbeltExec()) ? 'full' : 'unusable'
|
||||
}
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
}
|
||||
|
||||
/** The Landlock launcher to probe and exec (test seam over the resolved one). */
|
||||
private landlockLauncher(): string {
|
||||
return this.internals.landlockLauncher ?? landlockLauncherPath()
|
||||
}
|
||||
|
||||
/** The `sandbox-exec` executable to probe and exec (test seam over the system one). */
|
||||
private seatbeltExec(): string {
|
||||
return this.internals.seatbeltExec ?? 'sandbox-exec'
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalSandboxProvider
|
||||
@@ -0,0 +1,118 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, rmSync } 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 type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/**
|
||||
* KEYLESS bwrap integration proof for the BACKEND: the REAL `bwrap` confining
|
||||
* REAL processes through `confine()` + a direct spawn of the returned argv.
|
||||
* Nothing is forced off: bwrap is the ladder's FIRST rung, so a passing probe
|
||||
* selects it naturally — the wrap shape assertion pins that. Verifies the
|
||||
* WORLD (files exist or don't) and that the kernel's denial text matches the
|
||||
* dialect the wrap advertises; the through-`ctx.bash` consumer proof lives
|
||||
* with `@deepseek-ai/dsh-bash-sandbox`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
|
||||
* host that denies unprivileged user namespaces (the probe is the same
|
||||
* profile the provider enforces, so skip conditions match runtime exactly).
|
||||
*
|
||||
* Workspaces for the workspace-write tests live under the HOME directory on
|
||||
* purpose: bwrap's `/tmp` is an EPHEMERAL mount (the documented
|
||||
* bwrap-profile difference — pinned by its own test below), so only a
|
||||
* workspace OUTSIDE `/tmp` proves the workspace-root rebind itself.
|
||||
*/
|
||||
|
||||
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[] = []
|
||||
const tempFiles: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
for (const file of tempFiles.splice(0)) rmSync(file, { 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 provider(): Promise<LocalSandboxProvider> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
return ctx.sandbox as LocalSandboxProvider
|
||||
}
|
||||
|
||||
/** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's facts. */
|
||||
function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
|
||||
const confined = sandbox.confine(['bash', '-c', command], policy)
|
||||
const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
|
||||
return { result, confined }
|
||||
}
|
||||
|
||||
describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
|
||||
it('the passing probe selects the bwrap rung naturally — first in the ladder, full enforcement, EROFS dialect', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const confined = sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(confined.argv[0]).toBe('bwrap')
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.denialSignatures).toEqual(['read-only file system'])
|
||||
})
|
||||
|
||||
it('read-only denies a write — the file must NOT exist, and the kernel speaks the advertised dialect', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
// The wrap's denialSignatures must be what the kernel actually prints.
|
||||
expect(result.stderr.toLowerCase()).toContain('read-only file system')
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('read-only keeps the tree readable/executable and the fresh /dev/null writable', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('dev-ok\n')
|
||||
})
|
||||
|
||||
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 sandbox = await provider()
|
||||
|
||||
const inside = runConfined(sandbox, `printf bwrap-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(inside.result.status).toBe(0)
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
|
||||
|
||||
const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(denied.result.status).not.toBe(0)
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write mounts an EPHEMERAL /tmp: the write succeeds inside, the host /tmp stays untouched', async () => {
|
||||
// The documented bwrap-profile difference: Landlock and Seatbelt grant
|
||||
// the HOST temp areas, bwrap swaps in a fresh tmpfs that dies with the
|
||||
// process — the strongest of the three temp semantics.
|
||||
const workdir = await tempDir(homedir())
|
||||
const target = `/tmp/dsh-bwrap-e2e-ephemeral-${process.pid}.txt`
|
||||
tempFiles.push(target)
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, `printf tmp-ok > ${target} && cat ${target}`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('tmp-ok')
|
||||
expect(existsSync(target)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
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 type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/**
|
||||
* KEYLESS Landlock integration proof for the BACKEND: the REAL npm-distributed
|
||||
* `landlock-run` launcher (`node-addon-landlock-run`) confining REAL processes through `confine()` + a direct
|
||||
* spawn of the returned argv, with the bwrap rung forced off so the ladder
|
||||
* lands on the launcher. Verifies the WORLD (files exist or don't), not the
|
||||
* wrapper argv alone; the through-`ctx.bash` consumer proof lives with
|
||||
* `@deepseek-ai/dsh-bash-sandbox`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock (or this
|
||||
* platform has no launcher package — the probe cannot pass then). The
|
||||
* binary itself arrives with `pnpm install`, so absence is not a checkout
|
||||
* state.
|
||||
*
|
||||
* Workspaces live under the HOME directory on purpose: `workspace-write`
|
||||
* grants the host `/tmp` wholesale (the documented Landlock-profile
|
||||
* difference), so only a workspace OUTSIDE `/tmp` proves the workspace-root
|
||||
* grant itself.
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
const landlockUsable = probe.status === 0
|
||||
/** The running kernel's enforcement level, from the launcher's probe report — every wrap below must carry exactly this. */
|
||||
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 provider(): Promise<LocalSandboxProvider> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = { probeBwrap: () => false }
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's enforcement. */
|
||||
function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
|
||||
const confined = sandbox.confine(['bash', '-c', command], policy)
|
||||
const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
|
||||
return { result, enforcement: confined.enforcement }
|
||||
}
|
||||
|
||||
describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement through the bundled launcher', () => {
|
||||
it('read-only denies a write — the file must NOT exist, the wrap reports the probed enforcement', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result, enforcement: wrapped } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(wrapped).toBe(enforcement)
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('read-only keeps the tree readable/executable and /dev/null writable', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('dev-ok\n')
|
||||
})
|
||||
|
||||
it('read-only denies a write beneath the host /dev (the /dev/shm tmpfs must stay untouched)', async () => {
|
||||
// The grant is /dev/null the FILE, not /dev the directory: /dev/shm is a
|
||||
// world-writable host tmpfs, and a write landing there would be exactly
|
||||
// the persistent host effect read-only promises never happen.
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const target = `/dev/shm/dsh-landlock-e2e-${process.pid}`
|
||||
const { result } = runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(existsSync(target)).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 sandbox = await provider()
|
||||
|
||||
const inside = runConfined(sandbox, `printf landlock-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(inside.result.status).toBe(0)
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
|
||||
|
||||
const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(denied.result.status).not.toBe(0)
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write grants the host /tmp (the documented Landlock-profile difference)', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const scratch = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, `printf tmp-ok > ${scratch}/scratch.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(readFileSync(join(scratch, 'scratch.txt'), 'utf8')).toBe('tmp-ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* LocalSandboxProvider tests. No real runner is assumed to exist on the test
|
||||
* host: `runnerCommand` injects deterministic runner argvs, and `internals`
|
||||
* injects probe verdicts plus fake Landlock launcher / `sandbox-exec`
|
||||
* scripts, so profile dialects, ladder selection, verdict caching,
|
||||
* probe-report parsing, per-rung denial signatures, and fail-closed behavior
|
||||
* are all exercised through the real `confine()` path.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import {
|
||||
bwrapProfileArgs,
|
||||
landlockProfileArgs,
|
||||
LocalSandboxProvider,
|
||||
seatbeltProfileArgs,
|
||||
} from '@deepseek-ai/dsh-sandbox-local'
|
||||
import type { Config } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
|
||||
async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, config)
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = internals
|
||||
return { ctx, sandbox }
|
||||
}
|
||||
|
||||
/** Write an executable fake `landlock-run` that answers `--probe` with `report`. */
|
||||
function fakeLauncher(report = 'landlock: fully enforced'): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit 125\n`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
/** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */
|
||||
function fakeSeatbeltExec(status: number): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-'))
|
||||
const exec = join(dir, 'sandbox-exec')
|
||||
writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 })
|
||||
return exec
|
||||
}
|
||||
|
||||
/** The seatbelt read-only profile — every seatbelt profile starts with these forms. */
|
||||
const SEATBELT_RO_PROFILE = '(version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))'
|
||||
|
||||
describe('profile dialects', () => {
|
||||
it('bwrap read-only: whole tree read-only with fresh /dev and /proc, no writable mounts', () => {
|
||||
expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'])
|
||||
})
|
||||
|
||||
it('bwrap workspace-write: adds an ephemeral /tmp and rebinds the workspace root', () => {
|
||||
expect(bwrapProfileArgs(WW)).toEqual([
|
||||
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent',
|
||||
'--tmpfs', '/tmp', '--bind', '/ws', '/ws',
|
||||
])
|
||||
})
|
||||
|
||||
it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => {
|
||||
// /dev/null specifically, NOT /dev: a whole-/dev grant would let confined
|
||||
// commands write real host paths beneath it (/dev/shm) under read-only.
|
||||
expect(landlockProfileArgs(RO)).toEqual(['--ro', '/', '--rw', '/dev/null'])
|
||||
})
|
||||
|
||||
it('landlock workspace-write: adds the host /tmp and the workspace root', () => {
|
||||
expect(landlockProfileArgs(WW)).toEqual(['--ro', '/', '--rw', '/dev/null', '--rw', '/tmp', '--rw', '/ws'])
|
||||
})
|
||||
|
||||
it('seatbelt read-only: allow-default with every file write denied except the /dev/null literal', () => {
|
||||
expect(seatbeltProfileArgs(RO)).toEqual(['-p', SEATBELT_RO_PROFILE])
|
||||
})
|
||||
|
||||
it('seatbelt workspace-write: one more allow for the canonicalized workspace root, /tmp, and the user temp dir', () => {
|
||||
// `/ws` does not exist, so it is granted as spelled (the canonicalization
|
||||
// fallback); `/tmp` and `os.tmpdir()` exist everywhere and are granted
|
||||
// CANONICALIZED — Seatbelt matches resolved paths (`/tmp` IS
|
||||
// `/private/tmp` on macOS), and both collapse to one grant on hosts
|
||||
// where they resolve to the same directory.
|
||||
const roots = [...new Set(['/ws', realpathSync('/tmp'), realpathSync(tmpdir())])]
|
||||
const allow = `(allow file-write* ${roots.map(root => `(subpath "${root}")`).join(' ')})`
|
||||
expect(seatbeltProfileArgs(WW)).toEqual(['-p', `${SEATBELT_RO_PROFILE} ${allow}`])
|
||||
})
|
||||
|
||||
it('seatbelt workspace-write dedups a workspace root that already IS the temp dir', () => {
|
||||
const profile = seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: tmpdir() })[1] as string
|
||||
const grant = `(subpath "${realpathSync(tmpdir())}")`
|
||||
expect(profile).toContain(grant)
|
||||
expect(profile.split(grant)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runnerCommand config', () => {
|
||||
it('a non-empty runnerCommand skips the chain: runner argv + bwrap-shaped profile + -- + caller argv, asserted full', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const probeLandlock = vi.fn(() => 'unusable' as const)
|
||||
const probeSeatbelt = vi.fn(() => false)
|
||||
const { sandbox } = await setup({ runnerCommand: ['fake-runner', '--flag'] }, { probeBwrap, probeLandlock, probeSeatbelt })
|
||||
const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
|
||||
expect(confined).toEqual({
|
||||
argv: ['fake-runner', '--flag', ...bwrapProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
// An operator runner's kernel mechanism is unknown: both Linux
|
||||
// file-denial dialects, never bare EPERM.
|
||||
denialSignatures: ['read-only file system', 'permission denied'],
|
||||
// The runner's own dialect is unknown, but the consumer re-joins the
|
||||
// wrap through an outer `bash -c 'exec …'` — a missing or
|
||||
// unexecutable runner fails with the OUTER shell's argv0-scoped
|
||||
// shapes, and those classify as sandbox failures like any rung.
|
||||
runnerFailureSignatures: [
|
||||
'exec: fake-runner: not found',
|
||||
'fake-runner: No such file or directory',
|
||||
'fake-runner: Permission denied',
|
||||
],
|
||||
})
|
||||
expect(probeBwrap).not.toHaveBeenCalled()
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an EMPTY runnerCommand means unconfigured: the platform chain still gates the wrap', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const { sandbox } = await setup({ runnerCommand: [] }, { platform: 'linux', probeBwrap, probeLandlock: () => 'unusable' })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the platform chains', () => {
|
||||
it('linux probes bwrap first: a passing probe wraps with the bwrap dialect at full enforcement', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
const probeLandlock = vi.fn(() => 'full' as const)
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined).toEqual({
|
||||
argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['read-only file system'],
|
||||
runnerFailureSignatures: ['bwrap: '],
|
||||
})
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('linux falls back to the launcher when the bwrap probe fails, speaking the landlock dialect', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const probeLandlock = vi.fn(() => 'full' as const)
|
||||
const launcher = fakeLauncher()
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock, landlockLauncher: launcher })
|
||||
const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
|
||||
expect(confined).toEqual({
|
||||
argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['permission denied'],
|
||||
runnerFailureSignatures: ['landlock-run: '],
|
||||
})
|
||||
expect(probeLandlock).toHaveBeenCalledWith(launcher)
|
||||
})
|
||||
|
||||
it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => {
|
||||
// The safety property moves to execution time: an unusable sandbox-exec
|
||||
// refuses to run the command, and the wrap's runnerFailureSignatures let
|
||||
// the consumer classify that as a sandbox failure, not a task failure.
|
||||
const probeSeatbelt = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt })
|
||||
const confined = sandbox.confine(['bash', '-c', 'echo hi'], RO)
|
||||
expect(confined).toEqual({
|
||||
argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['operation not permitted'],
|
||||
runnerFailureSignatures: ['sandbox-exec: '],
|
||||
})
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a platform with no chain fails closed without a single probe: the command never runs', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
const probeLandlock = vi.fn(() => 'full' as const)
|
||||
const probeSeatbelt = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'freebsd', probeBwrap, probeLandlock, probeSeatbelt })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
expect(probeBwrap).not.toHaveBeenCalled()
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => {
|
||||
// The slot exists so Windows support is an additive fill-in (chain entry
|
||||
// + runner union member), never a redesign — and reserving it must not
|
||||
// weaken the fail-closed end in the meantime.
|
||||
const { sandbox } = await setup({}, { platform: 'win32' })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap })
|
||||
sandbox.confine(['true'], RO)
|
||||
sandbox.confine(['true'], WW)
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the unavailable verdict is cached too, and the error is structured', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const probeLandlock = vi.fn(() => 'unusable' as const)
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
expect(probeLandlock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a multi-rung chain probes a seatbelt rung like any other (the walk, not the platform table, decides)', async () => {
|
||||
// The product chains reach seatbelt only as darwin's sole (unprobed)
|
||||
// candidate; the chain seam exercises the probing path it would take in
|
||||
// a grown chain, keeping the default seatbelt probe honest.
|
||||
const exec = fakeSeatbeltExec(0)
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap, seatbeltExec: exec })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv[0]).toBe(exec)
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a rogue chain entry throws via the probe walk\'s exhaustiveness guard (closed union)', async () => {
|
||||
// Same convention as the wrap switch below: the union is closed, so a
|
||||
// runner added later fails to compile at the probe switch instead of
|
||||
// silently selecting without a probe. Only a cast can reach the guard.
|
||||
const { sandbox } = await setup({}, { chain: ['chroot', 'bwrap'] as unknown as readonly ['bwrap'] })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
|
||||
})
|
||||
|
||||
it('a rogue cached runner tag throws via the exhaustiveness guard (closed union)', async () => {
|
||||
// The wrap switches on the chain verdict's runner tag and ends with
|
||||
// assertNever: a rogue tag (only reachable by a cast — the union is
|
||||
// closed and chainVerdict writes only its own literals) must throw, so a
|
||||
// runner added later fails to compile at the switch instead of silently
|
||||
// wrapping with another runner's dialect.
|
||||
const { sandbox } = await setup()
|
||||
;(sandbox as unknown as { selectedRunner: unknown }).selectedRunner = { runner: 'chroot', enforcement: 'full' }
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
|
||||
})
|
||||
|
||||
it('runs the real default probes on the linux chain when none are injected (usable here or fail closed there)', async () => {
|
||||
// Pinning the platform (not the probes) makes the REAL defaultProbeBwrap
|
||||
// spawn run on every host: bwrap answers on a Linux box, ENOENT reads as
|
||||
// an unusable rung anywhere else — either way the walk is genuine.
|
||||
const { sandbox } = await setup({}, { platform: 'linux' })
|
||||
const verdict = (() => {
|
||||
try {
|
||||
sandbox.confine(['true'], RO)
|
||||
return 'usable'
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SandboxUnavailableError) return 'unavailable'
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
expect(['usable', 'unavailable']).toContain(verdict)
|
||||
})
|
||||
|
||||
it('walks the real platform chain when nothing is injected (usable here or fail closed there)', async () => {
|
||||
const { sandbox } = await setup({}, {})
|
||||
const verdict = (() => {
|
||||
try {
|
||||
sandbox.confine(['true'], RO)
|
||||
return 'usable'
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SandboxUnavailableError) return 'unavailable'
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
expect(['usable', 'unavailable']).toContain(verdict)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default landlock probe (launcher CLI contract)', () => {
|
||||
it('parses a fully-enforced probe report as full enforcement', async () => {
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: fakeLauncher() })
|
||||
expect(sandbox.confine(['true'], RO).enforcement).toBe('full')
|
||||
})
|
||||
|
||||
it('parses a partially-enforced (older-ABI) probe report as partial enforcement', async () => {
|
||||
const launcher = fakeLauncher('landlock: partially enforced (older ABI)')
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(sandbox.confine(['true'], RO).enforcement).toBe('partial')
|
||||
})
|
||||
|
||||
it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, '#!/bin/sh\nexit 125\n', { mode: 0o755 })
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeTimeoutMs config', () => {
|
||||
it('rejects 0 at construction: Node treats a 0 spawnSync timeout as UNBOUNDED, the opposite of the field', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(LocalSandboxProvider, { probeTimeoutMs: 0 }))
|
||||
.rejects.toThrow(/probeTimeoutMs must be a positive finite number/)
|
||||
})
|
||||
|
||||
it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => {
|
||||
// The same sleeping launcher passes under the default 5000ms budget and
|
||||
// fails under a 250ms one — the config demonstrably reaches spawnSync.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
|
||||
|
||||
const patient = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
|
||||
|
||||
const impatient = await setup(
|
||||
{ probeTimeoutMs: 250 },
|
||||
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
|
||||
)
|
||||
expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default seatbelt probe (sandbox-exec contract)', () => {
|
||||
// The product chains reach seatbelt only unprobed (darwin's sole
|
||||
// candidate), so the default probe's contract is pinned through the chain
|
||||
// seam: a grown chain must probe it like any other rung.
|
||||
it('selects the rung when the executable applies the read-only profile and exits 0', async () => {
|
||||
const exec = fakeSeatbeltExec(0)
|
||||
const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap: () => false, seatbeltExec: exec })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined).toEqual({
|
||||
argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['operation not permitted'],
|
||||
runnerFailureSignatures: ['sandbox-exec: '],
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a failing executable as unusable: the chain ends and fails closed', async () => {
|
||||
const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap: () => false, seatbeltExec: fakeSeatbeltExec(1) })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { accessSync, constants, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* KEYLESS publish-path rehearsal for this package's own distribution: the
|
||||
* provider must work from its PACKED tarball plus its REGISTRY launcher
|
||||
* dependency, not the git checkout. `pnpm pack` produces the EXACT bytes
|
||||
* `pnpm publish` would upload; this suite packs the workspace closure
|
||||
* (`dsh-sandbox-local` + its `@deepseek-ai` peers), installs the tarballs
|
||||
* into a throwaway consumer OUTSIDE the repo — npm resolving the
|
||||
* `node-addon-landlock-run` dependency (and its os/cpu-selected platform
|
||||
* package) from the public registry, the real consumer path — and drives
|
||||
* the INSTALLED packages under plain `node`: no tsx, no tsconfig paths, no
|
||||
* workspace resolution, so a `files`-list omission, a broken launcher
|
||||
* dependency, or a mode-stripped binary fails here instead of at the first
|
||||
* real install.
|
||||
*
|
||||
* World-proofs: the registry-installed launcher carries this host's ELF
|
||||
* architecture and IS executable (a tarball that loses the mode bit would
|
||||
* otherwise masquerade as a non-enforcing kernel — the fail-closed branch
|
||||
* below must never absorb that), and the installed provider confines a real
|
||||
* process THROUGH it (bwrap forced off) — or fails closed when the running
|
||||
* kernel does not enforce Landlock, which is itself the installed
|
||||
* fail-closed contract. Byte provenance of the launcher is the
|
||||
* `node-addon-landlock-run` repository's own release-pipeline concern.
|
||||
*
|
||||
* Self-skips off Linux or when the built `lib/` is absent (run
|
||||
* `pnpm run build` first — CI's landlock legs do).
|
||||
*/
|
||||
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
|
||||
/** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */
|
||||
const WORKSPACE_CLOSURE = [
|
||||
'packages/sandbox/sandbox-local',
|
||||
'packages/sandbox/sandbox',
|
||||
'packages/llm/llm',
|
||||
'packages/util/brand',
|
||||
]
|
||||
|
||||
/** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */
|
||||
const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64']
|
||||
|
||||
const packable = process.platform === 'linux'
|
||||
&& E_MACHINE !== undefined
|
||||
&& existsSync(join(packageDir, 'lib', 'index.js'))
|
||||
|
||||
let consumerDir = ''
|
||||
let workDir = ''
|
||||
/** The consumer script's JSON verdict (see its source below). */
|
||||
let verdict: {
|
||||
launcher: string
|
||||
launcherExists: boolean
|
||||
enforcing: boolean
|
||||
wrapArgv0?: string
|
||||
enforcement?: string
|
||||
exitCode?: number | null
|
||||
stderrHasDialect?: boolean
|
||||
confineOutcome?: string
|
||||
} = { launcher: '', launcherExists: false, enforcing: false }
|
||||
|
||||
describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-path rehearsal)', () => {
|
||||
beforeAll(async () => {
|
||||
const packDest = mkdtempSync(join(tmpdir(), 'dsh-pack-'))
|
||||
consumerDir = mkdtempSync(join(tmpdir(), 'dsh-packed-consumer-'))
|
||||
workDir = mkdtempSync(join(tmpdir(), 'dsh-packed-work-'))
|
||||
|
||||
// Pack each closure member with the exact bytes publish would upload.
|
||||
const tarballs: string[] = []
|
||||
for (const pkg of WORKSPACE_CLOSURE) {
|
||||
const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], {
|
||||
cwd: join(repoRoot, pkg),
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
})
|
||||
expect(pack.status, `pnpm pack failed for ${pkg}:\n${pack.stdout}\n${pack.stderr}`).toBe(0)
|
||||
const lines = pack.stdout.trim().split('\n')
|
||||
tarballs.push(lines[lines.length - 1] as string)
|
||||
}
|
||||
|
||||
// A real consumer: plain ESM project, tarballs installed by npm — the
|
||||
// peer ranges (^0.0.1) resolve to the tarball versions, cordis pins to
|
||||
// the peer range's rc, and `node-addon-landlock-run` (with its
|
||||
// os/cpu-selected platform package, an OPTIONAL dependency of the entry
|
||||
// — so no `--omit=optional` here) comes from the public registry.
|
||||
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
|
||||
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], {
|
||||
cwd: consumerDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
})
|
||||
expect(install.status, `npm install failed:\n${install.stdout}\n${install.stderr}`).toBe(0)
|
||||
|
||||
// The consumer script runs under PLAIN node against the installed
|
||||
// packages and reports a JSON verdict; every assertion happens back in
|
||||
// the test. bwrap is forced off so the wrap must select the INSTALLED
|
||||
// launcher; a non-enforcing kernel must surface the fail-closed error.
|
||||
writeFileSync(join(consumerDir, 'consumer.mjs'), `
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox
|
||||
sandbox.internals = { probeBwrap: () => false }
|
||||
const launcher = launcherPath()
|
||||
const probe = spawnSync(launcher, ['--probe'], { encoding: 'utf8', timeout: 5000 })
|
||||
const out = { launcher, launcherExists: existsSync(launcher), enforcing: probe.status === 0 }
|
||||
const workdir = process.argv[2]
|
||||
if (out.enforcing) {
|
||||
const confined = sandbox.confine(['bash', '-c', \`echo hi > \${workdir}/denied.txt\`], { mode: 'read-only', workspaceRoot: workdir })
|
||||
out.wrapArgv0 = confined.argv[0]
|
||||
out.enforcement = confined.enforcement
|
||||
const run = spawnSync(confined.argv[0], confined.argv.slice(1), { encoding: 'utf8', timeout: 30000 })
|
||||
out.exitCode = run.status
|
||||
out.stderrHasDialect = /permission denied/i.test(run.stderr)
|
||||
} else {
|
||||
try {
|
||||
sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
|
||||
out.confineOutcome = 'wrapped'
|
||||
} catch (error) {
|
||||
out.confineOutcome = error?.code === 'SANDBOX_UNAVAILABLE' ? 'fail-closed' : String(error)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(out))
|
||||
`)
|
||||
const consumer = spawnSync('node', ['consumer.mjs', workDir], { cwd: consumerDir, encoding: 'utf8', timeout: 60_000 })
|
||||
expect(consumer.status, `consumer script failed:\n${consumer.stdout}\n${consumer.stderr}`).toBe(0)
|
||||
verdict = JSON.parse(consumer.stdout.trim().split('\n').pop() as string) as typeof verdict
|
||||
}, 480_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([consumerDir, workDir].filter(Boolean).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('installs the registry launcher for this host: present, EXECUTABLE, right ELF arch', () => {
|
||||
const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run')
|
||||
expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true)
|
||||
// A tarball or extraction step that strips the mode bit would leave the
|
||||
// probe failing exactly like a non-enforcing kernel — assert it apart.
|
||||
expect(() => { accessSync(installed, constants.X_OK) }, 'installed launcher is not executable').not.toThrow()
|
||||
expect(readFileSync(installed).readUInt16LE(18), 'ELF e_machine').toBe(E_MACHINE)
|
||||
})
|
||||
|
||||
it('the installed provider resolves the launcher INSIDE the consumer node_modules platform package', () => {
|
||||
expect(verdict.launcher)
|
||||
.toBe(join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run'))
|
||||
})
|
||||
|
||||
it('confines through the installed launcher (enforcing kernel) or fails closed (non-enforcing) — never unconfined', async () => {
|
||||
// Fail-closed is only the acceptable outcome when the installed binary
|
||||
// IS present and executable and the kernel merely does not enforce —
|
||||
// the first test pins that apart, so nothing hides behind this branch.
|
||||
expect(verdict.launcherExists, 'installed launcher missing').toBe(true)
|
||||
if (verdict.enforcing) {
|
||||
expect(verdict.wrapArgv0).toBe(verdict.launcher)
|
||||
expect(['full', 'partial']).toContain(verdict.enforcement)
|
||||
expect(verdict.exitCode).not.toBe(0)
|
||||
expect(verdict.stderrHasDialect, 'kernel denial text must match the advertised dialect').toBe(true)
|
||||
expect(existsSync(join(workDir, 'denied.txt'))).toBe(false)
|
||||
} else {
|
||||
expect(verdict.confineOutcome).toBe('fail-closed')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
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 type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/**
|
||||
* KEYLESS Seatbelt integration proof for the BACKEND: the REAL macOS
|
||||
* `sandbox-exec` confining REAL processes through `confine()` + a direct
|
||||
* spawn of the returned argv, with the Linux rungs forced off so the ladder
|
||||
* lands on Seatbelt. Verifies the WORLD (files exist or don't) and that the
|
||||
* kernel's denial text matches the dialect the wrap advertises; the
|
||||
* through-`ctx.bash` consumer proof lives with `@deepseek-ai/dsh-bash-sandbox`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — every non-macOS host, or
|
||||
* a macOS whose `sandbox-exec` refuses the profile.
|
||||
*
|
||||
* Workspaces for the workspace-write tests live under the HOME directory on
|
||||
* purpose: `workspace-write` grants `/tmp` and the per-user temp dir
|
||||
* wholesale (the documented Seatbelt-profile temp areas), so only a
|
||||
* workspace OUTSIDE both proves the workspace-root grant itself.
|
||||
*/
|
||||
|
||||
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 provider(): Promise<LocalSandboxProvider> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's facts. */
|
||||
function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
|
||||
const confined = sandbox.confine(['bash', '-c', command], policy)
|
||||
const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
|
||||
return { result, confined }
|
||||
}
|
||||
|
||||
describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement through sandbox-exec', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and the kernel speaks the advertised dialect', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result, confined } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(confined.enforcement).toBe('full')
|
||||
// The wrap's denialSignatures must be what the kernel actually prints.
|
||||
expect(result.stderr.toLowerCase()).toContain('operation not permitted')
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('read-only keeps the tree readable/executable and /dev/null writable', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('dev-ok\n')
|
||||
})
|
||||
|
||||
it('read-only grants no temp area: a write under the user temp dir is denied too', async () => {
|
||||
// The per-user darwin temp dir is a workspace-write grant, not a
|
||||
// read-only one — under read-only the only write-shaped path is /dev/null.
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const target = join(workdir, 'tmp-denied.txt')
|
||||
const { result } = runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: await tempDir(homedir()) })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(existsSync(target)).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 sandbox = await provider()
|
||||
|
||||
const inside = runConfined(sandbox, `printf seatbelt-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(inside.result.status).toBe(0)
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
|
||||
|
||||
const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(denied.result.status).not.toBe(0)
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write grants /tmp and the user temp dir (the documented Seatbelt-profile temp areas)', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const hostTmp = await tempDir('/tmp')
|
||||
const userTmp = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(
|
||||
sandbox,
|
||||
`printf tmp-ok > ${hostTmp}/scratch.txt && printf user-tmp-ok > ${userTmp}/scratch.txt`,
|
||||
{ mode: 'workspace-write', workspaceRoot: workdir },
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
expect(readFileSync(join(hostTmp, 'scratch.txt'), 'utf8')).toBe('tmp-ok')
|
||||
expect(readFileSync(join(userTmp, 'scratch.txt'), 'utf8')).toBe('user-tmp-ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# @deepseek-ai/dsh-sandbox
|
||||
|
||||
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
|
||||
|
||||
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined.
|
||||
|
||||
Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy.
|
||||
|
||||
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `bwrap`, else the per-platform Landlock launcher; macOS: `sandbox-exec`/Seatbelt). The staged first consumer is the sandboxed bash executor (wrapping `['bash', '-c', command]`).
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox",
|
||||
"description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract",
|
||||
"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-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The process-sandbox seam (`ctx.sandbox`): an abstract service defining WHAT
|
||||
* platform confinement does — wrap a subprocess argv so it executes under a
|
||||
* file-effect policy — without saying HOW. Implementations subclass
|
||||
* {@link SandboxProvider} and register as the `sandbox` service;
|
||||
* `@deepseek-ai/dsh-sandbox-local` (per-platform chains: Linux `bwrap` then the
|
||||
* npm-distributed `landlock-run` launcher, macOS `sandbox-exec`/Seatbelt) is
|
||||
* the first.
|
||||
* Consumers hand over the exact argv they are about to spawn
|
||||
* (the staged bash executor wraps `['bash', '-c', command]`; a
|
||||
* subagent backend wraps its child-agent argv) and spawn the returned argv
|
||||
* instead.
|
||||
*
|
||||
* The seam confines SAME-WORLD subprocesses only: a backend shares the
|
||||
* host's filesystem and kernel, and the policy's `workspaceRoot` names a
|
||||
* real host path. Containers, microVMs, and remote executors are NOT
|
||||
* backends of this seam — they are sibling implementations of whole
|
||||
* capability seams (`ctx.bash`, `ctx.fs`), deployed as environment-coherent
|
||||
* groups; the boundary is recorded in
|
||||
* docs/rfc/proposed/feature/2026-07-06-sandbox.md.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* File-effect policy a sandbox backend enforces on confined processes.
|
||||
*
|
||||
* - `read-only` — the process cannot write the filesystem anywhere; a
|
||||
* write-shaped `/dev/null` sink stays available so `>/dev/null` redirects
|
||||
* keep working (HOW is the backend's choice: bwrap mounts a fresh `/dev`,
|
||||
* the Landlock launcher and Seatbelt grant the single `/dev/null` node).
|
||||
* - `workspace-write` — writes are allowed only under the policy's
|
||||
* workspace root and `/tmp`; everything else stays read-only. Which `/tmp`
|
||||
* is backend-specific — an ephemeral mount under bwrap, the HOST `/tmp`
|
||||
* under the Landlock launcher, the host `/private/tmp` plus the per-user
|
||||
* darwin temp dir under Seatbelt: the seam promises the write boundary,
|
||||
* not the mount's nature.
|
||||
* - `danger-full-access` — no confinement; a consumer configured with it
|
||||
* spawns its argv unwrapped and never calls the provider.
|
||||
*
|
||||
* The mode governs FILE effects only: network and process visibility are not
|
||||
* restricted (a backend that cannot honestly enforce them must not pretend
|
||||
* to). How completely the file effects themselves are enforced is likewise a
|
||||
* reported fact, not an assumption — see {@link SandboxEnforcement}.
|
||||
*/
|
||||
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
|
||||
/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */
|
||||
export type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>
|
||||
|
||||
/**
|
||||
* How completely the selected backend enforces a confined mode's file
|
||||
* effects.
|
||||
*
|
||||
* - `full` — every file effect the mode promises to block is governed: the
|
||||
* `bwrap` mount profile, a Landlock kernel enforcing the launcher's whole
|
||||
* ruleset, or an operator-configured runner (configuring one asserts full
|
||||
* enforcement along with existence).
|
||||
* - `partial` — the backend is active but the kernel governs only the subset
|
||||
* of accesses its ABI knows (an older Landlock ABI: path-based truncate is
|
||||
* ungoverned before ABI v3), so a file effect the mode promises to block
|
||||
* may still land. A caller that needs the mode's promise to be absolute
|
||||
* must treat `partial` as outside that promise.
|
||||
*/
|
||||
export type SandboxEnforcement = 'full' | 'partial'
|
||||
|
||||
/**
|
||||
* What one confined execution is allowed to touch — carried PER CALL, not
|
||||
* fixed on the provider: two consumers may confine under different policies
|
||||
* at the same instant (bash under `read-only` while a confined child agent
|
||||
* needs its state directory writable), and an approved escalated retry is a
|
||||
* new call with a wider policy. Defaulting/resolution is the consumer's
|
||||
* explicit step (its config owns the fallback chain); the provider treats
|
||||
* the policy as fully specified.
|
||||
*/
|
||||
export interface SandboxPolicy {
|
||||
/** The file-effect mode this execution runs under. */
|
||||
mode: ConfinedSandboxMode
|
||||
/** Absolute root directory `workspace-write` may write under. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SandboxProvider.confine} result: the argv to spawn in place of
|
||||
* the caller's own, plus the enforcement completeness the selected backend
|
||||
* achieves for it.
|
||||
*/
|
||||
export interface ConfinedArgv {
|
||||
/** The wrapped argv (runner, profile, separator, then the caller's argv). */
|
||||
argv: string[]
|
||||
/** How completely the selected backend enforces the policy's file effects. */
|
||||
enforcement: SandboxEnforcement
|
||||
/**
|
||||
* The selected backend's denial DIALECT: the case-insensitive stderr
|
||||
* substrings a file effect denied by THIS backend produces (EROFS text
|
||||
* under bwrap's read-only binds, EACCES under Landlock, EPERM under
|
||||
* Seatbelt). A consumer that infers denials from a failed run's stderr
|
||||
* matches against exactly these rather than a cross-backend union — the
|
||||
* union claims denials a given backend never produces.
|
||||
*/
|
||||
denialSignatures: readonly string[]
|
||||
/**
|
||||
* How the RUNNER ITSELF failing identifies itself: case-insensitive stderr
|
||||
* substrings produced when the sandbox binary is missing, refuses its
|
||||
* profile, or fails closed before exec'ing the command (`bwrap: `,
|
||||
* `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own
|
||||
* error prefix and the shell's runner-not-found message). ORTHOGONAL to
|
||||
* {@link denialSignatures}: a denial is the confined COMMAND being blocked
|
||||
* (the sandbox working as designed); a runner failure means the command
|
||||
* NEVER RAN and must surface as a sandbox failure, not a task failure —
|
||||
* consumers check these signatures FIRST (a runner's own error text may
|
||||
* contain denial words, e.g. an unopenable grant root reporting
|
||||
* `Permission denied`).
|
||||
*/
|
||||
runnerFailureSignatures: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Error `code` carried by the infrastructure error a provider throws when a
|
||||
* confined policy is requested but no backend is available or usable on this
|
||||
* host: confinement FAILS CLOSED (refuses to run) rather than silently
|
||||
* executing unconfined. Thrown as a `HarnessError`, it reaches the model
|
||||
* through the structured `{ name, code }` error channel on `tool/result`, so
|
||||
* callers can distinguish "the sandbox is missing" from a failing command.
|
||||
*/
|
||||
export const SANDBOX_UNAVAILABLE = 'SANDBOX_UNAVAILABLE'
|
||||
|
||||
/**
|
||||
* Thrown by {@link SandboxProvider.confine} when a confined policy is
|
||||
* requested but no backend is usable on this host: confinement fails closed.
|
||||
* Carries the {@link SANDBOX_UNAVAILABLE} code through the structured
|
||||
* `{ name, code }` error channel.
|
||||
*/
|
||||
export class SandboxUnavailableError extends HarnessError {
|
||||
constructor(mode: ConfinedSandboxMode, detail?: string) {
|
||||
super(
|
||||
`sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; `
|
||||
+ 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing '
|
||||
+ 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement '
|
||||
+ 'backend yet — or switch the consumer to danger-full-access.'
|
||||
+ (detail === undefined ? '' : ` Runner failure: ${detail}`),
|
||||
SANDBOX_UNAVAILABLE,
|
||||
)
|
||||
this.name = 'SandboxUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sandbox: SandboxProvider
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract process-sandbox service. Subclass, implement {@link confine}, and
|
||||
* load the subclass as a plugin — it registers as `ctx.sandbox` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link confine} either returns an argv whose runner ENFORCES the policy
|
||||
* or fails closed — at `confine` time with {@link SandboxUnavailableError}
|
||||
* (no backend for this host), or at EXECUTION time by the runner itself
|
||||
* refusing to run the command (exiting without exec'ing it, identified by
|
||||
* {@link ConfinedArgv.runnerFailureSignatures}). A silent unconfined
|
||||
* passthrough is never a legal outcome on either path.
|
||||
* - Probing exists to ARBITRATE between multiple candidate backends and may
|
||||
* be skipped when a platform has exactly one: the sole candidate is
|
||||
* selected directly and the runner's exec-time fail-closed refusal carries
|
||||
* the safety property. When probing does run, it is functional (actually
|
||||
* enforcing a profile, not a version check), at most once per provider
|
||||
* lifetime; `confine` itself spawns nothing beyond that one-time probing.
|
||||
* - The returned {@link ConfinedArgv.enforcement} states the backend's
|
||||
* actual completeness for THIS host; `partial` is reported, never silently
|
||||
* upgraded to `full`.
|
||||
*/
|
||||
export abstract class SandboxProvider extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sandbox')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `argv` so it executes confined under `policy` on this host; the
|
||||
* caller spawns the returned argv in place of its own.
|
||||
* @param argv - the exact argv the caller is about to spawn (program plus
|
||||
* arguments), NOT a shell string — a shell-shaped consumer passes
|
||||
* `['bash', '-c', command]`.
|
||||
* @param policy - the file-effect policy this execution runs under,
|
||||
* carried per call (see {@link SandboxPolicy}).
|
||||
* @returns the argv to spawn instead, plus the enforcement completeness
|
||||
* the selected backend achieves for it.
|
||||
*/
|
||||
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
|
||||
}
|
||||
|
||||
export default SandboxProvider
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Vocabulary-contract tests for the sandbox seam: the fail-closed error's
|
||||
* structured identity is what tool results and consumers key on, so its
|
||||
* shape is pinned here, next to the vocabulary that owns it. Provider
|
||||
* behavior is each implementation's suite (`dsh-sandbox-local`); consumer
|
||||
* behavior is each consumer's (`dsh-bash-sandbox`).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
describe('SandboxUnavailableError', () => {
|
||||
it('carries the structured { name, code } identity consumers key on', () => {
|
||||
const error = new SandboxUnavailableError('read-only')
|
||||
expect(error.name).toBe('SandboxUnavailableError')
|
||||
expect(error.code).toBe(SANDBOX_UNAVAILABLE)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('names the refused mode and the operator escape hatches in its message', () => {
|
||||
const error = new SandboxUnavailableError('workspace-write')
|
||||
expect(error.message).toContain('"workspace-write"')
|
||||
expect(error.message).toContain('danger-full-access')
|
||||
expect(error.message).not.toContain('Runner failure')
|
||||
})
|
||||
|
||||
it('carries the runner detail when the failure is discovered at execution time', () => {
|
||||
// The late twin of the confine-time throw: an unprobed sole candidate
|
||||
// that fails closed at exec surfaces the SAME error, with the runner's
|
||||
// own first stderr line as the cause.
|
||||
const error = new SandboxUnavailableError('read-only', 'landlock-run: landlock is not enforced by this kernel')
|
||||
expect(error.code).toBe(SANDBOX_UNAVAILABLE)
|
||||
expect(error.message).toContain('Runner failure: landlock-run: landlock is not enforced by this kernel')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+55
@@ -626,6 +626,34 @@ importers:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/sandbox/sandbox:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/sandbox/sandbox-local:
|
||||
dependencies:
|
||||
node-addon-landlock-run:
|
||||
specifier: 0.0.0-test.0
|
||||
version: 0.0.0-test.0
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../sandbox
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/session-persistence/session-persistence:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-session':
|
||||
@@ -3780,6 +3808,22 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
||||
resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==}
|
||||
engines: {node: '>=20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
node-addon-landlock-run-linux-x64@0.0.0-test.0:
|
||||
resolution: {integrity: sha512-eXvdfnH/UV55MTZzroKvM3CD68SP5OlCsuth908YOcJOnn0LPD5KJjmBz6ToDlBYjF52NNK62+g7TvmUWjbKWQ==}
|
||||
engines: {node: '>=20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
node-addon-landlock-run@0.0.0-test.0:
|
||||
resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -6786,6 +6830,17 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
node-addon-landlock-run-linux-arm64@0.0.0-test.0:
|
||||
optional: true
|
||||
|
||||
node-addon-landlock-run-linux-x64@0.0.0-test.0:
|
||||
optional: true
|
||||
|
||||
node-addon-landlock-run@0.0.0-test.0:
|
||||
optionalDependencies:
|
||||
node-addon-landlock-run-linux-arm64: 0.0.0-test.0
|
||||
node-addon-landlock-run-linux-x64: 0.0.0-test.0
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@3.3.2:
|
||||
|
||||
@@ -19,3 +19,12 @@ allowBuilds:
|
||||
# need, so we deny them — install still succeeds.
|
||||
'@google/genai': false
|
||||
protobufjs: false
|
||||
|
||||
# The Landlock launcher family is our own sibling-repo release, consumed
|
||||
# fresh (hours old at each coordinated bump) — the release-age quarantine
|
||||
# would block every such bump, so the family is exempted BY NAME, not by
|
||||
# pinned version.
|
||||
minimumReleaseAgeExclude:
|
||||
- node-addon-landlock-run
|
||||
- node-addon-landlock-run-linux-arm64
|
||||
- node-addon-landlock-run-linux-x64
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 653,
|
||||
"packages/AGENTS.md": 450,
|
||||
"packages/README.md": 660
|
||||
"packages/README.md": 710
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const GROUP_ORDER = [
|
||||
'llm',
|
||||
'core',
|
||||
'bash',
|
||||
'sandbox',
|
||||
'fs',
|
||||
'compact',
|
||||
'subagent',
|
||||
@@ -159,6 +160,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
pkg: 'sandbox',
|
||||
title: 'Process-sandbox seam',
|
||||
mode: 'seam',
|
||||
implementations: ['sandbox-local'],
|
||||
consumers: [],
|
||||
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
pkg: 'approval',
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/dsh-*": [
|
||||
"./packages/approval/*/src",
|
||||
"./packages/core/*/src",
|
||||
"./packages/sandbox/*/src",
|
||||
"./packages/llm/*/src",
|
||||
"./packages/bash/*/src",
|
||||
"./packages/code-runtime/*/src",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "./packages/core/system-prompt" },
|
||||
{ "path": "./packages/approval/approval" },
|
||||
{ "path": "./packages/sandbox/sandbox" },
|
||||
{ "path": "./packages/sandbox/sandbox-local" },
|
||||
{ "path": "./packages/core/agent" },
|
||||
{ "path": "./packages/ui/user-interaction" },
|
||||
{ "path": "./packages/core/tools" },
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "./packages/core/system-prompt" },
|
||||
{ "path": "./packages/approval/approval" },
|
||||
{ "path": "./packages/sandbox/sandbox" },
|
||||
{ "path": "./packages/sandbox/sandbox-local" },
|
||||
{ "path": "./packages/core/agent" },
|
||||
{ "path": "./packages/ui/user-interaction" },
|
||||
{ "path": "./packages/core/tools" },
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@ import { defineConfig } from 'tsdown'
|
||||
export default defineConfig({
|
||||
// Explicit globs: `workspace: true` would also discover examples (any
|
||||
// package.json), but only vendor and the packages hierarchy are pnpm
|
||||
// workspaces.
|
||||
// workspaces. The Landlock launcher platform packages ship a prebuilt
|
||||
// native binary and no JavaScript — nothing to bundle.
|
||||
workspace: ['vendor/*', 'packages/*/*'],
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
|
||||
Reference in New Issue
Block a user