mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat(sandbox): Phase 7 Solution 1 implementation + opus 4.8 (#68)
Implements ADR 0014 Amendment 1 (4-layer Solution 1) + ADR 0002 Amendment 9 (Provider ISOLATION contract) + opus 4.8 model. Fresh-context opus reviewer APPROVE_WITH_MINOR; 2 nit fold-ins applied. 813 unit tests pass. Known deferred coverage: Suite 44 PI231 E2E tests are placeholders under describe.skip pending Task #9 (PI231 prod-target validation). The load-bearing negative test ('in-sandbox cat ~/.olp/keys.json MUST fail') will be validated when Task #9 runs against the merged code. PR-B outer-bwrap superseded; archive at phase-7-pr-b-outer-bwrap-snapshot branch.
This commit is contained in:
+325
-280
@@ -1,291 +1,178 @@
|
||||
/**
|
||||
* lib/sandbox/manager.mjs — Sandbox manager bootstrap + spawn-wrap (Phase 7 PR-B)
|
||||
* lib/sandbox/manager.mjs — Sandbox manager + ephemeral-home orchestrator (Phase 7 PR-B')
|
||||
*
|
||||
* Authority:
|
||||
* OLP ADR 0014 Amendment 1 — Solution 1 four-layer architecture
|
||||
* § A1.2.1 — Layer 1: per-spawn ephemeral home directory
|
||||
* § A1.2.2 — Layer 2: symlinked credential files into ephemeral home
|
||||
* § A1.2.3 — Layer 3: optional sandbox-runtime per-call customConfig
|
||||
* § A1.6.1 — OLP_SANDBOX_DISABLED gate (preserved 1-2 releases)
|
||||
* OLP ADR 0002 Amendment 9 — Provider ISOLATION contract specification
|
||||
* § Field specification (ephemeralEnvOverrides, credentialMounts,
|
||||
* requiredHomePaths, hasInnerSandbox, toolHardeningArgs)
|
||||
* @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)
|
||||
* dist/sandbox/sandbox-manager.js — SandboxManager.wrapWithSandbox()
|
||||
* The third argument `customConfig` is the per-call override mechanism.
|
||||
* 2026-05-29 PI231 spike (docs/spikes/2026-05-29-ephemeral-home.md):
|
||||
* Verified HOME (claude) + CODEX_HOME (codex) redirect 100% of CLI state
|
||||
* writes into ephemeral location. Credentials via symlink work end-to-end.
|
||||
*
|
||||
* 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 (Amendment 1 architecture):
|
||||
*
|
||||
* 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).
|
||||
* Boot-time:
|
||||
* bootstrapSandbox() — checks sandbox-runtime library + OS deps availability
|
||||
* via doctor.mjs. Does NOT call SandboxManager.initialize() (per A1.2.3:
|
||||
* Layer 3 is per-call, not boot-singleton). The singleton pattern from PR-B
|
||||
* is removed entirely — per-spawn config eliminates its reason to exist.
|
||||
*
|
||||
* 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().
|
||||
* Per-spawn (uncached /v1/chat/completions request):
|
||||
* prepareIsolatedEnvironment({ provider, keyId, reqId }) — the main
|
||||
* orchestrator entry point. Reads provider.ISOLATION, composes Layers 1–3:
|
||||
* Layer 1: mkdir /tmp/olp-spawn/<keyId>/<reqId>/home
|
||||
* Layer 2: symlink credentialMounts into ephemeralRoot
|
||||
* Layer 3: wrapForLayer3 — when isSandboxActive() && !hasInnerSandbox,
|
||||
* calls SandboxManager.wrapWithSandbox() per-call with
|
||||
* per-spawn customConfig
|
||||
* Returns { ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup }.
|
||||
*
|
||||
* 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.
|
||||
* OLP_SANDBOX_DISABLED=1 (A1.6.1 belt-and-suspenders gate):
|
||||
* When set, Layers 1+2 still operate (ephemeral home + credential mounts).
|
||||
* Layer 3 (wrapForLayer3) becomes identity. Preserved for 1-2 releases.
|
||||
*
|
||||
* 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
|
||||
* bootstrapSandbox(opts?) — preflight check; returns { available, reason?, summary? }
|
||||
* isSandboxActive() — synchronous; true when Layer 3 is operational
|
||||
* prepareIsolatedEnvironment({ provider, keyId, reqId })
|
||||
* — compose Layers 1+2+3; returns env + hooks + cleanup
|
||||
* __resetSandboxManagerForTests() — test seam: reset module state
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, 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).
|
||||
* Whether bootstrapSandbox() has completed (initialized = true means bootstrap
|
||||
* ran; does NOT mean sandbox is active).
|
||||
* @type {boolean}
|
||||
*/
|
||||
let _initialized = false;
|
||||
|
||||
/**
|
||||
* Whether the SandboxManager was successfully initialized and is ready to wrap.
|
||||
* Whether the sandbox-runtime library is loaded and OS deps are present.
|
||||
* When true, Layer 3 (per-call wrapWithSandbox) is available.
|
||||
* @type {boolean}
|
||||
*/
|
||||
let _active = false;
|
||||
|
||||
/**
|
||||
* The config-at-boot snapshot passed to SandboxManager.initialize().
|
||||
* Null if never initialized or bootstrap failed.
|
||||
* Cached failure reason string (when _active=false after bootstrap).
|
||||
* @type {string|null}
|
||||
*/
|
||||
let _failReason = null;
|
||||
|
||||
/**
|
||||
* Memoized sandbox-runtime module (loaded lazily on first prepareIsolatedEnvironment
|
||||
* call that needs Layer 3). Import caching is native ESM semantics; this variable
|
||||
* holds the resolved SandboxManager class after first load.
|
||||
* @type {object|null}
|
||||
*/
|
||||
let _initConfig = null;
|
||||
let _SandboxManager = 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().
|
||||
// /tmp/olp-spawn/<keyId>/<reqId>/home — unique per (key, request).
|
||||
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.
|
||||
* Preflight check for Layer 3 capability (sandbox-runtime library + OS deps).
|
||||
* Idempotent — safe to call multiple times; returns cached result after first call.
|
||||
*
|
||||
* 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).
|
||||
* This function NO LONGER calls SandboxManager.initialize() at boot.
|
||||
* Per ADR 0014 Amendment 1 § A1.2.3, Layer 3 uses per-call wrapWithSandbox()
|
||||
* with a per-spawn customConfig; the singleton boot-init pattern is removed.
|
||||
*
|
||||
* 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.
|
||||
* The OLP_SANDBOX_DISABLED=1 env-var gate (A1.6.1): when set, Layer 3 is
|
||||
* disabled. Layers 1+2 (ephemeral home + credential mounts) still operate.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.force=false] — if true, re-run bootstrap even if already initialized
|
||||
* @param {boolean} [opts.force=false] — re-run even if already bootstrapped
|
||||
* @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' };
|
||||
: { active: false, reason: _failReason ?? 'sandbox not available' };
|
||||
}
|
||||
|
||||
// OLP_SANDBOX_DISABLED env-var gate (2026-05-28 PR-B emergency disable):
|
||||
// Live PI231 evidence showed that even with the exit-null guard, HTTP-path
|
||||
// anthropic spawns produced no claude stdout when wrapped (manual exec of
|
||||
// the SAME wrap script in the same process did produce output — root cause
|
||||
// not yet isolated; likely interaction between SandboxManager in-process
|
||||
// proxy sockets and OLP's request-handler event loop). Until the root cause
|
||||
// is debugged + Suite 44-equivalent E2E tests cover the HTTP path, the
|
||||
// sandbox bootstrap is opt-out via OLP_SANDBOX_DISABLED=1 in the server env.
|
||||
//
|
||||
// Default is sandbox-enabled (no env var = try-and-bootstrap). Sandbox is
|
||||
// skipped only when the operator explicitly disables.
|
||||
//
|
||||
// Future PR-B follow-up: investigate the in-process proxy lifecycle
|
||||
// interaction with OLP's HTTP server event loop; capture diagnostic
|
||||
// transcript; ship Suite 44-equivalent that exercises the full HTTP
|
||||
// request → sandbox spawn → response pipeline.
|
||||
// OLP_SANDBOX_DISABLED gate (A1.6.1): operator emergency disable.
|
||||
// Layer 3 skipped; Layers 1+2 unaffected (ephemeral home + credential mounts).
|
||||
if (process.env.OLP_SANDBOX_DISABLED === '1') {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
_initConfig = { failReason: 'OLP_SANDBOX_DISABLED=1 — sandbox bootstrap skipped by operator' };
|
||||
return {
|
||||
active: false,
|
||||
reason: 'OLP_SANDBOX_DISABLED=1 — sandbox bootstrap skipped by operator',
|
||||
};
|
||||
_failReason = 'OLP_SANDBOX_DISABLED=1 — Layer 3 (sandbox-runtime wrap) disabled by operator; Layers 1+2 still active';
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
// Reset state for re-bootstrap
|
||||
// Reset for re-bootstrap
|
||||
_initialized = false;
|
||||
_active = false;
|
||||
_initConfig = null;
|
||||
_failReason = null;
|
||||
|
||||
// Step 1: Check OS + library availability
|
||||
// Check OS + library availability via doctor
|
||||
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 };
|
||||
_failReason = `doctor check threw: ${e?.message ?? e}`;
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
if (!availability.available) {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
const reason = availability.missing.length > 0
|
||||
_failReason = 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 };
|
||||
return { active: false, reason: _failReason };
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
// 2026-05-28 PR-B fold-in: ~/.claude is NOT in denyRead. It contains the
|
||||
// spawn's own OAuth credentials — claude CLI must read its own auth file
|
||||
// to function. Denying read here causes "Not logged in" failures even
|
||||
// though the operator has valid credentials present.
|
||||
//
|
||||
// The cross-tenant risk for ~/.claude is mitigated by Phase 6c's
|
||||
// --system-prompt flag (ADR 0009 Amendment 1): the system prompt is
|
||||
// fully replaced, suppressing the default tool descriptions that would
|
||||
// otherwise tell the model it has Read/Bash. Without tool descriptions,
|
||||
// the model is highly unlikely to emit tool_use even under prompt
|
||||
// injection. Sandbox's contribution here is protecting OTHER auth
|
||||
// material (other clients' OLP keys, SSH identity, other providers'
|
||||
// tokens) — files claude CLI does NOT legitimately need.
|
||||
//
|
||||
// If we ever switch to a CLI that requires reading credentials.json
|
||||
// AND also legitimately offers tool execution that surfaces those files
|
||||
// (no known case today), this trade-off needs revisiting.
|
||||
const denyRead = [
|
||||
join(home, '.olp'), // OLP API keys + config — cross-tenant
|
||||
join(home, '.ssh'), // SSH identity material — lateral movement
|
||||
join(home, '.config'), // Generic config dir (may contain tokens)
|
||||
join(home, '.codex'), // Codex config — other-provider auth (PR-C will wrap codex)
|
||||
// NOT denied: ~/.claude — this spawn's own auth, breaks claude CLI if denied
|
||||
];
|
||||
|
||||
// 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;
|
||||
// Verify sandbox-runtime import is available (lazy-load check only;
|
||||
// no SandboxManager.initialize() — per ADR 0014 Amendment 1 A1.2.3).
|
||||
try {
|
||||
const mod = await import('@anthropic-ai/sandbox-runtime');
|
||||
SandboxManager = mod.SandboxManager;
|
||||
_SandboxManager = mod.SandboxManager;
|
||||
} catch (e) {
|
||||
_initialized = true;
|
||||
_active = false;
|
||||
_initConfig = { failReason: `sandbox-runtime import failed: ${e?.message ?? e}` };
|
||||
return { active: false, reason: _initConfig.failReason };
|
||||
_failReason = `sandbox-runtime import failed: ${e?.message ?? e}`;
|
||||
return { active: false, reason: _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 };
|
||||
}
|
||||
_initialized = true;
|
||||
_active = true;
|
||||
return { active: true, summary: _buildSummary() };
|
||||
}
|
||||
|
||||
/** @internal — returns summary string for logging */
|
||||
/** @internal */
|
||||
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]`;
|
||||
return `Layer 3 available (sandbox-runtime loaded, OS deps present); per-spawn wrapWithSandbox enabled`;
|
||||
}
|
||||
|
||||
// ── isSandboxActive ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synchronous query of bootstrap state.
|
||||
* Returns true only if bootstrapSandbox() completed successfully.
|
||||
* Used by provider plugins to decide spawn path.
|
||||
* Synchronous query: is Layer 3 (per-call sandbox-runtime wrap) operational?
|
||||
* Returns true only if bootstrapSandbox() completed successfully AND
|
||||
* OLP_SANDBOX_DISABLED is not set.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
@@ -293,117 +180,275 @@ export function isSandboxActive() {
|
||||
return _active;
|
||||
}
|
||||
|
||||
// ── wrapSpawn ─────────────────────────────────────────────────────────────
|
||||
// ── prepareIsolatedEnvironment ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wrap a spawn command + args for sandbox execution.
|
||||
* Compose per-spawn isolation primitives (Layers 1+2+3) for a single request.
|
||||
*
|
||||
* 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.
|
||||
* Reads provider.ISOLATION per ADR 0002 Amendment 9. If ISOLATION is absent,
|
||||
* returns the legacy unsandboxed shape (identity env, identity hooks, no cleanup).
|
||||
*
|
||||
* @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 }>}
|
||||
* @param {object} params.provider — provider plugin object (may have .ISOLATION)
|
||||
* @param {string} params.keyId — OLP key identity driving this request
|
||||
* @param {string} params.reqId — per-request UUID
|
||||
* @returns {Promise<{
|
||||
* ephemeralRoot: string|null,
|
||||
* envOverrides: Record<string, string>,
|
||||
* hardenedArgs: (args: string[]) => string[],
|
||||
* wrapForLayer3: (command: string) => Promise<string>,
|
||||
* cleanup: () => Promise<void>,
|
||||
* }>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||
const isolation = provider?.ISOLATION;
|
||||
|
||||
// ── Legacy unsandboxed path (no ISOLATION declared) ──────────────────────
|
||||
if (!isolation) {
|
||||
if (provider?.name) {
|
||||
console.warn(
|
||||
`[sandbox/manager] [WARN] provider "${provider.name}" does not declare ISOLATION; ` +
|
||||
`spawns will run under legacy unsandboxed shape. Recommended in multi-tenant ` +
|
||||
`deployments: declare ISOLATION per ADR 0002 Amendment 9.`,
|
||||
);
|
||||
}
|
||||
return _legacyShape();
|
||||
}
|
||||
|
||||
const SandboxManager = _initConfig.SandboxManager;
|
||||
// ── Layer 1: Create per-spawn ephemeral home ──────────────────────────────
|
||||
// /tmp/olp-spawn/<keyId>/<reqId>/home
|
||||
// keyId is sanitized to filesystem-safe characters (alphanumeric + hyphens).
|
||||
const safeKeyId = String(keyId ?? 'anon').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
||||
const safeReqId = String(reqId ?? 'req').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
||||
const ephemeralRoot = join(SPAWN_BASE_DIR, safeKeyId, safeReqId, 'home');
|
||||
|
||||
// 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 });
|
||||
mkdirSync(ephemeralRoot, { recursive: true });
|
||||
} catch (e) {
|
||||
throw new SandboxWrapError(`Failed to create ephemeral spawn dir ${spawnCwd}: ${e?.message ?? e}`);
|
||||
throw new Error(
|
||||
`[sandbox/manager] Failed to create ephemeral root ${ephemeralRoot}: ${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 = {
|
||||
// ── Layer 1 cont.: mkdir requiredHomePaths ────────────────────────────────
|
||||
const requiredPaths = isolation.requiredHomePaths ?? [];
|
||||
for (const relPath of requiredPaths) {
|
||||
if (typeof relPath !== 'string' || relPath.startsWith('..') || relPath.startsWith('/')) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.requiredHomePaths contains ` +
|
||||
`invalid entry "${relPath}" — must be a relative path with no leading .. or /`,
|
||||
);
|
||||
}
|
||||
const absPath = join(ephemeralRoot, relPath);
|
||||
mkdirSync(absPath, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Layer 2: Symlink credentialMounts ─────────────────────────────────────
|
||||
const mounts = isolation.credentialMounts ?? [];
|
||||
for (const mount of mounts) {
|
||||
if (!Array.isArray(mount) || mount.length !== 2) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.credentialMounts entry ` +
|
||||
`is not a 2-tuple: ${JSON.stringify(mount)}`,
|
||||
);
|
||||
}
|
||||
const [srcAbsPath, dstRel] = mount;
|
||||
|
||||
// Validate src
|
||||
if (typeof srcAbsPath !== 'string' || !srcAbsPath.startsWith('/')) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.credentialMounts src ` +
|
||||
`"${srcAbsPath}" must be an absolute path (call os.homedir() in the plugin)`,
|
||||
);
|
||||
}
|
||||
// Validate dst
|
||||
if (typeof dstRel !== 'string' || dstRel.startsWith('..') || dstRel.startsWith('/')) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.credentialMounts dst ` +
|
||||
`"${dstRel}" must be a relative path with no leading .. or /`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!existsSync(srcAbsPath)) {
|
||||
console.warn(
|
||||
`[sandbox/manager] [WARN] provider "${provider.name}" credentialMount src ` +
|
||||
`"${srcAbsPath}" does not exist — spawn may fail auth`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dstAbs = join(ephemeralRoot, dstRel);
|
||||
// Ensure parent dir exists
|
||||
mkdirSync(dirname(dstAbs), { recursive: true });
|
||||
|
||||
// Create symlink (skip if already exists — idempotent)
|
||||
if (!existsSync(dstAbs)) {
|
||||
try {
|
||||
symlinkSync(srcAbsPath, dstAbs);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] Failed to symlink ${srcAbsPath} → ${dstAbs}: ${e?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose envOverrides (Layer 1 output) ────────────────────────────────
|
||||
let envOverrides = {};
|
||||
if (typeof isolation.ephemeralEnvOverrides === 'function') {
|
||||
const raw = isolation.ephemeralEnvOverrides({ ephemeralRoot, keyId, reqId });
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.ephemeralEnvOverrides ` +
|
||||
`must return a plain object; got ${typeof raw}`,
|
||||
);
|
||||
}
|
||||
// Validate all values are strings
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.ephemeralEnvOverrides ` +
|
||||
`returned non-string value for key "${k}": ${typeof v}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
envOverrides = raw;
|
||||
}
|
||||
|
||||
// ── Compose hardenedArgs (Layer 4 hook) ──────────────────────────────────
|
||||
const hardenedArgs = typeof isolation.toolHardeningArgs === 'function'
|
||||
? (args) => {
|
||||
const copy = [...args];
|
||||
const result = isolation.toolHardeningArgs(copy);
|
||||
if (!Array.isArray(result)) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.toolHardeningArgs ` +
|
||||
`must return an array; got ${typeof result}`,
|
||||
);
|
||||
}
|
||||
for (const arg of result) {
|
||||
if (typeof arg !== 'string') {
|
||||
throw new Error(
|
||||
`[sandbox/manager] provider "${provider.name}" ISOLATION.toolHardeningArgs ` +
|
||||
`returned non-string element in args array: ${typeof arg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
: (args) => args; // identity — provider encodes hardening in its own spawn()
|
||||
|
||||
// ── Compose wrapForLayer3 ─────────────────────────────────────────────────
|
||||
// Layer 3: per-call sandbox-runtime wrap.
|
||||
// Skipped when:
|
||||
// (a) hasInnerSandbox === true (codex — outer wrap would conflict with inner bwrap)
|
||||
// (b) sandbox is not active (!_active — deps missing or OLP_SANDBOX_DISABLED=1)
|
||||
// When active + no inner sandbox: calls SandboxManager.wrapWithSandbox() per-spawn
|
||||
// with a per-spawn customConfig scoped to the ephemeralRoot.
|
||||
const hasInnerSandbox = isolation.hasInnerSandbox === true;
|
||||
const layer3Active = _active && !hasInnerSandbox;
|
||||
|
||||
let wrapForLayer3;
|
||||
if (layer3Active && _SandboxManager) {
|
||||
const operatorHome = homedir();
|
||||
// Per-spawn customConfig: deny reads on real operator home; allow the
|
||||
// ephemeral home and /tmp. Cross-tenant deny list will be tightened in a
|
||||
// follow-up task once the base Layer 3 integration is validated (Task #9).
|
||||
// ADR 0002 Amendment 9 does NOT declare an allowedDomains field on the
|
||||
// ISOLATION contract. Network policy at Layer 3 is therefore the
|
||||
// orchestrator's responsibility, not the provider's. v1 defaults to empty
|
||||
// allowlist (kernel-level deny-all on outbound to non-trusted domains
|
||||
// would be added here in a follow-up ADR amendment once the contract
|
||||
// surface for "trusted-domains per provider" is ratified). For now: open
|
||||
// network (legacy behaviour, matches pre-Solution-1 spawn shape).
|
||||
const customConfig = {
|
||||
network: {
|
||||
allowedDomains,
|
||||
allowedDomains: [],
|
||||
deniedDomains: [],
|
||||
},
|
||||
filesystem: {
|
||||
denyRead: [
|
||||
operatorHome,
|
||||
join(operatorHome, '.ssh'),
|
||||
join(operatorHome, '.gnupg'),
|
||||
join(operatorHome, '.olp'),
|
||||
],
|
||||
allowRead: [ephemeralRoot],
|
||||
allowWrite: [ephemeralRoot, '/tmp'],
|
||||
denyWrite: [],
|
||||
},
|
||||
};
|
||||
|
||||
const SM = _SandboxManager;
|
||||
wrapForLayer3 = async (commandString) => {
|
||||
try {
|
||||
return await SM.wrapWithSandbox(commandString, undefined, customConfig);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[sandbox/manager] SandboxManager.wrapWithSandbox failed: ${e?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Identity — no Layer 3 wrap (either hasInnerSandbox=true or sandbox inactive)
|
||||
wrapForLayer3 = async (commandString) => commandString;
|
||||
}
|
||||
|
||||
let wrappedCommand;
|
||||
try {
|
||||
wrappedCommand = await SandboxManager.wrapWithSandbox(commandString, undefined, customConfig);
|
||||
} catch (e) {
|
||||
throw new SandboxWrapError(`SandboxManager.wrapWithSandbox failed: ${e?.message ?? e}`);
|
||||
}
|
||||
// ── Cleanup (called by server after spawn completes) ─────────────────────
|
||||
const cleanup = async () => {
|
||||
// Walk up to /tmp/olp-spawn/<safeKeyId>/<safeReqId> and remove.
|
||||
// Best-effort: log + swallow errors (don't fail the response pipeline).
|
||||
const spawnDir = join(SPAWN_BASE_DIR, safeKeyId, safeReqId);
|
||||
try {
|
||||
await rm(spawnDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[sandbox/manager] Warning: cleanup of ${spawnDir} 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,
|
||||
ephemeralRoot,
|
||||
envOverrides,
|
||||
hardenedArgs,
|
||||
wrapForLayer3,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Legacy unsandboxed shape ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the identity shape used for providers without ISOLATION declared.
|
||||
* Per ADR 0002 Amendment 9 § Backward compatibility.
|
||||
*/
|
||||
function _legacyShape() {
|
||||
return {
|
||||
ephemeralRoot: null,
|
||||
envOverrides: {},
|
||||
hardenedArgs: (args) => args,
|
||||
wrapForLayer3: async (cmd) => cmd,
|
||||
cleanup: async () => { /* nothing to clean up — no ephemeral root was created */ },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test seam ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset internal state so test suite can simulate fresh process.
|
||||
* Also calls SandboxManager.reset() if it was initialized (to clear singleton).
|
||||
* Reset module-level state so the test suite can simulate a fresh process.
|
||||
* Per ADR 0014 § Pitfalls #4: only safe in sequential test contexts with no
|
||||
* in-flight spawns.
|
||||
*
|
||||
* ADR 0014 § Pitfalls #4: must only be called when no in-flight wrapSpawn calls
|
||||
* are active. Safe in sequential test contexts.
|
||||
* Note: Under Amendment 1, there is no SandboxManager singleton to reset
|
||||
* (no SandboxManager.reset() call) — the per-call pattern means the library's
|
||||
* internal state is transient per wrapWithSandbox() invocation.
|
||||
*
|
||||
* @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;
|
||||
_failReason = null;
|
||||
_SandboxManager = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user