From 7b8c3a9b40b5e259484a627ee22284f8f710b7a1 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 9 Jul 2026 15:42:37 +0800 Subject: [PATCH] feat(sandbox): the confinement seam and the per-platform native runner chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/sandbox.yml | 124 +++++ docs/capability-seams.md | 6 + docs/config-catalog.md | 37 ++ docs/cordis-catalog/services.md | 16 + docs/module-graph.md | 9 + knip.json | 5 + packages/README.md | 3 +- packages/sandbox/README.md | 12 + packages/sandbox/sandbox-local/README.md | 18 + packages/sandbox/sandbox-local/package.json | 38 ++ packages/sandbox/sandbox-local/src/index.ts | 455 ++++++++++++++++++ .../sandbox/sandbox-local/tests/bwrap.e2e.ts | 118 +++++ .../sandbox-local/tests/landlock.e2e.ts | 118 +++++ .../sandbox/sandbox-local/tests/local.spec.ts | 349 ++++++++++++++ .../sandbox-local/tests/packed-install.e2e.ts | 173 +++++++ .../sandbox-local/tests/seatbelt.e2e.ts | 120 +++++ packages/sandbox/sandbox-local/tsconfig.json | 27 ++ packages/sandbox/sandbox/README.md | 11 + packages/sandbox/sandbox/package.json | 32 ++ packages/sandbox/sandbox/src/index.ts | 199 ++++++++ .../sandbox/sandbox/tests/vocabulary.spec.ts | 35 ++ packages/sandbox/sandbox/tsconfig.json | 21 + pnpm-lock.yaml | 55 +++ pnpm-workspace.yaml | 9 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-doc-graphs.ts | 10 + tsconfig.base.json | 1 + tsconfig.build.json | 2 + tsconfig.json | 2 + tsdown.config.ts | 3 +- 30 files changed, 2007 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/sandbox.yml create mode 100644 packages/sandbox/README.md create mode 100644 packages/sandbox/sandbox-local/README.md create mode 100644 packages/sandbox/sandbox-local/package.json create mode 100644 packages/sandbox/sandbox-local/src/index.ts create mode 100644 packages/sandbox/sandbox-local/tests/bwrap.e2e.ts create mode 100644 packages/sandbox/sandbox-local/tests/landlock.e2e.ts create mode 100644 packages/sandbox/sandbox-local/tests/local.spec.ts create mode 100644 packages/sandbox/sandbox-local/tests/packed-install.e2e.ts create mode 100644 packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts create mode 100644 packages/sandbox/sandbox-local/tsconfig.json create mode 100644 packages/sandbox/sandbox/README.md create mode 100644 packages/sandbox/sandbox/package.json create mode 100644 packages/sandbox/sandbox/src/index.ts create mode 100644 packages/sandbox/sandbox/tests/vocabulary.spec.ts create mode 100644 packages/sandbox/sandbox/tsconfig.json diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml new file mode 100644 index 0000000000..561d74b587 --- /dev/null +++ b/.github/workflows/sandbox.yml @@ -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\)' diff --git a/docs/capability-seams.md b/docs/capability-seams.md index abddc73ed2..8dac9fbfd6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -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
Process-sandbox seam"] + pkg_sandbox_local["sandbox-local"] pkg_approval["approval"] svc_approval["ctx.approval
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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ed175f250b..b092b20537 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb0fe8ffd1..e0fa68bab9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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). diff --git a/docs/module-graph.md b/docs/module-graph.md index 2bcd15c967..2c2e94a254 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/knip.json b/knip.json index 67da80cd2a..ba6dfb6fb2 100644 --- a/knip.json +++ b/knip.json @@ -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"] diff --git a/packages/README.md b/packages/README.md index 8d1580b291..7a389b29fa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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///`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-` 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///`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-` 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///`. 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 | diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md new file mode 100644 index 0000000000..bc535bbc8a --- /dev/null +++ b/packages/sandbox/README.md @@ -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). diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md new file mode 100644 index 0000000000..c4ca758aee --- /dev/null +++ b/packages/sandbox/sandbox-local/README.md @@ -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: : not found`, `: No such file or directory`, `: 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. diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json new file mode 100644 index 0000000000..d2f8b34161 --- /dev/null +++ b/packages/sandbox/sandbox-local/package.json @@ -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" + } +} diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts new file mode 100644 index 0000000000..01189bc78b --- /dev/null +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -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 = { + 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 = { + 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 + +/** + * 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 + +/** + * 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 = 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: : 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 diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts new file mode 100644 index 0000000000..da6e683da1 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -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 { + const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-')) + tempDirs.push(dir) + return dir +} + +async function provider(): Promise { + 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) + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts new file mode 100644 index 0000000000..104d3b318a --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -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 { + const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-')) + tempDirs.push(dir) + return dir +} + +async function provider(): Promise { + 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') + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts new file mode 100644 index 0000000000..d12f4970cb --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -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 })) + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts new file mode 100644 index 0000000000..9d0e8ade4f --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -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') + } + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts new file mode 100644 index 0000000000..7136e4e524 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -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 { + const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-')) + tempDirs.push(dir) + return dir +} + +async function provider(): Promise { + 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') + }) +}) diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json new file mode 100644 index 0000000000..c756b6af69 --- /dev/null +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md new file mode 100644 index 0000000000..c63b9da5de --- /dev/null +++ b/packages/sandbox/sandbox/README.md @@ -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]`). diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json new file mode 100644 index 0000000000..b37ef714ea --- /dev/null +++ b/packages/sandbox/sandbox/package.json @@ -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" + } +} diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts new file mode 100644 index 0000000000..43ee8f3d16 --- /dev/null +++ b/packages/sandbox/sandbox/src/index.ts @@ -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 + +/** + * 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 diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts new file mode 100644 index 0000000000..caba42b2a7 --- /dev/null +++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts @@ -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') + }) +}) diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json new file mode 100644 index 0000000000..9f687793d7 --- /dev/null +++ b/packages/sandbox/sandbox/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bef41d9bdf..27302831d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b2b731fc58..6407a99f52 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a5261698a2..52f563b86d 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 800, "examples/AGENTS.md": 653, "packages/AGENTS.md": 450, - "packages/README.md": 660 + "packages/README.md": 710 } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index be6d8b7121..dd6c6fb67d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -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', diff --git a/tsconfig.base.json b/tsconfig.base.json index b6d0281f81..80becb532c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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", diff --git a/tsconfig.build.json b/tsconfig.build.json index 2033218d01..c6c814c221 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" }, diff --git a/tsconfig.json b/tsconfig.json index af8badd19f..cd458490f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 7b172180d1..9479c5c91d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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',