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:
dtzp555-max
2026-05-29 10:43:53 +10:00
committed by GitHub
parent ffe81f7a45
commit 7019294c63
7 changed files with 876 additions and 549 deletions
+166 -5
View File
@@ -458,7 +458,7 @@ function buildSpawnEnv() {
//
// Authority: Codex CLI reference § "codex exec [flags] PROMPT"
// § "--json": NDJSON event stream on stdout
async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
async function* _spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx) {
const auth = authContext ?? readAuthArtifact();
if (!auth?.accessToken) {
throw new ProviderError(
@@ -468,7 +468,7 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
}
const bin = resolveCodexBin();
const { args, prompt, useStdin } = irToCodex(irRequest);
const { args: baseArgs, prompt, useStdin } = irToCodex(irRequest);
const env = buildSpawnEnv();
// Authority: Codex CLI reference § "Authentication"
@@ -476,7 +476,34 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
// No explicit token injection: Codex CLI reads its own auth.json
// (contrast with Anthropic plugin which injects CLAUDE_CODE_OAUTH_TOKEN).
const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
// Task #8 — Phase 7 Solution 1: apply isolation context from orchestrator.
// isolationCtx is provided by server.mjs (prepareIsolatedEnvironment) when
// present. Three layers compose here:
// Layer 1 (env): envOverrides (HOME, CODEX_HOME) have final precedence.
// Layer 4 (args): hardenedArgs injects --sandbox read-only + -c approval_policy.
// Layer 3 (wrap): wrapForLayer3 is identity for codex (hasInnerSandbox=true).
// When isolationCtx is absent (legacy callers / tests), behavior is unchanged.
// Authority: ADR 0014 Amendment 1 § A1.2 + ADR 0002 Amendment 9 § Backward compat.
const envOverrides = isolationCtx?.envOverrides ?? {};
const finalEnv = Object.keys(envOverrides).length > 0 ? { ...env, ...envOverrides } : env;
const hardenedArgs = isolationCtx?.hardenedArgs ?? ((a) => a);
const args = hardenedArgs(baseArgs);
// Layer 3: wrapForLayer3 for codex is always identity (hasInnerSandbox=true);
// included here for API symmetry with the anthropic path and future-proofing.
const wrapForLayer3 = isolationCtx?.wrapForLayer3 ?? (async (c) => c);
const wrappedBin = await wrapForLayer3(bin);
let finalBin, finalArgs;
if (wrappedBin !== bin) {
finalBin = '/bin/sh';
finalArgs = ['-c', wrappedBin];
} else {
finalBin = bin;
finalArgs = args;
}
const proc = spawnImpl(finalBin, finalArgs, { env: finalEnv, stdio: ['pipe', 'pipe', 'pipe'] });
// Write prompt via stdin for multi-line prompts (D6 assumption A1)
if (useStdin) {
@@ -659,8 +686,14 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
// spawn: async (irRequest, authContext) => AsyncIterator<ResponseChunk>
let _spawnImpl = defaultSpawn;
export async function* spawn(irRequest, authContext) {
yield* _spawnAndStream(irRequest, authContext, _spawnImpl);
// Task #8 — Phase 7 Solution 1: isolationCtx is an optional third argument.
// When present (from server.mjs prepareIsolatedEnvironment call), it carries
// { envOverrides, hardenedArgs, wrapForLayer3, cleanup } — the orchestrator
// composes these on top of the provider's own env-cleanup + args composition.
// When absent (legacy callers, tests that don't pass it), behavior is unchanged.
// Authority: ADR 0014 Amendment 1 § A1.2.
export async function* spawn(irRequest, authContext, isolationCtx) {
yield* _spawnAndStream(irRequest, authContext, _spawnImpl, isolationCtx);
}
// Test hook: inject mock spawn without importing child_process.
@@ -795,6 +828,134 @@ export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
];
}
// ── ISOLATION export ─────────────────────────────────────────────────────
// Declares per-provider isolation primitives consumed by lib/sandbox/manager.mjs
// (per ADR 0014 Amendment 1 + ADR 0002 Amendment 9).
//
// Authority citations (all required per ALIGNMENT.md Rule 1):
// codex CLI v0.133.0 — current PI231 prod version (verified 2026-05-29 spike)
// https://developers.openai.com/codex/config-reference — CODEX_HOME env var
// (2 occurrences verified: "$CODEX_HOME/profile-name.config.toml" and
// "$CODEX_HOME/log" path templates)
// https://developers.openai.com/codex/auth/ — ~/.codex/auth.json path
// (2 occurrences verified: "auth.json under CODEX_HOME" credential-storage
// section)
// https://developers.openai.com/codex/concepts/sandboxing — --sandbox flag +
// read-only default (codex inner bubblewrap sandbox)
// openai/codex#16018 — inner bwrap behavior documented (failure under
// restricted env, establishing hasInnerSandbox: true)
// ADR 0014 Amendment 1 — orchestrator composition architecture
// ADR 0002 Amendment 9 — ISOLATION contract spec (field semantics)
// docs/spikes/2026-05-29-ephemeral-home.md § 5.3 — flag-drift caveat
// (--ask-for-approval removed in codex v0.133.0; use -c approval_policy=)
//
// isolation rationale: OpenAI Codex's `codex exec` exposes a shell tool that
// actually executes commands during the spawn (cc-mem incident memory § 3.2).
// The CLI provides its own inner bubblewrap sandbox (`--sandbox read-only` by
// default per https://developers.openai.com/codex/concepts/sandboxing) that
// confines shell tool reads/writes. The orchestrator's outer isolation composes
// with the inner sandbox: credential-dir redirect via CODEX_HOME
// (https://developers.openai.com/codex/config-reference) + HOME redirect for
// the inner bwrap's HOME lookup + per-spawn ephemeral credential mount.
// hasInnerSandbox: true so the outer profile is relaxed to permit the inner
// bwrap's user-namespace clone (openai/codex#16018).
export const ISOLATION = {
// ephemeralEnvOverrides: pure function, no side effects, no fs access.
// CODEX_HOME redirects the entire codex config/credential base directory.
// HOME is also redirected because the codex inner sandbox inherits the parent
// process's HOME for its own home lookup unless overridden.
// Authority: CODEX_HOME → https://developers.openai.com/codex/config-reference
// HOME → POSIX convention (both verified by PI231 spike § 4.3-4.4).
ephemeralEnvOverrides: ({ ephemeralRoot, keyId: _keyId, reqId: _reqId }) => ({
HOME: ephemeralRoot,
CODEX_HOME: `${ephemeralRoot}/.codex`,
}),
// credentialMounts: static list of [srcAbsPath, dstRelativeToEphemeralRoot].
// srcAbsPath uses os.homedir() (imported as `homedir` at top of file) per
// ADR 0002 Amendment 9 § Field 2 validation rules: absolute paths only, no
// `~/` prefixes (shell-expansion semantics differ from Node.js behavior).
// Authority: ~/.codex/auth.json → https://developers.openai.com/codex/auth/
// "Codex caches login details locally in a plaintext file at ~/.codex/auth.json"
// (matches existing codex.mjs `auth.path` field declaration above).
credentialMounts: [
[join(homedir(), '.codex', 'auth.json'), '.codex/auth.json'],
],
// requiredHomePaths: directories to mkdir-p under ephemeralRoot before mounts.
// .codex is required because CODEX_HOME points there and codex startup may
// attempt to read from it before any auto-create logic runs (observed in
// PI231 spike § 4.3 post-state: .codex/ created at spawn time).
requiredHomePaths: [
'.codex',
],
// hasInnerSandbox: true — codex exec spawns its own bubblewrap sandbox
// internally. Declaring true tells the outer isolation orchestrator to relax
// the outer profile to permit clone(CLONE_NEWUSER) so the inner bwrap can
// create user namespaces. Without this flag the inner bwrap fails with
// EPERM. Authority: openai/codex#16018 + https://developers.openai.com/codex/concepts/sandboxing
hasInnerSandbox: true,
// crossTenantReadProtection: 'inner-sandbox' — codex's shell tool runs real
// commands but the inner bubblewrap sandbox (read-only by default) confines
// reads/writes to the inner namespace. The toolHardeningArgs below makes this
// default explicit at the spawn-args level. Authority: openai/codex#16018 +
// https://developers.openai.com/codex/concepts/sandboxing.
crossTenantReadProtection: 'inner-sandbox',
// recommendedDeploymentTier: 'per-os-user' — the inner bwrap sandbox protects
// against accidental cross-tenant leakage from the model's shell tool, but a
// sandbox-escape CVE (e.g. in bubblewrap) would expose the OS-user filesystem.
// Per-OS-user isolation adds defense in depth. See ADR 0002 Amendment 9
// § Field 6 for the full rationale per recommendedDeploymentTier semantics.
recommendedDeploymentTier: 'per-os-user',
// toolHardeningArgs: injects --sandbox read-only if not already present, and
// -c approval_policy="never" to suppress interactive approval prompts.
//
// Flag-drift caveat (docs/spikes/2026-05-29-ephemeral-home.md § 5.3):
// ADR 0002 Amendment 9 § codex example uses `--ask-for-approval never`.
// PI231 spike (2026-05-29) confirmed this flag was REMOVED in codex
// v0.133.0. The codex v0.133.0 `--help` output shows the replacement is
// the generic config-override flag: `-c approval_policy="never"`.
// We use `-c approval_policy="never"` here. This deviates from the ADR
// 0002 Amendment 9 code example (not the field spec — the spec only
// requires an injected flag corresponding to a documented CLI flag).
// The config-override form is documented at https://developers.openai.com/codex/config-reference
// as the mechanism for overriding any config key at spawn time, including
// approval_policy. The deviation is intentional, flag-drift-driven, and
// takes precedence over the (now-incorrect) Amendment 9 code example per
// ALIGNMENT.md Rule 2 (provider CLI is the authority, not the ADR text).
//
// --sandbox read-only: Authority: https://developers.openai.com/codex/concepts/sandboxing
// § "Sandboxing modes" — the default posture is `read-only`; injecting it
// explicitly prevents a future codex default change from silently weakening
// isolation (same rationale as the existing irToCodex --skip-git-repo-check).
toolHardeningArgs: (existingArgs) => {
let result = [...existingArgs];
// Inject --sandbox read-only if the caller has not already specified --sandbox.
if (!result.some(arg => arg === '--sandbox' || arg.startsWith('--sandbox='))) {
result = [...result, '--sandbox', 'read-only'];
}
// Inject -c approval_policy="never" if not already present.
// Checks for the exact -c flag form used by codex v0.133.0 config overrides.
// Flag-drift note: --ask-for-approval (pre-v0.133.0) is NOT injected — it
// was removed; see header comment above.
const approvalAlreadySet = result.some(
(arg, i) => arg === '-c' && typeof result[i + 1] === 'string' && result[i + 1].startsWith('approval_policy'),
);
if (!approvalAlreadySet) {
result = [...result, '-c', 'approval_policy="never"'];
}
return result;
},
};
// ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.