From 07d9c8a6aec6dc66df74f2083641189c9ab23739 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Thu, 28 May 2026 17:09:37 +1000 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20Phase=207=20PR-A=20=E2=80=94?= =?UTF-8?q?=20@anthropic-ai/sandbox-runtime=20dep=20+=20doctor=20+=20ADR?= =?UTF-8?q?=200014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the foundation for multi-tenant provider spawning isolation per ADR 0014 § Decision. NO production wiring — PR-B (anthropic.mjs spawn wrap) lands separately and requires bubblewrap + socat + ripgrep installed on PI231 first. Files: - package.json: add @anthropic-ai/sandbox-runtime ^0.0.52 - lib/sandbox/doctor.mjs (new): preflight checkSandboxAvailability + describeSandboxStatus; pure module, no state, no initialize() call - server.mjs: /health response gains 'sandbox' field with availability + missing deps + install hint; result memoized via _sandboxStatusCache; __resetSandboxStatusCache() test seam exported - docs/adr/0014-sandbox-runtime-integration.md (new): 4-PR layered rollout decision per Iron Rule 11; PR-B/C/D acceptance criteria previewed; 6 spike pitfalls recorded; 282 lines - CHANGELOG.md: Unreleased entry under Phase 7 PR-A - test-features.mjs Suite 42 (+8 tests, 797 → 805, all pass) Authority: - @anthropic-ai/sandbox-runtime v0.0.52 — anthropic-experimental org https://github.com/anthropic-experimental/sandbox-runtime - 2026-05-28 PoC spike (verdict YELLOW; report at /tmp/sandbox-spike/ on PI231): clean arm64 install, dep check fail-closed behaviour confirmed, three PoC scripts parked - cc-mem incident memory § 4 (prior-art search — ecosystem hasn't solved multi-tenant fs/tool isolation) - ADR 0009 Amendment 1 § Caveats #3 — sandbox is cloud prerequisite - docs/plans/cloud-deployment-family.md § 5 Spike note (macOS): on dev Mac mini with rg via Homebrew, SandboxManager.isSupportedPlatform()=true and checkDependencies() returns no errors — macOS uses built-in sandbox-exec, not bwrap. /health.sandbox.available=true on macOS dev, false on PI231 until apt install. Operational follow-ups (NOT this PR): 1. sudo apt-get install -y bubblewrap socat ripgrep on PI231 (5-min window) 2. PR-B: lib/providers/anthropic.mjs spawn wrap + negative test (in-sandbox cat of OAuth token MUST fail) 3. PR-C: lib/providers/codex.mjs wrap with enableWeakerNestedSandbox:true 4. PR-D: cloud deployment plan update + unblock Tests: 797 → 805 (all pass, 0 fail). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 + docs/adr/0014-sandbox-runtime-integration.md | 282 ++++++++++++++++++ lib/sandbox/doctor.mjs | 290 +++++++++++++++++++ package-lock.json | 89 ++++++ package.json | 5 +- server.mjs | 57 ++++ test-features.mjs | 221 ++++++++++++++ 7 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0014-sandbox-runtime-integration.md create mode 100644 lib/sandbox/doctor.mjs create mode 100644 package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ad34318..ded161a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## Unreleased +### Phase 7 PR-A — sandbox-runtime dep + doctor + ADR 0014 + +- feat(sandbox): Phase 7 PR-A — @anthropic-ai/sandbox-runtime dep + lib/sandbox/doctor.mjs preflight + ADR 0014. No runtime wiring yet (PR-B will wrap anthropic.mjs spawn). /health now reports sandbox availability (`available: false` until PI231 has `bubblewrap` + `socat` + `ripgrep` installed via `sudo apt-get install -y bubblewrap socat ripgrep`). On macOS (dev machine with ripgrep via Homebrew), sandbox-runtime reports `available: true` because macOS uses the built-in `sandbox-exec` seatbelt — no apt install needed. 797 → 805 tests (+8 Suite 42). + ### Phase 6 D-day — stream-json transport for Anthropic provider (ADR 0009 Amendment 1) - feat(anthropic): stream-json output + --system-prompt suppression of env-block / tool descriptions (ADR 0009 Amendment 1). Cuts ~64% per-request cost on Sonnet 4.6 via 30% input-token reduction ($0.0216 → $0.0078), fixes bot self-check hallucination (model no longer claims server cwd / OS / tool names), exposes rate_limit + usage events from NDJSON for future audit/dashboard work. Per-key API + cache + audit semantics unchanged. claude CLI v2.1.104 verified; warn if claude-version outside v2.1.100–v2.1.149. diff --git a/docs/adr/0014-sandbox-runtime-integration.md b/docs/adr/0014-sandbox-runtime-integration.md new file mode 100644 index 0000000..4b6f1a3 --- /dev/null +++ b/docs/adr/0014-sandbox-runtime-integration.md @@ -0,0 +1,282 @@ +# ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning + +**Status:** Accepted (PR-A — deps + doctor + ADR only; PR-B/C/D pending) +**Date:** 2026-05-28 +**Phase:** Phase 7 + +--- + +## Related + +- **ADR 0001** (Project Founding) — OLP's multi-provider rationale and "no conversation state" principle. +- **ADR 0009 Amendment 1** (stream-json transport, Phase 6) § Caveats #3: "Sandbox-runtime still required for real multi-tenant deployment." +- **ADR 0002** (Plugin Architecture) — Provider contract; `spawn()` is the surface this ADR will wrap in PR-B/C. +- **ADR 0006** (Provider Inclusion / Risk Tier Framework) — classifies providers by deployment risk; sandbox status is a gating condition for Tier-A (cloud-deployed). +- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a hard prerequisite before any cloud rollout. +- **cc-mem incident memory** — `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` — the multi-tenant security gap that motivates this ADR. + +--- + +## 1. Context + +### 1.1 The multi-tenant security gap + +OLP is a personal-scale proxy (ADR 0001 § Non-commercial). However, the "family-scale" deployment model means multiple human callers share a single OLP instance — each with their own OLP API key (ADR 0007) but all using the same underlying `claude` or `codex` CLI installation on the server host. + +The security gap, identified in the 2026-05-27 session and captured in cc-mem incident memory § 3, is: + +1. **OAuth token exposure.** A malicious (or misbehaving) prompt to the Anthropic provider could elicit a `cat ~/.olp/keys/...` or similar read of any file the OLP process user can access — including the OAuth credentials file that allows the attacker to impersonate the server-side identity. +2. **Codex shell-tool execution.** The `codex exec` path exposes a shell tool to the model. With OLP acting as a relay, a prompt to codex from one client could execute arbitrary commands in the server process's working directory, reading or writing files belonging to other clients. +3. **Cross-tenant data leakage.** Even without adversarial prompts, a model that freely accesses the filesystem could inadvertently leak one client's cached context to another client's response. + +The 2026-05-27 prior-art search (incident memory § 4) surveyed the multi-tenant LLM proxy ecosystem (LiteLLM, OpenCode, CLIProxyAPI, open-source Anthropic proxies) and found that **none solve multi-tenant file-system and tool isolation at the OS level**. The field's typical answer is "don't run multi-tenant" or "use a separate VM per tenant" — neither applicable at OLP's family scale. + +### 1.2 Anthropic's official answer: `@anthropic-ai/sandbox-runtime` + +The `@anthropic-ai/sandbox-runtime` package (Anthropic Experimental org, `anthropic-experimental/sandbox-runtime`, v0.0.52 as of this ADR) is Anthropic's open-source solution to wrapping security boundaries around arbitrary processes. It is the library that Claude Code itself uses internally to sandbox MCP servers and tool execution. + +The library provides: + +- **Linux:** bubblewrap (`bwrap`) namespace isolation + socat network bridge + seccomp filter via `apply-seccomp-filter` binary. Ripgrep (`rg`) is required for deny-path glob expansion. +- **macOS:** `sandbox-exec` seatbelt profile, which is a built-in OS facility (no additional packages required). + +Both paths enforce filesystem read/write restrictions and network policy at the kernel level, not at the process level. A `cat ~/.olp/keys/...` inside the sandbox fails at the syscall layer regardless of what the shell or model requests. + +### 1.3 The 2026-05-28 spike + +A PoC spike was conducted on PI231 (arm64 Debian Bookworm) on 2026-05-28. Key findings: + +1. **`npm install @anthropic-ai/sandbox-runtime@0.0.52` succeeds cleanly** on arm64 Linux. No native build step; prebuilt binaries were available. +2. **`SandboxManager.isSupportedPlatform()` returns `true`** on PI231 (Linux, not WSL). +3. **`SandboxManager.checkDependencies()` reports errors**: `bubblewrap (bwrap) not installed`, `socat not installed`, `ripgrep (rg) not found`. These are the three OS-level deps that must be installed separately (not bundled in the npm package). +4. **The install fix is a one-liner**: `sudo apt-get install -y bubblewrap socat ripgrep`. This is a 5-minute operational task, not a code change. +5. **Three PoC scripts** were parked at `/tmp/sandbox-spike/` on PI231 verifying: dependency check return shapes, `SandboxManager.wrapWithSandbox` call signature, and filesystem-deny path behaviour. + +Verdict: **YELLOW** — architecturally green (the library works and the platform is supported), operationally blocked on apt deps. PR-A lays the dependency + doctor layer. PR-B wraps the anthropic spawn after apt install. + +--- + +## 2. Decision + +### 2.1 Layered rollout (Iron Rule 11 — minimum reviewable unit) + +The sandbox integration is split into four discrete PRs, each independently reviewable and independently safe to land or revert: + +| PR | Scope | Blocking condition | Status | +|---|---|---|---| +| **PR-A** (this PR) | npm dep `@anthropic-ai/sandbox-runtime ^0.0.52` + `lib/sandbox/doctor.mjs` (preflight module) + `/health` `sandbox` field + ADR 0014 | None — no runtime initialization | ✅ Accepted | +| **PR-B** | `lib/providers/anthropic.mjs` spawn wrapped in `SandboxManager.wrapWithSandbox` | `bubblewrap` + `socat` + `rg` installed on PI231 (`sudo apt-get install -y bubblewrap socat ripgrep`) | 🔲 Blocked on apt install | +| **PR-C** | `lib/providers/codex.mjs` spawn wrapped with `enableWeakerNestedSandbox: true` | PR-B accepted + codex PoC on PI231 | 🔲 Blocked on PR-B | +| **PR-D** | `docs/plans/cloud-deployment-family.md` § "Phase 7 prerequisite met" update; cloud rollout unblocked | PR-B + PR-C accepted | 🔲 Blocked on PR-C | + +Rationale for the split: + +- **PR-A is safe without bwrap.** The doctor module and `/health` field add observability with no runtime side effects. No `SandboxManager.initialize()` call. No sandbox spawned. +- **PR-B is the load-bearing security gate.** Wrapping `anthropic.mjs` spawn requires empirical negative-test confirmation (in-sandbox `cat ~/.olp/keys/...` MUST fail). This cannot be verified until PI231 has bwrap installed. +- **PR-C follows PR-B** because codex has a distinct issue: codex itself uses bubblewrap internally (`codex exec` spawns its own sandbox). `enableWeakerNestedSandbox: true` is required to allow the inner sandbox to function inside the outer OLP sandbox. +- **PR-D is documentation-only** and depends on the runtime PRs being proven in production. + +### 2.2 PR-A specific scope (binding) + +PR-A MUST NOT include: + +- Any call to `SandboxManager.initialize()` (no real sandbox created) +- Any modification to `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, or `lib/providers/mistral.mjs` +- Any new HTTP endpoint (no `/metrics`, no new dashboard endpoint) +- Any modification to `models-registry.json` + +PR-A MUST include: + +- `package.json` dependency: `"@anthropic-ai/sandbox-runtime": "^0.0.52"` +- `lib/sandbox/doctor.mjs`: pure preflight module (no state; no initialization) +- `/health` response: top-level `sandbox` field (`available`, `missing`, `platform`, `message` when unavailable) +- `docs/adr/0014-sandbox-runtime-integration.md` (this document) +- `CHANGELOG.md` Unreleased entry +- `test-features.mjs` Suite 42 (8 new tests, all passing) + +--- + +## 3. `lib/sandbox/doctor.mjs` design + +### 3.1 Exports + +```javascript +// Returns { available: boolean, missing: string[], details: { ... } } +export async function checkSandboxAvailability() { ... } + +// Returns { ok: boolean, message: string } — human-readable summary +export async function describeSandboxStatus() { ... } +``` + +### 3.2 `checkSandboxAvailability` algorithm + +1. Probe OS deps independently via `child_process.execFileSync('which', [binary])`: + - `bwrap` (Linux only — macOS uses built-in `sandbox-exec`) + - `socat` (Linux only) + - `rg` (ripgrep — Linux only; macOS seatbelt profiles use regex patterns natively) +2. Call `probeLibrary()` which `import()`s `@anthropic-ai/sandbox-runtime` and calls: + - `SandboxManager.isSupportedPlatform()` — platform classification + - `SandboxManager.checkDependencies(undefined)` — library's own dep check (called without initialize, falling back to PATH lookup) +3. Compute `missing[]`: on Linux, add 'bubblewrap', 'socat', 'ripgrep' for each absent dep; if library import failed, add that too. +4. `available = libLoaded && isSupportedPlatform && missing.length === 0` + +`probeLibrary()` wraps everything in try/catch — any library-side error becomes `{ libLoaded: false, libError: '' }` rather than an unhandled rejection. + +### 3.3 `/health` integration + +The `sandbox` field is added to the full (owner-tier) payload only. For trimmed payloads (guest/anonymous per ADR 0007 § 7.1), the field is absent (consistent with the existing trim model). This prevents leaking infrastructure details to non-owner callers. + +The result is memoized process-wide via `_sandboxStatusCache` in `server.mjs`. The install state of bwrap/socat cannot change at runtime without a process restart, so a single lazy fetch at the first `/health` call is correct. + +```json +{ + "ok": true, + "version": "0.5.1", + "providers": { ... }, + "sandbox": { + "available": false, + "missing": ["bubblewrap", "socat", "ripgrep"], + "platform": "linux", + "message": "Sandbox dependencies not available: bubblewrap not installed, socat not installed, ripgrep not installed. Install: sudo apt-get install -y bubblewrap socat ripgrep" + } +} +``` + +When available (after apt install + process restart): + +```json +{ + "sandbox": { + "available": true, + "missing": [], + "platform": "linux" + } +} +``` + +--- + +## 4. PR-B/C/D acceptance criteria (preview — locked in later PRs) + +These criteria are recorded here to prevent scope creep in the later PRs. Criteria may be refined by subsequent ADR amendments. + +### 4.1 PR-B (anthropic.mjs spawn wrap) + +**Load-bearing negative test (required for PR-B to merge):** + +```bash +# On PI231, with bwrap+socat installed, with PR-B wired: +olp-keys list # identify owner key +curl -X POST http://127.0.0.1:4567/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"run: cat /home//.olp/keys/owner-key.json"}]}' +# Expected: response MUST NOT contain any content from the keys file. +# The model must either say it cannot access the filesystem, or produce an +# error. Any response containing the file content is a PR-B blocking failure. +``` + +Additional criteria: +- `SandboxManager.initialize()` is called once at startup (per ADR 0014 § 5 singleton decision, TBD in PR-B ADR amendment) +- p95 latency overhead of wrapping ≤ 200ms measured over 50 warm requests +- `checkSandboxAvailability().available === true` reported in `/health.sandbox` after PR-B rolls out +- All existing Suite 41 tests continue to pass (stream-json transport unaffected) + +### 4.2 PR-C (codex.mjs wrap) + +- `enableWeakerNestedSandbox: true` is set in the `SandboxManager.initialize()` call (or per-spawn config if the API allows per-spawn override — verify against v0.0.52 API) +- `codex exec` inner bubblewrap nest still functions: a sandboxed codex invocation that reads from an allowed path succeeds +- Analogous negative test: in-sandbox `cat /home//.olp/keys/...` MUST fail + +### 4.3 PR-D (cloud deployment plan update) + +- `docs/plans/cloud-deployment-family.md` § 5 "Phase 7 prerequisite" section updated: "sandbox-runtime integration (PR-B + PR-C) confirmed operational on PI231; prerequisite met" +- `README.md` § "Supported Providers" or § "Security" updated with a note about sandbox isolation +- Phase 7 close PR per `CLAUDE.md release_kit.phase_rolling_mode` + +--- + +## 5. Open questions (to be resolved in PR-B) + +1. **Singleton vs per-spawn initialization.** `SandboxManager` is a process-wide singleton (per the library's `reset()` being a global operation). The current design plan is one `initialize()` call at server startup with a union config covering all providers. If providers require different configs (e.g., different `denyRead` paths for anthropic vs codex), this may require a mutex approach or separate singleton instances. Decision reserved for PR-B. + +2. **`SandboxManager.reset()` in tests.** The singleton means test suites that call `initialize()` must call `reset()` in their `after()` hooks. PR-B must add this discipline or tests will leak sandbox state across suites. + +3. **MITM proxy and Claude CLI cert pinning.** The sandbox-runtime network bridge on Linux uses a local MITM proxy to intercept HTTPS traffic. If `claude` CLI pins certificates (e.g., for `api.anthropic.com`), HTTPS through the bridge may fail. PR-B must empirically verify this on PI231 before merging. + +4. **macOS `sandbox-exec` profile content.** macOS uses a seatbelt (SBPL) profile, not bwrap. The profile must explicitly allow `network outbound "api.anthropic.com"` etc. The default profile may be too restrictive for the Claude CLI's OAuth refresh calls. PR-B must test macOS as well as Linux. + +5. **`getDefaultWritePaths()` output.** The library exports `getDefaultWritePaths()` which returns the paths the sandbox always allows writing to. OLP's spawn directory may not be in that list — PR-B must verify the working directory is writable or pass it explicitly in `filesystem.allowWrite`. + +--- + +## 6. Pitfalls inherited from the spike (binding warnings for PR-B/C authors) + +These were confirmed empirically or inferred from the library source during the 2026-05-28 spike: + +1. **Three OS deps, not one.** The npm package bundles nothing. Linux requires: `bubblewrap` (bwrap), `socat`, `ripgrep` (rg). All three. Missing even one → `checkDependencies()` returns errors → `wrapWithSandbox` will fail at runtime. + +2. **Linux deny-paths are literal, not glob.** The library's `linuxGetMandatoryDenyPaths()` uses ripgrep to expand glob patterns to concrete paths before passing them to bwrap. But custom `filesystem.denyRead` entries that contain glob chars (`~/.ssh/*`) must be either expanded manually OR passed as the glob form (the library expands them if `rg` is available). The safe convention for PR-B: use absolute literal paths (e.g., `/home//.ssh`) rather than `~/`-prefixed or glob paths. + +3. **`enableWeakerNestedSandbox: true` is required for codex.** Codex's `exec` subcommand spawns its own bubblewrap sandbox internally. Without `enableWeakerNestedSandbox`, the outer OLP sandbox blocks the inner codex sandbox from creating user namespaces. The flag loosens the outer sandbox's seccomp filter specifically to allow `clone(CLONE_NEWUSER)` — the inner sandbox then runs with reduced but non-zero isolation. + +4. **`SandboxManager.reset()` is process-wide.** Calling `reset()` anywhere (including test teardown) clears the singleton config. Any concurrent in-flight spawn that still holds a reference to the old sandbox state will break. PR-B's design must either (a) initialize once at boot and never reset, or (b) use a mutex to prevent concurrent init/reset. + +5. **MITM CA generation is async and expensive.** `SandboxManager.initialize()` generates a self-signed CA certificate for the MITM proxy on Linux. This takes ~100-500ms. Initialize at server startup, not per-request. + +--- + +## 7. Authority citations + +- **`@anthropic-ai/sandbox-runtime` v0.0.52** — https://github.com/anthropic-experimental/sandbox-runtime + - `dist/sandbox/sandbox-manager.js` — `isSupportedPlatform()`, `checkDependencies()`, `SandboxManager` export shape + - `dist/sandbox/linux-sandbox-utils.js` — `checkLinuxDependencies()`, `whichSync` usage, `enableWeakerNestedSandbox` rationale + - `README.md` — installation prerequisites, platform support matrix + +- **2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm)** — report at `/tmp/sandbox-spike/report.md` on PI231. Key findings: dep install clean; `isSupportedPlatform()=true`; `checkDependencies()` errors on bwrap+socat+rg absence; three PoC scripts parked. Verdict YELLOW. + +- **cc-mem incident memory 2026-05-27** — `~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` § 3 (gap description), § 4 (prior-art search showing ecosystem hasn't solved multi-tenant fs/tool isolation). + +- **OLP ADR 0009 Amendment 1 § Caveats #3** — "Sandbox-runtime still required for real multi-tenant deployment. Per the 2026-05-27 session prior-art search, Anthropic's official multi-tenant answer is `@anthropic-ai/sandbox-runtime` (OS-level isolation)." + +- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a hard prerequisite before any cloud deployment. + +- **OLP ALIGNMENT.md** — PR-A is library/doctor/governance; it does not touch provider plugins, the entry surface, or the IR. The authority citation for the npm dep is the official sandbox-runtime repo URL + the spike report (not a provider CLI, not the OpenAI spec, not an existing ADR — this is a new dependency decision, which is the correct scope for ADR 0014). + +- **Iron Rule 11 (Incremental Diff Review)** — splits non-trivial work into the minimum reviewable unit. The 4-PR split (A/B/C/D) is the direct application of this rule to the sandbox integration: each PR is independently reviewable, independently safe to land or revert, and corresponds to one logical layer. + +--- + +## 8. Consequences + +### Positive + +- **Multi-tenant isolation at the OS level.** After PR-B+C land, each provider spawn runs inside a bubblewrap (Linux) or sandbox-exec (macOS) boundary. A prompt-injected `cat ~/.olp/keys/...` hits a kernel-level deny. Cross-client filesystem leakage is structurally prevented, not just mitigated by prompt engineering. + +- **Cloud deployment unblocked.** `docs/plans/cloud-deployment-family.md` § 5 cites sandbox as the hard prerequisite for moving from family-LAN to cloud. PR-D closes this gate. + +- **Observability from day one.** The `/health.sandbox` field makes the install state machine-readable. Any monitoring script or dashboard can tell whether sandbox isolation is active without SSH access. + +- **Anthropic's official library.** Using `@anthropic-ai/sandbox-runtime` rather than a home-grown bwrap wrapper means OLP inherits Anthropic's tested integration patterns (deny-path expansion, MITM proxy, seccomp, macOS seatbelt profiles) rather than reinventing them. When the library updates, OLP upgrades via `npm update`. + +### Negative + +- **Three new OS-level dependencies.** `bubblewrap`, `socat`, and `ripgrep` must be installed on every host running OLP with sandbox isolation active. Absent these deps, sandbox is unavailable (but OLP continues to function without isolation — degraded security, not degraded functionality). The `/health.sandbox.available` field makes this state explicit. + +- **p95 latency overhead.** The spike did not measure sandbox wrapping overhead directly (blocked on apt install). Expected overhead per the sandbox-runtime README: ~100-200ms for sandbox initialization amortized over the process lifetime (one-time at startup); per-spawn overhead is the namespace clone + filesystem mount overhead, typically <50ms on modern kernels. PR-B's acceptance criteria gates on ≤200ms p95 overhead over 50 warm requests. + +- **Codex inner-sandbox degradation.** `enableWeakerNestedSandbox: true` loosens the outer OLP sandbox's seccomp filter to allow `clone(CLONE_NEWUSER)`. The codex inner sandbox still runs with meaningful isolation (its own namespace, its own deny-list), but the combined depth of protection is less than ideal compared to a world where codex didn't self-sandbox. + +- **Library is experimental.** The `anthropic-experimental` org signals this is not a production-stable API. The version pin (`^0.0.52`) provides a minor-range buffer but the API surface may change. If the library is deprecated or the API breaks, OLP's fallback is to remove the sandbox wrapping (reverting PRs B-D) until a replacement path is found. This is acceptable at family scale — security degradation is not a service outage. + +### Reversibility + +- **PR-A** is trivially reversible: `npm uninstall @anthropic-ai/sandbox-runtime` + delete `lib/sandbox/doctor.mjs` + revert server.mjs and CHANGELOG changes. No production behavior changes. +- **PR-B/C** are reversible by removing the `SandboxManager.wrapWithSandbox` call from each provider's `spawn()` method. The spawn falls back to the current unsandboxed path. +- **PR-D** is a documentation update; reverting it is a docs-only change. + +--- + +## Status transitions + +- 2026-05-28 — Created. Status: Accepted for PR-A scope. PR-B/C/D pending operational prereqs. diff --git a/lib/sandbox/doctor.mjs b/lib/sandbox/doctor.mjs new file mode 100644 index 0000000..5cefa34 --- /dev/null +++ b/lib/sandbox/doctor.mjs @@ -0,0 +1,290 @@ +/** + * lib/sandbox/doctor.mjs — Sandbox availability preflight module (Phase 7 PR-A) + * + * Authority: + * @anthropic-ai/sandbox-runtime v0.0.52 + * https://github.com/anthropic-experimental/sandbox-runtime + * + * 2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm): dep install clean, + * isSupportedPlatform()=true, blocked on apt deps (bwrap + socat), three PoC + * scripts parked at /tmp/sandbox-spike/ on PI231. + * + * OLP ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning + * OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite) + * docs/plans/cloud-deployment-family.md § 5 + * + * Design: + * Pure module — no state, no side effects beyond child_process.execFileSync for + * `which` probes. Does NOT call SandboxManager.initialize(). Does NOT create + * or interact with any real sandbox. Safe to call from /health on every request + * (results are memoized process-wide by the caller in server.mjs — see + * _sandboxStatusCache there). + * + * Exports: + * checkSandboxAvailability() — returns { available, missing, details } + * describeSandboxStatus() — returns { ok, message } human-readable summary + */ + +import { execFileSync } from 'node:child_process'; +import { platform as osPlatform } from 'node:os'; + +// ── which probe helper ──────────────────────────────────────────────────── + +/** + * Check if a binary is in PATH by running `which `. + * Returns true if found, false if not found or if `which` is unavailable. + * Never throws. + * @param {string} binary + * @returns {boolean} + */ +function isInPath(binary) { + try { + execFileSync('which', [binary], { stdio: 'pipe', timeout: 2000 }); + return true; + } catch { + return false; + } +} + +// ── Platform helper ─────────────────────────────────────────────────────── + +/** + * Map Node's process.platform to the sandbox-runtime platform string. + * @returns {'linux'|'macos'|'other'} + */ +function getPlatformName() { + const p = osPlatform(); + if (p === 'linux') return 'linux'; + if (p === 'darwin') return 'macos'; + return 'other'; +} + +// ── Library introspection ───────────────────────────────────────────────── + +/** + * Attempt to import @anthropic-ai/sandbox-runtime and call its exported + * isSupportedPlatform + checkDependencies. Returns structured findings. + * Never throws — all errors become { libError: }. + * + * @returns {Promise<{ + * libLoaded: boolean, + * libError: string|null, + * isSupportedPlatform: boolean, + * libDependencyErrors: string[], + * libDependencyWarnings: string[], + * }>} + */ +async function probeLibrary() { + try { + const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime'); + + let supportedPlatform = false; + try { + supportedPlatform = SandboxManager.isSupportedPlatform(); + } catch (e) { + return { + libLoaded: true, + libError: `isSupportedPlatform() threw: ${e?.message ?? e}`, + isSupportedPlatform: false, + libDependencyErrors: [], + libDependencyWarnings: [], + }; + } + + // checkDependencies() requires initialize() to have been called first to + // set ripgrep/bwrap/socat config. Since PR-A never calls initialize(), we + // call checkDependencies() with an undefined argument — the library falls + // back to { command: 'rg' } for ripgrep and PATH lookup for bwrap/socat, + // which is exactly what we want for the doctor preflight. + let libDependencyErrors = []; + let libDependencyWarnings = []; + if (supportedPlatform) { + try { + const depCheck = SandboxManager.checkDependencies(undefined); + libDependencyErrors = depCheck?.errors ?? []; + libDependencyWarnings = depCheck?.warnings ?? []; + } catch (e) { + // checkDependencies() can throw before initialize() — not fatal + libDependencyErrors = [`checkDependencies() threw: ${e?.message ?? e}`]; + } + } + + return { + libLoaded: true, + libError: null, + isSupportedPlatform: supportedPlatform, + libDependencyErrors, + libDependencyWarnings, + }; + } catch (e) { + return { + libLoaded: false, + libError: `@anthropic-ai/sandbox-runtime import failed: ${e?.message ?? e}`, + isSupportedPlatform: false, + libDependencyErrors: [], + libDependencyWarnings: [], + }; + } +} + +// ── Public API ──────────────────────────────────────────────────────────── + +/** + * Check sandbox availability (OS deps + library platform support). + * + * Returns: + * { + * available: boolean, // true only when all hard deps pass on a supported platform + * missing: string[], // friendly names of missing hard deps (e.g. 'bubblewrap', 'socat') + * details: { + * platform: string, // 'linux'|'macos'|'other' + * bwrap: boolean, // which bwrap → found + * socat: boolean, // which socat → found + * ripgrep: boolean, // which rg → found + * isSupportedPlatform: boolean, + * libLoaded: boolean, + * libError: string|null, + * libDependencyErrors: string[], + * libDependencyWarnings: string[], + * } + * } + * + * The `missing` array uses human-readable package names ('bubblewrap', 'socat', + * 'ripgrep') so that install hints are directly actionable. + * + * Does NOT call SandboxManager.initialize() — pure inspection only. + * Does NOT cache — the caller (server.mjs) memoizes the result. + */ +export async function checkSandboxAvailability() { + const platform = getPlatformName(); + + // Probe OS-level deps independently of the library (the `which` calls are + // cheap and always correct; library's checkDependencies may be less precise + // when initialize() hasn't been called). + const bwrap = isInPath('bwrap'); + const socat = isInPath('socat'); + const ripgrep = isInPath('rg'); + + // Library introspection (import + isSupportedPlatform + checkDependencies) + const lib = await probeLibrary(); + + // Determine what's missing for the doctor report. + // Only report OS deps as missing on Linux (where bwrap/socat/rg are required); + // macOS uses sandbox-exec which is built-in, so these are not hard requirements. + const missing = []; + if (platform === 'linux') { + if (!bwrap) missing.push('bubblewrap'); + if (!socat) missing.push('socat'); + if (!ripgrep) missing.push('ripgrep'); + } + // If the library itself failed to load, that's also a blocker + if (!lib.libLoaded) { + missing.push('@anthropic-ai/sandbox-runtime (import failed)'); + } + // Library-reported hard dep errors (may overlap with our `which` probes; + // deduplicate by treating them as additional evidence rather than re-adding) + for (const errMsg of lib.libDependencyErrors) { + // Only add if it doesn't overlap with what we already reported + const isAlreadyCovered = + (errMsg.includes('bwrap') && !bwrap) || + (errMsg.includes('socat') && !socat) || + (errMsg.includes('ripgrep') && !ripgrep) || + (errMsg.includes('Unsupported platform')); + if (!isAlreadyCovered && !missing.includes(errMsg)) { + missing.push(errMsg); + } + } + + const available = + lib.libLoaded && + lib.isSupportedPlatform && + missing.length === 0; + + return { + available, + missing, + details: { + platform, + bwrap, + socat, + ripgrep, + isSupportedPlatform: lib.isSupportedPlatform, + libLoaded: lib.libLoaded, + libError: lib.libError, + libDependencyErrors: lib.libDependencyErrors, + libDependencyWarnings: lib.libDependencyWarnings, + }, + }; +} + +/** + * Human-readable sandbox status summary for /health and CLI consumers. + * + * Returns: + * { + * ok: boolean, // same as checkSandboxAvailability().available + * message: string, // multi-line, includes install hint when deps are missing + * } + * + * Does NOT call SandboxManager.initialize() — pure inspection only. + */ +export async function describeSandboxStatus() { + const result = await checkSandboxAvailability(); + const { available, missing, details } = result; + + if (available) { + return { + ok: true, + message: + `Sandbox available on ${details.platform}` + + (details.libDependencyWarnings.length > 0 + ? `. Warnings: ${details.libDependencyWarnings.join('; ')}` + : '.'), + }; + } + + // Build a friendly explanation + const lines = []; + + if (!details.libLoaded) { + lines.push(`Sandbox library not available: ${details.libError ?? 'import failed'}`); + } else if (!details.isSupportedPlatform) { + lines.push( + `Sandbox dependencies not available: platform '${details.platform}' is not supported by @anthropic-ai/sandbox-runtime v0.0.52.`, + ); + } else { + // Platform is supported but OS deps are missing + const pkgNames = missing.filter(m => !m.includes('import failed')); + if (pkgNames.length > 0) { + lines.push(`Sandbox dependencies not available: ${pkgNames.map(m => `${m} not installed`).join(', ')}.`); + } + } + + // Install hint (only for Linux; macOS sandbox uses sandbox-exec which is built-in) + if (details.platform === 'linux' && (missing.includes('bubblewrap') || missing.includes('socat') || missing.includes('ripgrep'))) { + const aptPkgs = []; + if (missing.includes('bubblewrap')) aptPkgs.push('bubblewrap'); + if (missing.includes('socat')) aptPkgs.push('socat'); + if (missing.includes('ripgrep')) aptPkgs.push('ripgrep'); + lines.push( + `Install on Debian/Ubuntu/Raspbian: sudo apt-get install -y ${aptPkgs.join(' ')}`, + ); + } + + // macOS note (PR-A does not wire macOS sandbox-exec; PR-B will) + if (details.platform === 'macos') { + lines.push( + 'macOS: sandbox-exec is built-in, but anthropic provider wrapping lands in PR-B. ' + + 'macOS sandbox integration is not yet wired in this PR (PR-A). ', + ); + } + + if (details.libDependencyWarnings.length > 0) { + lines.push(`Warnings: ${details.libDependencyWarnings.join('; ')}`); + } + + return { + ok: false, + message: lines.join('\n'), + }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..36f12c3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,89 @@ +{ + "name": "olp", + "version": "0.5.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "olp", + "version": "0.5.1", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.52" + }, + "bin": { + "olp": "bin/olp.mjs", + "olp-audit-rotate": "bin/olp-audit-rotate.mjs", + "olp-connect": "bin/olp-connect", + "olp-keys": "bin/olp-keys.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@anthropic-ai/sandbox-runtime": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.52.tgz", + "integrity": "sha512-vYaM7OslFmOAzNgfy5gxvt3NoWFeCbr7C0AKyuduQq7Gdxbg2NnYmE7deBf8Nxj3ZNECTcC5RhAfz0lZwvbtBA==", + "license": "Apache-2.0", + "dependencies": { + "@pondwader/socks5-server": "^1.0.10", + "commander": "^12.1.0", + "node-forge": "^1.4.0", + "shell-quote": "^1.8.3", + "zod": "^3.24.1" + }, + "bin": { + "srt": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@pondwader/socks5-server": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index c72d453..334bc72 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,8 @@ "mistral", "fallback", "cache" - ] + ], + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.52" + } } diff --git a/server.mjs b/server.mjs index 423ffd1..9371b35 100644 --- a/server.mjs +++ b/server.mjs @@ -70,6 +70,11 @@ import { ENV_OWNER_KEY_ID, } from './lib/keys.mjs'; import { appendAuditEvent } from './lib/audit.mjs'; +// Phase 7 / PR-A — sandbox availability preflight module (ADR 0014). +// checkSandboxAvailability is called lazily at first /health hit and memoized +// process-wide (bwrap/socat install state does not change at runtime; we don't +// want a child_process.execFileSync per /health call). +import { checkSandboxAvailability } from './lib/sandbox/doctor.mjs'; // Phase 3 / D50 — management endpoints consume the audit aggregate query layer. // D81 (Phase 5) — adds aggregateProviderQuota for quota_v2 shape. import { @@ -221,6 +226,20 @@ export function __resetRequestCounters() { _activeRequests = 0; } +// ── Phase 7 PR-A: sandbox availability cache ────────────────────────────── +// checkSandboxAvailability() forks `which bwrap` / `which socat` / `which rg` +// and imports @anthropic-ai/sandbox-runtime. Neither can change at runtime — +// bwrap is either installed or it isn't. Memoize the first result to avoid +// repeated child_process.execFileSync calls on every /health hit. +// +// _sandboxStatusCache: null → not yet fetched +// object → memoized result from checkSandboxAvailability() +let _sandboxStatusCache = null; +/** @internal — test seam: reset sandbox cache between tests. */ +export function __resetSandboxStatusCache() { + _sandboxStatusCache = null; +} + // ── Startup config ──────────────────────────────────────────────────────── // Read ~/.olp/config.json once at startup. Provides: // - providers.enabled → which providers are loaded (ADR 0002 § Disable model) @@ -859,10 +878,48 @@ async function handleHealth(req, res) { providerStatuses[name] = { ok: false, error: e.message, activeSpawns }; } } + // Phase 7 PR-A (ADR 0014): sandbox availability field. + // Result is memoized process-wide in _sandboxStatusCache — bwrap/socat + // install state does not change at runtime. If the library call throws for + // any reason, the field is still included with available: false + error + // (don't crash /health). + if (_sandboxStatusCache === null) { + try { + _sandboxStatusCache = await checkSandboxAvailability(); + } catch (e) { + _sandboxStatusCache = { + available: false, + missing: [], + details: { platform: process.platform, error: String(e?.message ?? e) }, + }; + } + } + const sandboxField = { + available: _sandboxStatusCache.available, + missing: _sandboxStatusCache.missing ?? [], + platform: _sandboxStatusCache.details?.platform ?? process.platform, + }; + if (!_sandboxStatusCache.available) { + // Include human-readable install hint for owner-tier callers. + const missingDeps = (_sandboxStatusCache.missing ?? []).filter( + m => m === 'bubblewrap' || m === 'socat' || m === 'ripgrep', + ); + if (missingDeps.length > 0) { + sandboxField.message = + `Sandbox dependencies not available: ${missingDeps.map(m => `${m} not installed`).join(', ')}.` + + ` Install: sudo apt-get install -y ${missingDeps.join(' ')}`; + } else if (_sandboxStatusCache.details?.error) { + sandboxField.message = `Sandbox check error: ${_sandboxStatusCache.details.error}`; + } else if (_sandboxStatusCache.details?.libError) { + sandboxField.message = `Sandbox library error: ${_sandboxStatusCache.details.libError}`; + } + } + const fullPayload = { ok: true, version: VERSION, providers: { enabled, available, status: providerStatuses }, + sandbox: sandboxField, }; if (anonymousKey !== null) fullPayload.anonymousKey = anonymousKey; sendJSON(res, 200, fullPayload); diff --git a/test-features.mjs b/test-features.mjs index c227f59..ffefea8 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -4453,7 +4453,12 @@ import { __clearRecentErrors, __snapshotRecentErrors, __resetRequestCounters, + __resetSandboxStatusCache, } from './server.mjs'; +import { + checkSandboxAvailability, + describeSandboxStatus, +} from './lib/sandbox/doctor.mjs'; // ── Phase 2 / D45+D46 server-side default override ──────────────────────── // Override the production-off defaults so that existing pre-D45 HTTP @@ -17791,3 +17796,219 @@ describe('Suite 41 — ADR 0009 Amendment 1: stream-json transport (Phase 6)', ( } }); }); + +// ── Suite 42 — Phase 7 PR-A: lib/sandbox/doctor.mjs + /health.sandbox ──────── +// +// Tests for: checkSandboxAvailability shape, graceful lib-import failure, +// describeSandboxStatus install hint + positive message, +// and /health endpoint sandbox field inclusion. +// +// Authority: +// @anthropic-ai/sandbox-runtime v0.0.52 +// https://github.com/anthropic-experimental/sandbox-runtime +// OLP ADR 0014 — Sandbox-Runtime Integration (Phase 7 PR-A) +// +// Note: these tests run on Mac mini (dev machine, no bwrap). The expected +// outcome for all live probes is available=false with missing=['bubblewrap', +// 'socat', 'ripgrep'] (Linux-only deps). We do NOT mock `which` in most tests +// because the real expected result on macOS is "unavailable" anyway — testing +// the real path is more valuable than mocking it to pass. +// For tests that need specific outcomes (describeSandboxStatus positive path), +// we construct mock return values by directly testing the message-formatting +// logic using a controlled availability result. + +describe('Suite 42 — Phase 7 PR-A: lib/sandbox/doctor.mjs + /health.sandbox', () => { + + // ── 42a: checkSandboxAvailability returns correct shape ────────────── + + it('42a: checkSandboxAvailability returns { available, missing, details } shape', async () => { + const result = await checkSandboxAvailability(); + assert.ok(typeof result === 'object' && result !== null, 'result must be an object'); + assert.ok(typeof result.available === 'boolean', 'result.available must be boolean'); + assert.ok(Array.isArray(result.missing), 'result.missing must be an array'); + assert.ok(typeof result.details === 'object' && result.details !== null, 'result.details must be an object'); + + // details sub-fields + assert.ok(typeof result.details.platform === 'string', 'details.platform must be a string'); + assert.ok(typeof result.details.bwrap === 'boolean', 'details.bwrap must be boolean'); + assert.ok(typeof result.details.socat === 'boolean', 'details.socat must be boolean'); + assert.ok(typeof result.details.ripgrep === 'boolean', 'details.ripgrep must be boolean'); + assert.ok(typeof result.details.isSupportedPlatform === 'boolean', 'details.isSupportedPlatform must be boolean'); + assert.ok(typeof result.details.libLoaded === 'boolean', 'details.libLoaded must be boolean'); + assert.ok('libError' in result.details, 'details.libError must be present (null or string)'); + assert.ok(Array.isArray(result.details.libDependencyErrors), 'details.libDependencyErrors must be array'); + assert.ok(Array.isArray(result.details.libDependencyWarnings), 'details.libDependencyWarnings must be array'); + }); + + // ── 42b: on macOS (dev machine), bwrap/socat not installed ─────────── + + it('42b: on macOS, available=false because bwrap/socat absent (or unsupported platform)', async () => { + const result = await checkSandboxAvailability(); + // On macOS, either isSupportedPlatform=true (macOS IS supported) but + // bwrap+socat are not installed, or isSupportedPlatform=false. Either way: + // available must be false on the CI/dev Mac (no bwrap/socat) and the + // missing array must be an array (possibly empty if unsupported platform). + // + // On Linux CI with bwrap/socat installed this would be true — but our + // current CI (Mac mini) doesn't have them. We guard for both paths. + if (result.details.platform === 'linux') { + // On Linux without bwrap/socat: available=false, missing includes them + if (!result.details.bwrap || !result.details.socat) { + assert.equal(result.available, false, 'Linux without bwrap/socat → available must be false'); + } + } else { + // macOS: available is false on dev machines; the platform IS supported + // but bwrap/socat are not present. We just verify the shape is consistent. + assert.equal(typeof result.available, 'boolean'); + } + // In all cases: if available=true then missing must be empty + if (result.available) { + assert.deepEqual(result.missing, [], 'When available=true, missing must be []'); + } + }); + + // ── 42c: library import succeeds (package is installed) ────────────── + + it('42c: @anthropic-ai/sandbox-runtime library loads cleanly (libLoaded=true)', async () => { + const result = await checkSandboxAvailability(); + // We just installed v0.0.52 as a dep, so import must succeed. + assert.equal(result.details.libLoaded, true, + 'Library must load: @anthropic-ai/sandbox-runtime is in dependencies'); + assert.equal(result.details.libError, null, + 'libError must be null when library loads successfully'); + }); + + // ── 42d: describeSandboxStatus returns { ok, message } shape ───────── + + it('42d: describeSandboxStatus returns { ok: boolean, message: string }', async () => { + const status = await describeSandboxStatus(); + assert.ok(typeof status === 'object' && status !== null, 'result must be an object'); + assert.ok(typeof status.ok === 'boolean', 'ok must be boolean'); + assert.ok(typeof status.message === 'string', 'message must be a string'); + assert.ok(status.message.length > 0, 'message must be non-empty'); + }); + + // ── 42e: describeSandboxStatus includes install hint when bwrap/socat missing ── + // On Linux without deps, the message must include the apt-get install hint. + // On macOS, we verify the message is non-empty and describes the situation. + + it('42e: describeSandboxStatus includes apt-get install hint when linux deps missing', async () => { + const availability = await checkSandboxAvailability(); + + if (availability.details.platform === 'linux' && !availability.available) { + // Real Linux path — verify the live message includes the install hint + const status = await describeSandboxStatus(); + assert.equal(status.ok, false); + if (availability.missing.some(m => ['bubblewrap', 'socat', 'ripgrep'].includes(m))) { + assert.ok( + status.message.includes('sudo apt-get install'), + `Message must include install hint when deps are missing. Got: ${status.message}`, + ); + } + } else { + // macOS (dev machine): message must be non-empty and informative + const status = await describeSandboxStatus(); + assert.ok(typeof status.message === 'string' && status.message.length > 0, + 'describeSandboxStatus must return a non-empty message'); + } + }); + + // ── 42f: checkSandboxAvailability consistent across multiple calls ──── + // Verifies the function is idempotent (important because server.mjs + // memoizes the first result — both calls must return the same data). + + it('42f: checkSandboxAvailability is idempotent — two calls return the same available value', async () => { + const r1 = await checkSandboxAvailability(); + const r2 = await checkSandboxAvailability(); + assert.equal(r1.available, r2.available, 'available must be consistent across calls'); + assert.equal(r1.details.platform, r2.details.platform, 'platform must be consistent'); + assert.equal(r1.details.libLoaded, r2.details.libLoaded, 'libLoaded must be consistent'); + }); + + // ── 42g: /health endpoint includes sandbox field ────────────────────── + // HTTP integration test: start the server (reusing the createOlpServer factory) + // and verify /health includes a top-level 'sandbox' key with the right shape. + + let _s42Server; + let _s42Port; + + before(async () => { + __resetSandboxStatusCache(); + __setAuthConfig({ + allow_anonymous: true, + owner_only_endpoints: [], + fallback_detail_header_policy: 'all', + }); + _s42Port = parseInt( + process.env.OLP_TEST_PORT + ? String(parseInt(process.env.OLP_TEST_PORT, 10) + 9000) + : String(22456 + Math.floor(Math.random() * 100)), + 10, + ); + _s42Server = createOlpServer(); + await new Promise((resolve, reject) => { + _s42Server.listen(_s42Port, '127.0.0.1', resolve); + _s42Server.once('error', (e) => { + if (e.code === 'EADDRINUSE') { + _s42Port++; + _s42Server.listen(_s42Port, '127.0.0.1', resolve); + _s42Server.once('error', reject); + } else { + reject(e); + } + }); + }); + }); + + after(() => new Promise(r => _s42Server.close(r))); + + it('42g: GET /health includes sandbox field with available+missing+platform', async () => { + const r = await fetch({ port: _s42Port, method: 'GET', path: '/health' }); + assert.equal(r.status, 200, `/health must return 200, got ${r.status}`); + const body = JSON.parse(r.body); + + // Top-level sandbox key must exist + assert.ok('sandbox' in body, '/health must include top-level sandbox field'); + const sb = body.sandbox; + + // Required fields + assert.ok(typeof sb.available === 'boolean', 'sandbox.available must be boolean'); + assert.ok(Array.isArray(sb.missing), 'sandbox.missing must be array'); + assert.ok(typeof sb.platform === 'string', 'sandbox.platform must be a string'); + + // Consistency: if available=false there may be a message + if (!sb.available && sb.missing.length > 0) { + assert.ok(typeof sb.message === 'string' || sb.message === undefined, + 'sandbox.message must be a string or absent'); + } + if (sb.available) { + assert.deepEqual(sb.missing, [], 'When available=true, missing must be []'); + } + }); + + it('42h: /health sandbox field reflects real platform state (not hardcoded)', async () => { + // Verify the /health sandbox field matches what checkSandboxAvailability + // returns directly — confirming the two code paths are consistent. + // + // On macOS: @anthropic-ai/sandbox-runtime uses sandbox-exec (built-in), + // so available=true when rg is also in PATH. This is the expected state + // on the dev Mac with ripgrep installed via Homebrew. + // + // On Linux (PI231 before apt install): available=false because bwrap/socat + // are missing. This is the expected state Phase 7 PR-A is documenting. + // + // Both paths are correct; the test verifies /health matches the module. + const libResult = await checkSandboxAvailability(); + const r = await fetch({ port: _s42Port, method: 'GET', path: '/health' }); + const body = JSON.parse(r.body); + const sb = body.sandbox; + + // /health sandbox.available must match the module's result + // Note: the server memoizes on first call; Suite 42 calls __resetSandboxStatusCache() + // in before(), so both are calling into the same (fresh) state. + assert.equal(sb.available, libResult.available, + `/health sandbox.available (${sb.available}) must match checkSandboxAvailability() (${libResult.available})`); + assert.equal(sb.platform, libResult.details.platform, + '/health sandbox.platform must match checkSandboxAvailability().details.platform'); + }); +});