Files
olp/docs/adr/0014-sandbox-runtime-integration.md
T
taodengandClaude Sonnet 4.6 d0dcd281ef feat(sandbox): Phase 7 PR-B — anthropic.mjs spawn wrapped in sandbox-runtime
Wraps the claude CLI spawn in @anthropic-ai/sandbox-runtime per ADR 0014
§ PR-B. Achieves multi-tenant filesystem + network isolation: the spawned
claude subprocess can no longer read ~/.olp/keys.json, ~/.claude/.credentials.json,
~/.ssh/, or any other per-client OAuth material. Only api.anthropic.com and
statsig.anthropic.com are reachable; only /tmp/olp-spawn/<uuid>/ is writable
per spawn (ephemeral, UUID-scoped to prevent cross-request contamination).

## Authority citations

- @anthropic-ai/sandbox-runtime v0.0.52
  https://github.com/anthropic-experimental/sandbox-runtime
  dist/sandbox/sandbox-manager.js — SandboxManager.initialize(), wrapWithSandbox()
  dist/sandbox/sandbox-utils.js  — getDefaultWritePaths()
- 2026-05-28 spike report at /tmp/sandbox-spike/report.md on PI231:
  spike-anthropic.mjs (wrapWithSandbox call signature + NDJSON proof),
  spike-deny.mjs (cat ~/.olp/keys.json MUST fail)
- OLP ADR 0014 § 2.1 PR-B, § 4.1 PR-B acceptance criteria
- OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation
- cc-mem incident 2026-05-27 § 3 (multi-tenant OAuth token exposure via
  prompt injection)

## Files changed

- lib/sandbox/manager.mjs (new): bootstrap + spawn-wrap layer.
  Exports: bootstrapSandbox(), isSandboxActive(), wrapSpawn(),
  __resetSandboxManagerForTests(). Config-at-boot model (one
  SandboxManager.initialize() at server start; per-request wrapWithSandbox()
  reads from already-initialized state). Transparent pass-through when inactive.

- lib/providers/anthropic.mjs: spawn site wrapped via wrapSpawn({
  bin, args, env, allowedDomains: ['api.anthropic.com','statsig.anthropic.com']
  }). ADR 0009 Amendment 1 spawn args unchanged — only execution is wrapped.

- server.mjs: bootstrapSandbox() called before server.listen(); startup banner
  logs sandbox.active state. /health.sandbox now includes active:boolean field
  (distinguishes "deps present" from "SandboxManager initialized and wrapping").

- test-features.mjs: Suite 43 (8 tests — manager unit: bootstrap state, idempotency,
  isSandboxActive, wrapSpawn passthrough, /health.active field) + Suite 44
  (2 PI231-gated tests: 44a security negative test + 44b positive echo test,
  skipped by default, run with OLP_E2E_SANDBOX=1 npm test).

- CHANGELOG.md, docs/adr/0014-sandbox-runtime-integration.md: PR-B status
  updated; ADR table + /health JSON example updated with active field.

## Test count

805 (pre-PR-B) → 813 (+8 Suite 43; Suite 44 skipped on macOS, runs on PI231)
All 813 pass on macOS dev machine. 0 regressions.

## PI231 validation (required before merge — Suite 44)

After apt-get install bubblewrap socat (ripgrep already present) + server restart:

1. curl /health → confirm sandbox.available=true AND sandbox.active=true
2. Real anthropic request → confirm stream-json still works end-to-end
3. OLP_E2E_SANDBOX=1 npm test → confirm Suite 44a (cat ~/.olp/keys.json MUST fail)
   and Suite 44b (echo SANDBOX_PROOF succeeds)

Reviewer: must SSH PI231, run Suite 44, and confirm the ADR 0014 § 4.1 criteria.
This commit is ready to push; do NOT push before Suite 44 transcript is captured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:26:34 +10:00

21 KiB

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


  • 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/sandbox/manager.mjs (bootstrap + spawn-wrap) + lib/providers/anthropic.mjs spawn wrapped + server startup wiring + /health.sandbox.active + Suite 43/44 tests bubblewrap + socat + rg installed on PI231 (sudo apt-get install -y bubblewrap socat ripgrep) Implemented — pending PI231 validation (Suite 44) + opus reviewer
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

// 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: '<reason>' } 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.

{
  "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) and PR-B bootstrapped:

{
  "sandbox": {
    "available": true,
    "active": true,
    "missing": [],
    "platform": "linux"
  }
}

(PR-A shape did not include active. PR-B adds active: boolean — distinguishes "deps present" from "sandbox actually initialized and wrapping spawns".)


4. PR-B/C/D acceptance criteria

4.1 PR-B (anthropic.mjs spawn wrap) — Implementation shipped, PI231 validation pending

PR-B implementation (commit pending reviewer):

  • lib/sandbox/manager.mjs: singleton bootstrap + transparent wrapSpawn() API
  • lib/providers/anthropic.mjs: spawn site wrapped via wrapSpawn() (ADR 0009 Amendment 1 spawn args unchanged)
  • server.mjs: bootstrapSandbox() called before server.listen(), /health.sandbox.active field added
  • test-features.mjs Suite 43 (8 tests, all pass on macOS) + Suite 44 (2 tests, PI231-gated with OLP_E2E_SANDBOX=1)
  • 805 → 813 tests. Suite 44 skipped by default; runs on PI231 after apt install.

Load-bearing negative test (required for PR-B to merge):

# 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 <owner-key>" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"run: cat /home/<user>/.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/<user>/.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/<user>/.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.52https://github.com/anthropic-experimental/sandbox-runtime

    • dist/sandbox/sandbox-manager.jsisSupportedPlatform(), checkDependencies(), SandboxManager export shape
    • dist/sandbox/linux-sandbox-utils.jscheckLinuxDependencies(), 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.