Files
olp/lib/sandbox/manager.mjs
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

366 lines
15 KiB
JavaScript

/**
* lib/sandbox/manager.mjs — Sandbox manager bootstrap + spawn-wrap (Phase 7 PR-B)
*
* Authority:
* @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() (used internally)
*
* 2026-05-28 PR-A spike report on PI231 (arm64 Debian Bookworm):
* /tmp/sandbox-spike/spike-anthropic.mjs — wrapWithSandbox call signature,
* CLAUDE_CODE_OAUTH_TOKEN env passthrough, shell-mode spawn pattern.
* OLP ADR 0014 § Decision (singleton at boot) + § PR-B specific scope
* OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
* cc-mem incident 2026-05-27 § 3 (multi-tenant security gap motivation)
* ALIGNMENT.md Rule 1 — provider plugin authority citation
*
* Design:
* One-shot bootstrap at server startup (idempotent). If sandbox not available
* (doctor.available=false or SandboxManager.initialize throws), bootstrap is a
* no-op and isSandboxActive() returns false → provider falls back to direct spawn
* (transparent pass-through).
*
* Singleton pattern: SandboxManager is a process-wide singleton per library
* design (reset() clears ALL state). PR-B initializes once at boot with union
* config (Anthropic domains only; codex config follows in PR-C). Per-request
* wrapSpawn() calls SandboxManager.wrapWithSandbox() which reads from the
* already-initialized config state — no per-request initialize().
*
* ADR 0014 § Pitfalls #4: SandboxManager.reset() in test teardown must happen
* in finally blocks; concurrent in-flight spawns may break if reset fires while
* a wrapWithSandbox call is in-flight. OLP's current single-server model (one
* process) makes this safe: tests call __resetSandboxManagerForTests() which
* also calls SandboxManager.reset() — only safe in test context where no real
* spawns are in-flight.
*
* Exports:
* bootstrapSandbox(opts?) — one-shot bootstrap; returns { active, reason?, summary? }
* isSandboxActive() — synchronous query
* wrapSpawn({ bin, args, env, cwd, allowedDomains })
* — wraps spawn args; transparent pass-through when inactive
* __resetSandboxManagerForTests() — test seam: reset internal state + SandboxManager
*/
import { createHash } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { checkSandboxAvailability } from './doctor.mjs';
// ── Internal state ────────────────────────────────────────────────────────
/**
* Whether bootstrapSandbox() has been called (initialized = true means we
* ran through bootstrap, not necessarily that sandbox is active).
* @type {boolean}
*/
let _initialized = false;
/**
* Whether the SandboxManager was successfully initialized and is ready to wrap.
* @type {boolean}
*/
let _active = false;
/**
* The config-at-boot snapshot passed to SandboxManager.initialize().
* Null if never initialized or bootstrap failed.
* @type {object|null}
*/
let _initConfig = null;
// ── Ephemeral workspace root ─────────────────────────────────────────────
// Per-request cwd: /tmp/olp-spawn/<uuid>/ — unique per request to prevent
// cross-request contamination. Caller (provider) owns cleanup (or trusts tmpfs
// lifetime). Created by mkdirSync(recursive:true) inside wrapSpawn().
const SPAWN_BASE_DIR = '/tmp/olp-spawn';
// ── Custom error types ───────────────────────────────────────────────────
export class SandboxBootstrapError extends Error {
constructor(message) {
super(message);
this.name = 'SandboxBootstrapError';
}
}
export class SandboxWrapError extends Error {
constructor(message) {
super(message);
this.name = 'SandboxWrapError';
}
}
// ── bootstrapSandbox ──────────────────────────────────────────────────────
/**
* One-shot bootstrap of the sandbox. Idempotent — safe to call multiple times.
* If already bootstrapped, returns cached result immediately.
*
* Steps:
* 1. Call checkSandboxAvailability() from doctor module.
* 2. If !available → set _active=false, return { active:false, reason }.
* 3. If available → build config-at-boot, call SandboxManager.initialize(config).
* 4. On init success → _active=true, return { active:true, summary }.
* 5. On init failure → log + _active=false + return error (server still starts).
*
* The network allowedDomains covers the Anthropic provider only (PR-B scope).
* Codex domains will be added in PR-C alongside the enableWeakerNestedSandbox flag.
*
* ADR 0014 § PR-B: denyRead covers ~/.olp, ~/.claude, ~/.ssh, ~/.config, ~/.codex
* using absolute literal Linux paths (no globs — see ADR 0014 § Pitfalls #2).
* ~/.olp contains keys.json (OLP API keys). ~/.claude contains OAuth credentials.
* ~/.ssh and ~/.config contain identity material. ~/.codex contains codex config.
*
* @param {object} [opts]
* @param {boolean} [opts.force=false] — if true, re-run bootstrap even if already initialized
* @returns {Promise<{ active: boolean, reason?: string, summary?: string }>}
*/
export async function bootstrapSandbox(opts = {}) {
// Return cached result if already initialized (unless forced)
if (_initialized && !opts.force) {
return _active
? { active: true, summary: _buildSummary() }
: { active: false, reason: _initConfig?.failReason ?? 'sandbox not available' };
}
// Reset state for re-bootstrap
_initialized = false;
_active = false;
_initConfig = null;
// Step 1: Check OS + library availability
let availability;
try {
availability = await checkSandboxAvailability();
} catch (e) {
_initialized = true;
_active = false;
_initConfig = { failReason: `doctor check threw: ${e?.message ?? e}` };
return { active: false, reason: _initConfig.failReason };
}
if (!availability.available) {
_initialized = true;
_active = false;
const reason = availability.missing.length > 0
? `sandbox deps missing: ${availability.missing.join(', ')}`
: `sandbox not available on platform: ${availability.details?.platform}`;
_initConfig = { failReason: reason };
return { active: false, reason };
}
// Step 2: Build config-at-boot
// Network allowedDomains: Anthropic provider API domains (PR-B scope).
// - api.anthropic.com: primary Anthropic API endpoint
// - statsig.anthropic.com: claude CLI telemetry (verified empirically in spike;
// required by claude CLI OAuth token refresh path — removing it causes auth failure)
// TODO(PR-C): union in codex/openai provider domains when codex wrap lands.
const allowedDomains = [
'api.anthropic.com',
'statsig.anthropic.com',
];
const home = homedir();
// denyRead: Absolute literal Linux paths per ADR 0014 § Pitfalls #2.
// No ~ or glob — ripgrep glob expansion is not used here to stay safe on
// both Linux (bwrap) and macOS (sandbox-exec profile).
const denyRead = [
join(home, '.olp'), // OLP API keys + config
join(home, '.claude'), // Claude OAuth credentials
join(home, '.ssh'), // SSH identity material
join(home, '.config'), // Generic config dir (may contain tokens)
join(home, '.codex'), // Codex config (PR-C will wrap codex)
];
// allowWrite: ephemeral spawn workspace only. mkdirSync at bootstrap.
// getDefaultWritePaths() adds /dev/stdout, /dev/null etc. internally.
try {
mkdirSync(SPAWN_BASE_DIR, { recursive: true });
} catch (e) {
// Non-fatal: if this dir can't be created, wrapSpawn will fail per-request.
console.warn(`[sandbox/manager] Warning: could not create ${SPAWN_BASE_DIR}: ${e?.message}`);
}
const config = {
network: {
allowedDomains,
deniedDomains: [],
},
filesystem: {
denyRead,
allowWrite: [SPAWN_BASE_DIR, '/tmp'],
denyWrite: [],
},
};
// Step 3: Initialize SandboxManager
let SandboxManager;
try {
const mod = await import('@anthropic-ai/sandbox-runtime');
SandboxManager = mod.SandboxManager;
} catch (e) {
_initialized = true;
_active = false;
_initConfig = { failReason: `sandbox-runtime import failed: ${e?.message ?? e}` };
return { active: false, reason: _initConfig.failReason };
}
try {
// ADR 0014 § Pitfalls #5: initialize() generates MITM CA cert (~100-500ms).
// Must happen at boot, not per-request.
await SandboxManager.initialize(config);
_initialized = true;
_active = true;
_initConfig = { config, SandboxManager };
return { active: true, summary: _buildSummary() };
} catch (e) {
_initialized = true;
_active = false;
const reason = `SandboxManager.initialize failed: ${e?.message ?? e}`;
_initConfig = { failReason: reason };
// Log but DO NOT throw — server still starts in unsandboxed mode.
// PR-D will add hard-fail mode via config flag.
console.warn(`[sandbox/manager] WARNING: ${reason} — provider spawns will run UNSANDBOXED`);
return { active: false, reason };
}
}
/** @internal — returns summary string for logging */
function _buildSummary() {
const cfg = _initConfig?.config;
if (!cfg) return 'active (no config)';
const domains = (cfg.network?.allowedDomains ?? []).join(', ');
return `network allowlist=[${domains}], denyRead=[${(cfg.filesystem?.denyRead ?? []).length} paths], allowWrite=[${SPAWN_BASE_DIR}, /tmp]`;
}
// ── isSandboxActive ───────────────────────────────────────────────────────
/**
* Synchronous query of bootstrap state.
* Returns true only if bootstrapSandbox() completed successfully.
* Used by provider plugins to decide spawn path.
*
* @returns {boolean}
*/
export function isSandboxActive() {
return _active;
}
// ── wrapSpawn ─────────────────────────────────────────────────────────────
/**
* Wrap a spawn command + args for sandbox execution.
*
* Returns { bin, args, env, cwd, sandboxed: boolean }.
* - If sandbox inactive: returns inputs unchanged with sandboxed:false.
* - If sandbox active: returns the wrapped shell string as
* { bin: '/bin/sh', args: ['-c', wrappedShellString], env, cwd, sandboxed:true }.
*
* The wrapped command is a shell string from SandboxManager.wrapWithSandbox().
* It must be spawned with shell:true OR by invoking /bin/sh -c <string> directly
* (the latter is what we do here — avoids relying on the shell that Node picks).
*
* Per-spawn ephemeral cwd uses a UUID to prevent cross-request contamination.
* The caller is responsible for cleanup (or trusts tmpfs lifetime).
*
* ADR 0014 § PR-B: env vars passed through unchanged so CLAUDE_CODE_OAUTH_TOKEN
* (if operator set at OLP boot time) still works inside the sandbox.
*
* @param {object} params
* @param {string} params.bin — original binary (e.g. 'claude')
* @param {string[]} params.args — original args
* @param {object} params.env — spawn environment (from buildSpawnEnv())
* @param {string} [params.cwd] — original cwd (ignored; replaced by ephemeral dir)
* @param {string[]} [params.allowedDomains] — per-spawn domain override (passed as customConfig)
* @returns {Promise<{ bin: string, args: string[], env: object, cwd: string, sandboxed: boolean }>}
*/
export async function wrapSpawn({ bin, args, env, cwd: _cwd, allowedDomains }) {
// Transparent pass-through when sandbox inactive
if (!_active || !_initConfig?.SandboxManager) {
return {
bin,
args: args ?? [],
env: env ?? {},
cwd: _cwd,
sandboxed: false,
};
}
const SandboxManager = _initConfig.SandboxManager;
// Build the shell command string from bin + args.
// Each arg is shell-quoted to handle spaces and special characters.
// Authority: spike-anthropic.mjs line 29-31 — same quoting pattern.
const quotedArgs = (args ?? []).map(a =>
/[\s"'`$\\;&|<>()\[\]{}!#~*?]/.test(a)
? `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')}"`
: a
);
const commandString = [bin, ...quotedArgs].join(' ');
// Per-spawn ephemeral cwd (UUID) — prevents cross-request contamination.
// ADR 0014 § PR-B: unique per request.
const reqId = createHash('sha256').update(`${Date.now()}-${Math.random()}`).digest('hex').slice(0, 16);
const spawnCwd = join(SPAWN_BASE_DIR, reqId);
try {
mkdirSync(spawnCwd, { recursive: true });
} catch (e) {
throw new SandboxWrapError(`Failed to create ephemeral spawn dir ${spawnCwd}: ${e?.message ?? e}`);
}
// Per-spawn customConfig: allow caller to override domains (e.g. different provider).
// Default: use the config-at-boot allowedDomains.
let customConfig;
if (allowedDomains && allowedDomains.length > 0) {
customConfig = {
network: {
allowedDomains,
deniedDomains: [],
},
};
}
let wrappedCommand;
try {
wrappedCommand = await SandboxManager.wrapWithSandbox(commandString, undefined, customConfig);
} catch (e) {
throw new SandboxWrapError(`SandboxManager.wrapWithSandbox failed: ${e?.message ?? e}`);
}
// Invoke via /bin/sh -c to avoid spawning a second shell layer.
// The wrapped command is already a complete shell invocation (bwrap args or
// sandbox-exec profile + the original command inside).
return {
bin: '/bin/sh',
args: ['-c', wrappedCommand],
env: env ?? {},
cwd: spawnCwd,
sandboxed: true,
};
}
// ── Test seam ─────────────────────────────────────────────────────────────
/**
* Reset internal state so test suite can simulate fresh process.
* Also calls SandboxManager.reset() if it was initialized (to clear singleton).
*
* ADR 0014 § Pitfalls #4: must only be called when no in-flight wrapSpawn calls
* are active. Safe in sequential test contexts.
*
* @returns {Promise<void>}
*/
export async function __resetSandboxManagerForTests() {
if (_active && _initConfig?.SandboxManager) {
try {
await _initConfig.SandboxManager.reset();
} catch { /* ignore — test teardown, best-effort */ }
}
_initialized = false;
_active = false;
_initConfig = null;
}