From 1fd27e1c11944a4bb5f95fcabeb80229836a6680 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sat, 30 May 2026 19:09:12 +1000 Subject: [PATCH] =?UTF-8?q?feat(tui):=20PR-0=20=E2=80=94=20TUI-only=20ISOL?= =?UTF-8?q?ATION=20.claude.json=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First PR of the TUI-mode A-path (interactive claude → cc_entrypoint=cli → subscription pool, so the proxy stays usable for Pro subscribers post-6/15). Adds a `tui` opt-in param to prepareIsolatedEnvironment (lib/sandbox/manager.mjs). When tui:true (and only then): - chmod 700 the per-reqId dir + ephemeralRoot (credential-wall, spec §5.5) - seed ephemeral .claude.json via _seedTuiClaudeJson: read ~/.claude.json (or tuiSeedSource), STRIP projects (no owner history leak), stamp hasCompletedOnboarding + bypassPermissionsModeAccepted, preserve oauthAccount/userID, write mode 0o600, never log contents - return reqId in isolationCtx (PR-2 driver derives tmux session name — maintainer decision P1: reuse isolationCtx, no server.mjs call-site edits) DEFAULT PATH BYTE-FOR-BYTE UNCHANGED: when tui falsy, none of the above runs; the only return-shape change is the additive reqId field existing callers ignore. Reviewer verified this invariant airtight (server.mjs:1347/:1564 pass no tui arg). Seed carries NO MCP-disable weight (T6 negative control: --strict-mcp-config in PR-2 is the mechanism, not the seed). The seed also does NOT suppress the per-directory trust-folder dialog — corrected a review-fix overclaim that attributed trust-dialog suppression to bypassPermissionsModeAccepted (that flag only suppresses the bypass-permissions dialog; the trust dialog still appears and PR-2's driver MUST answer it "1", per the spikes). Tests: Suite 45 (45a default-path-unchanged, 45b tui-seed perms/markers/ strip, 45c missing-source minimal seed). 816 pass / 0 fail. ADR 0002 Amendment 9 § tuiSeed extension note added. Authority: design spec docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §7.1/§5.5/§12.1; implementation plan + maintainer decisions; ADR 0002 Amd 9; claude CLI v2.1.158. Co-Authored-By: jaekwon-park Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/0002-plugin-architecture.md | 4 + lib/providers/anthropic.mjs | 11 ++ lib/sandbox/manager.mjs | 112 ++++++++++++++- test-features.mjs | 206 +++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 5 deletions(-) diff --git a/docs/adr/0002-plugin-architecture.md b/docs/adr/0002-plugin-architecture.md index e3d7e49..a003e4a 100644 --- a/docs/adr/0002-plugin-architecture.md +++ b/docs/adr/0002-plugin-architecture.md @@ -677,4 +677,8 @@ The full test list is captured in ADR 0014 Amendment 1's PR-B-revised test suite - **`ALIGNMENT.md` Rule 1 (Cite First)** — every per-field design choice is cited above. Every per-provider concrete instance is cited to the underlying CLI authority. - **`ALIGNMENT.md` Rule 2 (No Invention)** — no invented env vars, no invented CLI flags. The mistral `crossTenantReadProtection: 'none'` declaration is the explicit honest acknowledgment that no protection regime has been established, rather than invention of one. - **`ALIGNMENT.md` Rule 4 (Unalignable Plugins / Fields Are Deleted)** — see § Rule 4 compliance above for the explicit reasoning that OPTIONAL `ISOLATION` is not "feature-flagging" but rather "honestly transitional." + +#### tuiSeed extension note (PR-0 co-merge, 2026-05-30) + +`lib/sandbox/manager.mjs` adds a `tui` opt-in param to `prepareIsolatedEnvironment`. When `tui:true`, the orchestrator (a) chmod 700s the per-reqId dir and ephemeralRoot (spec §5.5 credential-wall), and (b) calls the module-private helper `_seedTuiClaudeJson(ephemeralRoot, seedSource)`. The helper reads `~/.claude.json` (or the override `tuiSeedSource`) once, copies `oauthAccount` and `userID` via object spread, strips `projects` entirely to avoid leaking the operator's real project history (spec §7.1), stamps `hasCompletedOnboarding:true` and `bypassPermissionsModeAccepted:true`, and writes the result to `join(ephemeralRoot, '.claude.json')` at mode 0o600. The seed carries NO MCP-disable weight — it does not set `claudeAiMcpEverConnected` or `mcpServers`; that is the spawn-argv flag `--strict-mcp-config` landed in PR-2 (spec §5.2 T6 negative control). The `projects` field is stripped unconditionally and is NOT re-populated with a pre-trusted `cwd` entry. **Correction to the original plan framing:** `bypassPermissionsModeAccepted:true` suppresses the bypass-permissions acceptance dialog only — it does **NOT** suppress the per-directory **trust-folder** dialog ("Is this a project you trust?"), which still appears for a fresh ephemeral `$HOME`. The pre-code spikes confirmed this (their playbook answers the trust dialog by sending "1"). PR-0 does not pre-trust because the spawn `cwd` is a PR-2 session-driver concern (not known at seed time) and the exact trust-field key is unverified; therefore **PR-2's session driver MUST answer the trust-folder dialog** (the spike-validated approach). Pre-trusting `projects[cwd]` in the seed remains an optional future optimization. Authority: claude CLI v2.1.158 first-run onboarding behavior (spec §7.1); the default (stream-json) code path is byte-for-byte unchanged. - **`ALIGNMENT.md` Amendment Procedure** — this section (Amendment 9) is the PR-required citation of evidence (the 2026-05-27 incident memory, the ADR 0014 PoC spike report at `/tmp/sandbox-spike/report.md` on PI231) and the structural amendment of the Provider contract documented in this ADR's § Decision. diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index 45a6d8d..383a330 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -1726,4 +1726,15 @@ export const ISOLATION = { // 1). No additional CLI flags are needed at the orchestrator level. // Per ADR 0002 Amendment 9 § 7: absence means the orchestrator passes args // through unchanged from spawn(). + + // TUI-mode reuses this ISOLATION shape unchanged for Layers 1+2. + // The .claude.json seed extension (onboarding/trust/bypass markers) is an + // orchestrator-side opt-in: the session driver (PR-2/PR-3) calls + // prepareIsolatedEnvironment({ ..., tui:true }) to seed the ephemeral home. + // The seed logic lives in lib/sandbox/manager.mjs _seedTuiClaudeJson(), + // gated on the tui param so the default stream-json path is unchanged. + // + // NOTE (T6 negative control): the seed does NOT disable managed MCP — that + // is the spawn-argv flag --strict-mcp-config (PR-2). See spec §5.2 + §7.1. + // Authority: claude CLI v2.1.158 + ADR 0002 Amendment 9 (tuiSeed extension). }; diff --git a/lib/sandbox/manager.mjs b/lib/sandbox/manager.mjs index 7679f37..5526862 100644 --- a/lib/sandbox/manager.mjs +++ b/lib/sandbox/manager.mjs @@ -47,7 +47,7 @@ * __resetSandboxManagerForTests() — test seam: reset module state */ -import { existsSync, mkdirSync, symlinkSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -189,18 +189,21 @@ export function isSandboxActive() { * returns the legacy unsandboxed shape (identity env, identity hooks, no cleanup). * * @param {object} params - * @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 + * @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 + * @param {boolean} [params.tui=false] — TUI-mode opt-in: seed .claude.json + chmod 700 (spec §7.1) + * @param {string} [params.tuiSeedSource] — override path for the seed source (default: ~/.claude.json) * @returns {Promise<{ * ephemeralRoot: string|null, + * reqId: string, * envOverrides: Record, * hardenedArgs: (args: string[]) => string[], * wrapForLayer3: (command: string) => Promise, * cleanup: () => Promise, * }>} */ -export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) { +export async function prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false, tuiSeedSource }) { const isolation = provider?.ISOLATION; // ── Test-context bypass ────────────────────────────────────────────────── @@ -317,6 +320,46 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) { } } + // ── TUI-mode: chmod 700 + seed .claude.json (gated on tui=true) ───────── + // Spec §7.1 + §5.5: runs ONLY when the caller opts in with tui:true. + // The default (stream-json) path is byte-for-byte unchanged — this block + // does not execute when tui is falsy. + // + // NOTE: the seed does NOT disable managed MCP. That is the spawn-argv flag + // --strict-mcp-config (PR-2). See spec §5.2 + T6 negative control. + if (tui) { + // chmod 700 the per-reqId dir and ephemeralRoot so sibling spawns cannot + // traverse into each other's ephemeral homes (§5.5 credential-wall). + const reqIdDir = join(SPAWN_BASE_DIR, safeKeyId, safeReqId); + chmodSync(reqIdDir, 0o700); + chmodSync(ephemeralRoot, 0o700); + + // Seed .claude.json with onboarding + bypass markers. + // + // PR-0 seeds `hasCompletedOnboarding` + `bypassPermissionsModeAccepted` only. + // It deliberately does NOT pre-trust the spawn cwd, and the per-directory + // **trust-folder dialog is therefore NOT suppressed** by this seed. + // + // Important correction to the original plan §4/§7.1 framing: there are TWO + // distinct dialogs. `bypassPermissionsModeAccepted:true` suppresses the + // bypass-permissions acceptance dialog ("you accept all responsibility… + // 1.No 2.Yes") — verified on claude v2.1.158. It does NOT suppress the + // per-directory trust-folder dialog ("Is this a project you trust? 1.Yes + // 2.No"), which still appears for a fresh ephemeral $HOME (projects is + // stripped). The pre-code spikes confirmed this — their playbook answers + // the trust dialog by sending "1". + // + // PR-0 does not pre-trust because (a) the spawn cwd is chosen by PR-2's + // session driver, not known here, and (b) the exact trust-field key in the + // projects map is unverified. Therefore **PR-2's session driver MUST answer + // the trust-folder dialog (send "1")** — the spike-validated approach. + // Pre-trusting projects[cwd] in the seed is an optional future optimization + // (PR-2 could thread cwd back), but the dialog-answer is the proven default. + // See ADR 0002 Amendment 9 § tuiSeed extension note. + const resolvedSeedSource = tuiSeedSource ?? join(homedir(), '.claude.json'); + _seedTuiClaudeJson(ephemeralRoot, resolvedSeedSource); + } + // ── Compose envOverrides (Layer 1 output) ──────────────────────────────── let envOverrides = {}; if (typeof isolation.ephemeralEnvOverrides === 'function') { @@ -434,6 +477,7 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) { return { ephemeralRoot, + reqId: safeReqId, envOverrides, hardenedArgs, wrapForLayer3, @@ -441,6 +485,64 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) { }; } +// ── TUI-mode helper: seed ephemeral .claude.json ───────────────────────── + +/** + * Reads the operator's ~/.claude.json (or tuiSeedSource), strips `projects`, + * stamps onboarding/trust/bypass markers, and writes the result to + * join(ephemeralRoot, '.claude.json') mode 0o600. + * + * Called ONLY from prepareIsolatedEnvironment when tui=true. Never called on + * the default (stream-json) path. + * + * Authority: claude CLI v2.1.158 first-run onboarding behavior (spec §7.1); + * seed does NOT disable managed MCP (T6 negative control, spec §5.2). + * + * @param {string} ephemeralRoot — the per-request ephemeral $HOME + * @param {string} seedSource — path to read oauthAccount/userID from + */ +function _seedTuiClaudeJson(ephemeralRoot, seedSource) { + const destPath = join(ephemeralRoot, '.claude.json'); + + let seedObj; + if (existsSync(seedSource)) { + let raw; + try { + raw = JSON.parse(readFileSync(seedSource, 'utf8')); + } catch (e) { + throw new Error(`[sandbox/manager] Failed to parse TUI seed source ${seedSource}: ${e?.message ?? e}`); + } + // Strip projects to avoid leaking owner's real project history (§7.1). + // Copy everything else (oauthAccount, userID, etc.) and stamp markers. + const { projects: _dropped, ...rest } = raw; + seedObj = { + ...rest, + hasCompletedOnboarding: true, + bypassPermissionsModeAccepted: true, + }; + } else { + // Source absent: write minimal seed so the session can still start. + // OAuth may fail without a real oauthAccount; operator must supply one. + console.warn( + `[sandbox/manager] [WARN][tui] TUI seed source not found: ${seedSource}. ` + + `Writing minimal seed — interactive claude may fail OAuth without a real ~/.claude.json.`, + ); + seedObj = { + hasCompletedOnboarding: true, + bypassPermissionsModeAccepted: true, + }; + } + + try { + writeFileSync(destPath, JSON.stringify(seedObj), { mode: 0o600 }); + } catch (e) { + throw new Error(`[sandbox/manager] Failed to write TUI seed to ${destPath}: ${e?.message ?? e}`); + } + + // NEVER log the seed contents — carries oauthAccount/userID (§5.5, §7.1). + console.info('[tui] seeded ephemeral .claude.json'); +} + // ── Legacy unsandboxed shape ────────────────────────────────────────────── /** diff --git a/test-features.mjs b/test-features.mjs index c269f1e..c61ff28 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -18318,3 +18318,209 @@ describe('Suite 44 — sandbox Layer 3 E2E test (PI231 only) [SKIP: awaiting Tas assert.ok(true, 'placeholder — real test lands in Task #9'); }); }); + +// ── Suite 45 — PR-0 TUI ISOLATION seed ──────────────────────────────────── +// +// Tests for the tui opt-in param of prepareIsolatedEnvironment: +// T-a default-path-unchanged (tui omitted / false) +// T-b tui-seed (tui:true, valid fixture source) +// T-c missing-source (tui:true, non-existent source → minimal seed) +// +// Authority: +// spec §7.1 + §5.2 (T6 negative control) — seed does NOT disable managed MCP +// spec §5.5 — chmod 700 + mode-600 seed +// ADR 0002 Amendment 9 (ISOLATION contract, tuiSeed extension) +// claude CLI v2.1.158 first-run onboarding behavior + +import { ISOLATION as _ISOLATION_PR0 } from './lib/providers/anthropic.mjs'; +import { mkdtempSync as _mkdtemp45, rmSync as _rmSync45, statSync as _statSync45, writeFileSync as _wfs45, existsSync as _exists45, readFileSync as _rfs45 } from 'node:fs'; +import { join as _join45 } from 'node:path'; +import { tmpdir as _tmpdir45 } from 'node:os'; + +describe('Suite 45 — PR-0 TUI ISOLATION seed', () => { + + // ── Shared: build a mock provider that has the real anthropic ISOLATION shape + // (credentialMounts is overridden to [] so we don't symlink ~/.claude/.credentials.json). + function _mockAnthropicProvider() { + return { + name: 'mock-anthropic-pr0', + ISOLATION: { + ..._ISOLATION_PR0, + // Override credentialMounts to avoid accessing the real home during tests. + credentialMounts: [], + }, + }; + } + + // ── T-a: default-path-unchanged ────────────────────────────────────────── + // With tui omitted (or false), prepareIsolatedEnvironment must NOT write + // .claude.json in the ephemeralRoot. The return shape must equal the + // existing shape PLUS the additive reqId field. + + it('45a: default-path-unchanged — no .claude.json written when tui is falsy', async () => { + await _resetSandboxMgr(); + globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true; + const provider = _mockAnthropicProvider(); + let ctx; + try { + ctx = await prepareIsolatedEnvironment({ + provider, + keyId: 'test-key-45a', + reqId: 'test-req-45a', + // tui intentionally omitted (defaults to false) + }); + + // ephemeralRoot must exist and be under /tmp/olp-spawn/ + assert.ok(typeof ctx.ephemeralRoot === 'string' && ctx.ephemeralRoot.length > 0, + 'ephemeralRoot must be a non-empty string'); + assert.ok(ctx.ephemeralRoot.startsWith('/tmp/olp-spawn/'), + `ephemeralRoot must be under /tmp/olp-spawn/; got ${ctx.ephemeralRoot}`); + + // NO .claude.json must be present + const claudeJsonPath = _join45(ctx.ephemeralRoot, '.claude.json'); + assert.equal(_exists45(claudeJsonPath), false, + '.claude.json MUST NOT be present on the default (non-tui) path'); + + // reqId is present (additive — does not break existing callers who ignore it) + assert.ok('reqId' in ctx, 'reqId must be present in the returned ctx'); + assert.equal(typeof ctx.reqId, 'string', 'reqId must be a string'); + + // Return shape has all original fields + assert.equal(typeof ctx.envOverrides, 'object', 'envOverrides must be present'); + assert.equal(typeof ctx.hardenedArgs, 'function', 'hardenedArgs must be a function'); + assert.equal(typeof ctx.wrapForLayer3, 'function', 'wrapForLayer3 must be a function'); + assert.equal(typeof ctx.cleanup, 'function', 'cleanup must be a function'); + + await ctx.cleanup(); + } finally { + delete globalThis.__OLP_FORCE_ISOLATION_IN_TEST; + if (ctx?.cleanup) { try { await ctx.cleanup(); } catch { /* already cleaned */ } } + await _resetSandboxMgr(); + } + }); + + // ── T-b: tui-seed ──────────────────────────────────────────────────────── + // With tui:true + a fixture tuiSeedSource: .claude.json must be written + // mode 600, projects stripped, markers present, oauthAccount/userID present. + // The dir and ephemeralRoot must be mode 700. + + it('45b: tui-seed — .claude.json written mode 600, markers present, projects stripped, dirs mode 700', async () => { + await _resetSandboxMgr(); + globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true; + + // Write a fixture .claude.json to a temp dir (never touches real ~/.claude.json). + const fixtureDir = _mkdtemp45(_join45(_tmpdir45(), 'olp-pr0-fixture-')); + const fixtureSeedPath = _join45(fixtureDir, 'fixture-claude.json'); + const fixtureSource = { + oauthAccount: { emailAddress: 'test@example.com', organizationUuid: 'org-uuid-fake' }, + userID: 'user-id-fake', + projects: { '/some/real/project': { hasTrustDialogAccepted: true } }, + someOtherField: 'keep-me', + }; + _wfs45(fixtureSeedPath, JSON.stringify(fixtureSource), 'utf8'); + + const provider = _mockAnthropicProvider(); + let ctx; + try { + ctx = await prepareIsolatedEnvironment({ + provider, + keyId: 'test-key-45b', + reqId: 'test-req-45b', + tui: true, + tuiSeedSource: fixtureSeedPath, + }); + + // .claude.json must exist + const claudeJsonPath = _join45(ctx.ephemeralRoot, '.claude.json'); + assert.equal(_exists45(claudeJsonPath), true, + '.claude.json must be present when tui:true'); + + // mode 600 + const seedMode = _statSync45(claudeJsonPath).mode & 0o777; + assert.equal(seedMode, 0o600, + `.claude.json must have mode 0o600; got ${seedMode.toString(8)}`); + + // Parse and check content + const seedContent = JSON.parse(_rfs45(claudeJsonPath, 'utf8')); + assert.equal(seedContent.hasCompletedOnboarding, true, + 'hasCompletedOnboarding must be true'); + assert.equal(seedContent.bypassPermissionsModeAccepted, true, + 'bypassPermissionsModeAccepted must be true'); + assert.equal('projects' in seedContent, false, + 'projects must NOT be present in the seed (stripped to avoid leaking owner history)'); + assert.deepEqual(seedContent.oauthAccount, fixtureSource.oauthAccount, + 'oauthAccount must be copied from source'); + assert.equal(seedContent.userID, fixtureSource.userID, + 'userID must be copied from source'); + assert.equal(seedContent.someOtherField, 'keep-me', + 'other fields must be preserved'); + + // Negative assertion: no MCP-disable fields (T6 negative control) + assert.equal('claudeAiMcpEverConnected' in seedContent, false, + 'seed must NOT contain claudeAiMcpEverConnected (MCP disable is spawn-argv, not seed)'); + assert.equal('mcpServers' in seedContent, false, + 'seed must NOT contain mcpServers'); + + // reqId present + assert.ok('reqId' in ctx, 'reqId must be present'); + assert.equal(ctx.reqId, 'test-req-45b', 'reqId must match the sanitized input'); + + // dir and ephemeralRoot must be mode 700 (§5.5) + const reqIdDir = _join45('/tmp/olp-spawn', 'test-key-45b', 'test-req-45b'); + const reqIdMode = _statSync45(reqIdDir).mode & 0o777; + assert.equal(reqIdMode, 0o700, + ` dir must have mode 0o700; got ${reqIdMode.toString(8)}`); + const rootMode = _statSync45(ctx.ephemeralRoot).mode & 0o777; + assert.equal(rootMode, 0o700, + `ephemeralRoot must have mode 0o700; got ${rootMode.toString(8)}`); + + await ctx.cleanup(); + } finally { + delete globalThis.__OLP_FORCE_ISOLATION_IN_TEST; + if (ctx?.cleanup) { try { await ctx.cleanup(); } catch { /* already cleaned */ } } + try { _rmSync45(fixtureDir, { recursive: true, force: true }); } catch { /* best-effort */ } + await _resetSandboxMgr(); + } + }); + + // ── T-c: missing-source ────────────────────────────────────────────────── + // With tui:true and a non-existent tuiSeedSource, _seedTuiClaudeJson should + // write a minimal seed (markers only, no oauthAccount) and NOT throw. + + it('45c: missing-source — minimal seed written when tuiSeedSource does not exist', async () => { + await _resetSandboxMgr(); + globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true; + + const provider = _mockAnthropicProvider(); + const nonExistentSource = _join45(_tmpdir45(), 'olp-pr0-nonexistent-' + Date.now() + '.json'); + let ctx; + try { + // Must not throw even though source is absent + ctx = await prepareIsolatedEnvironment({ + provider, + keyId: 'test-key-45c', + reqId: 'test-req-45c', + tui: true, + tuiSeedSource: nonExistentSource, + }); + + const claudeJsonPath = _join45(ctx.ephemeralRoot, '.claude.json'); + assert.equal(_exists45(claudeJsonPath), true, + 'minimal seed must be written even when source is absent'); + + const seedContent = JSON.parse(_rfs45(claudeJsonPath, 'utf8')); + assert.equal(seedContent.hasCompletedOnboarding, true, + 'minimal seed must have hasCompletedOnboarding:true'); + assert.equal(seedContent.bypassPermissionsModeAccepted, true, + 'minimal seed must have bypassPermissionsModeAccepted:true'); + assert.equal('oauthAccount' in seedContent, false, + 'minimal seed must NOT have oauthAccount (source was absent)'); + + await ctx.cleanup(); + } finally { + delete globalThis.__OLP_FORCE_ISOLATION_IN_TEST; + if (ctx?.cleanup) { try { await ctx.cleanup(); } catch { /* already cleaned */ } } + await _resetSandboxMgr(); + } + }); +});