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>
This commit is contained in:
2026-05-28 17:26:34 +10:00
co-authored by Claude Sonnet 4.6
parent 07d9c8a6ae
commit d0dcd281ef
6 changed files with 753 additions and 6 deletions
+28
View File
@@ -75,6 +75,11 @@ import { appendAuditEvent } from './lib/audit.mjs';
// 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 7 / PR-B — sandbox manager bootstrap + spawn-wrap (ADR 0014 § PR-B).
// bootstrapSandbox() is called at server startup (before listen) and sets up
// the process-wide SandboxManager singleton. isSandboxActive() is used by
// /health to report sandbox.active.
import { bootstrapSandbox, isSandboxActive, __resetSandboxManagerForTests } from './lib/sandbox/manager.mjs';
// Phase 3 / D50 — management endpoints consume the audit aggregate query layer.
// D81 (Phase 5) — adds aggregateProviderQuota for quota_v2 shape.
import {
@@ -896,6 +901,11 @@ async function handleHealth(req, res) {
}
const sandboxField = {
available: _sandboxStatusCache.available,
// Phase 7 PR-B: active = sandbox was bootstrapped and SandboxManager is
// ready to wrap spawns. available=true + active=true means every provider
// spawn is actually sandboxed. available=true + active=false means deps
// present but bootstrap failed at runtime (see server startup log).
active: isSandboxActive(),
missing: _sandboxStatusCache.missing ?? [],
platform: _sandboxStatusCache.details?.platform ?? process.platform,
};
@@ -2358,6 +2368,8 @@ export function createOlpServer() {
}
export { router, loadedProviders, VERSION };
// Phase 7 PR-B: re-export sandbox manager test seam so tests can reset state.
export { __resetSandboxManagerForTests };
// Main guard: only listen when invoked as the entrypoint. ESM equivalent of
// `require.main === module` is comparing import.meta.url against argv[1].
@@ -2370,6 +2382,22 @@ const isMain = (() => {
})();
if (isMain) {
// Phase 7 PR-B (ADR 0014 § PR-B): bootstrap sandbox before listening.
// bootstrapSandbox() is idempotent + error-safe — server always starts even
// if sandbox initialization fails (degrades to unsandboxed, logs a warning).
// The /health.sandbox.active field reflects the result.
const sandboxBoot = await bootstrapSandbox();
if (sandboxBoot.active) {
process.stdout.write(
`OLP sandbox active (config-at-boot): ${sandboxBoot.summary}\n`,
);
} else {
process.stderr.write(
`OLP sandbox NOT active: ${sandboxBoot.reason}` +
`provider spawns will run UNSANDBOXED (test/dev only; not safe for cloud)\n`,
);
}
const server = createOlpServer();
server.listen(PORT, BIND, () => {
const enabledCount = loadedProviders.size;