From aa2e57d39328ffff498ca3d0f0c86f35d1f8c031 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 09:36:31 -0400 Subject: [PATCH 1/4] feat(B-0852.3a): interactive cred-picker + zeta-install.sh Step 6.94 integration (16 unit tests; consumes B-0852.2b persist CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements operator's 2026-05-27 USB-push direction: ship cred-persistence end-to-end before next USB test cycle. **Picker (tools/installer/zeta-creds-picker.ts)**: Interactive CLI that reads DEFAULT_MANIFEST (B-0852.5) + per-cred handler contracts (B-0852.10), then prompts operator per cred: [b]ake-in NOW / [d]efer to device-flow at runtime / [s]kip For bake-in choices, sub-prompts for value-source matching handler's supportedSources: - [l]iteral (typed value; not logged) - [f]ile (@path syntax to B-0852.10 handler) - [e]nv (env:VAR syntax) After picker loop completes, invokes zeta-creds-persist (B-0852.2b CLI) with collected --bake-cred args + passphrase + usb-uuid + output path + optional persona. Auto-skips persona-scoped creds when --persona not supplied (operator choosing global-only install scope). --dry-run mode prints the persist invocation without executing (useful for test/debug). Exit codes: 0 success / 2 arg-parse / 3 abort / 4 persist-failure. **Tests (tools/installer/zeta-creds-picker.test.ts)**: 16 unit tests passing: - parseArgs validation (6 tests covering well-formed + missing-required + unknown-flag) - runPicker against mock readline (10 tests covering defer-all / bake-literal / bake-file / bake-env / empty-value-skip / persona-scoped auto-skip / persona-supplied bake / empty-choice-as-defer / unrecognized-choice-as-defer / explicit-skip) Pure picker logic tested without spawning persist subprocess. **zeta-install.sh Step 6.94 integration**: Adds conditional Step 6.94 BEFORE existing Step 6.95 cred-persistence block. Gated on three preconditions: - ZETA_CREDS_PICKER=1 env (opt-in; default skip preserves backward compat with automated/CI installs) - $ZETA_HOME/Zeta exists (pre-cloned repo from Step 6.95a-bootstrap) - /etc/zeta/usb-uuid exists (iter-4.2 ESP write surface) - ZETA_CREDS_PASSPHRASE env set When all preconditions met: invokes picker as zeta user via sudo, forwarding passphrase through env. Writes blob to /esp/zeta-creds.enc which B-0852.4 NixOS module will consume at boot (future row). Non-fatal failure: warns + continues (per .claude/rules/non-coercion-invariant.md HC-8 — required-cred write failure surfaces but doesn't halt install). **What this unblocks for operator's USB test cycle**: - Operator can re-flash USB → boot → run installer → set ZETA_CREDS_PASSPHRASE + ZETA_CREDS_PICKER=1 → bake desired creds → reboot - /esp/zeta-creds.enc is written; persistence verified empirically on USB - B-0852.4 NixOS module (consume at boot) lands in next sub-row Composes: - B-0852.1 crypto (PR #5413) - B-0852.2a envelope (PR #5421) - B-0852.2b persist+restore CLIs (PR #5425) - B-0852.3 row (PR #5449) - B-0852.5 manifest (PR #5414) - B-0852.10 handlers (PR #5418) - B-0857.1 audit confirms Step 6.95a invocation (PR #5426) Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over own creds; passphrase NEVER logged; literal values redacted at display; declined creds defer (not coerced into bake-in default). Per .claude/rules/agent-worktree-hygiene-never-hold-main-...: isolated worktree at /private/tmp/zeta-b0852-3a-picker-1215z; never touched operator's primary checkout. Per .claude/rules/holding-without-named-dependency-is-standing-by-failure.md: this commit IS the externalized heartbeat per AgencySignature substrate the operator pointed at 2026-05-27 — git log + audit-agencysignature-main-tip.ts gives the counter mechanism the brief-ack rule's N=6 forcing function needs to fire reliably. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: operator-direction-2026-05-27-usb-push-keep-pushing-forward Action-Mode: substrate-implementation Task: B-0852.3a Co-Authored-By: Claude Opus 4.7 --- .../usb-nixos-installer/zeta-install.sh | 37 +++ tools/installer/zeta-creds-picker.test.ts | 154 +++++++++++++ tools/installer/zeta-creds-picker.ts | 212 ++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 tools/installer/zeta-creds-picker.test.ts create mode 100644 tools/installer/zeta-creds-picker.ts diff --git a/full-ai-cluster/usb-nixos-installer/zeta-install.sh b/full-ai-cluster/usb-nixos-installer/zeta-install.sh index b8c158007d..a85ddb1867 100755 --- a/full-ai-cluster/usb-nixos-installer/zeta-install.sh +++ b/full-ai-cluster/usb-nixos-installer/zeta-install.sh @@ -1015,6 +1015,43 @@ sudo nixos-install \ cleanup_symlinks trap - EXIT +# ── Step 6.94: iter-6.x — B-0852.3a cred-picker (interactive bake-vs-defer at setup time) ── +# Operator 2026-05-27 framing: "human interactive at setup time" + "ask +# what declared creds you want to bake in vs go through device flow". +# +# Conditional on ZETA_CREDS_PICKER=1 + /etc/zeta/usb-uuid + a passphrase +# source (env or file). Skipping is the default for backward compat with +# automated/CI installs; opt-in for operator-driven USB flows. +# +# When invoked: reads tools/installer/zeta-creds-manifest.ts DEFAULT_MANIFEST +# from the pre-cloned Zeta repo (cloned in 6.95a-bootstrap below — so this +# step runs AFTER repo clone in the operator-invocation order; in practice +# operator runs picker post-install via interactive shell rather than during +# automated zeta-install.sh flow). +# +# This block documents the integration point + is conditional; default skip. +if [ -n "${ZETA_CREDS_PICKER:-}" ] && [ "$ZETA_CREDS_PICKER" = "1" ]; then + echo "[iter-6.x] ── B-0852.3a cred-picker (operator interactive) ──" + if [ -d "$ZETA_HOME/Zeta" ] && [ -f /etc/zeta/usb-uuid ]; then + USB_UUID="$(cat /etc/zeta/usb-uuid)" + if [ -n "${ZETA_CREDS_PASSPHRASE:-}" ]; then + sudo HOME="$ZETA_HOME" -u "#$ZETA_UID" \ + bash -c "cd $ZETA_HOME/Zeta && ZETA_CREDS_PASSPHRASE='$ZETA_CREDS_PASSPHRASE' \ + bun tools/installer/zeta-creds-picker.ts \ + --usb-uuid '$USB_UUID' \ + --output /esp/zeta-creds.enc \ + --passphrase-env ZETA_CREDS_PASSPHRASE" || \ + echo "[iter-6.x] WARN: picker exited non-zero; cred-blob may be partial" + else + echo "[iter-6.x] SKIP picker: ZETA_CREDS_PASSPHRASE not set" + fi + else + echo "[iter-6.x] SKIP picker: prereq missing ($ZETA_HOME/Zeta exists? /etc/zeta/usb-uuid exists?)" + fi +else + echo "[iter-6.x] SKIP B-0852.3a cred-picker (set ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid to enable)" +fi + # ── Step 6.95: iter-5.5.0 — claude-code install + credential persistence (B-0848 Phase 2) ── # Aaron 2026-05-27 ask: "wanna make this automatic on boot before i even # login and have it save my claude code device login like gh, also make diff --git a/tools/installer/zeta-creds-picker.test.ts b/tools/installer/zeta-creds-picker.test.ts new file mode 100644 index 0000000000..18a97dbca1 --- /dev/null +++ b/tools/installer/zeta-creds-picker.test.ts @@ -0,0 +1,154 @@ +// zeta-creds-picker.test.ts — B-0852.3a picker tests. +// +// Tests parseArgs (pure) + runPicker (against a mock readline-like interface). + +import { describe, expect, it } from "bun:test"; +import { parseArgs, runPicker } from "./zeta-creds-picker"; + +describe("parseArgs", () => { + it("accepts well-formed args with --passphrase-env", () => { + const r = parseArgs(["--usb-uuid", "u1", "--output", "/o", "--passphrase-env", "P"]); + if ("error" in r) throw new Error(r.error); + expect(r.usbUuid).toBe("u1"); + expect(r.output).toBe("/o"); + expect(r.passphraseEnv).toBe("P"); + expect(r.persona).toBe(null); + expect(r.dryRun).toBe(false); + }); + + it("accepts --persona + --dry-run", () => { + const r = parseArgs([ + "--usb-uuid", "u1", "--output", "/o", "--passphrase-file", "/pp", + "--persona", "otto", "--dry-run", + ]); + if ("error" in r) throw new Error(r.error); + expect(r.persona).toBe("otto"); + expect(r.dryRun).toBe(true); + expect(r.passphraseFile).toBe("/pp"); + }); + + it("rejects missing --usb-uuid", () => { + const r = parseArgs(["--output", "/o", "--passphrase-env", "P"]); + expect("error" in r).toBe(true); + }); + + it("rejects missing --output", () => { + const r = parseArgs(["--usb-uuid", "u1", "--passphrase-env", "P"]); + expect("error" in r).toBe(true); + }); + + it("rejects no passphrase source", () => { + const r = parseArgs(["--usb-uuid", "u1", "--output", "/o"]); + expect("error" in r).toBe(true); + }); + + it("rejects unknown flag", () => { + const r = parseArgs(["--bogus"]); + expect("error" in r).toBe(true); + }); +}); + +// Mock readline-like interface for testing runPicker against scripted answers. +function mockRl(answers: string[]) { + let idx = 0; + return { + question: (_prompt: string) => Promise.resolve(answers[idx++] ?? ""), + close: () => {}, + } as unknown as Parameters[0]; +} + +describe("runPicker", () => { + it("returns no bake-args when operator defers everything (no persona)", async () => { + const rl = mockRl([ + "d", // gh-cli + // claude/gemini/codex auto-skip (persona-scoped + no persona) + // ssh-host-keys / ssh-operator-pubkey: prompt depending on handler/scope + "d", "d", "d", "d", "d", "d", "d", "d", + ]); + const args = await runPicker(rl, null); + expect(args.length).toBe(0); + }); + + it("bakes gh-cli with literal value when chosen", async () => { + // Skip persona-scoped (no persona); gh-cli is the first global cred. + // Answers: bake, literal, value, then defer remaining + const rl = mockRl([ + "b", "l", "ghp_test_value", + "d", "d", "d", "d", "d", "d", "d", "d", + ]); + const args = await runPicker(rl, null); + expect(args.length).toBeGreaterThanOrEqual(1); + const gh = args.find((a) => a.startsWith("gh-cli=")); + expect(gh).toBe("gh-cli=ghp_test_value"); + }); + + it("skips empty literal value", async () => { + const rl = mockRl([ + "b", "l", "", // gh-cli bake, literal, EMPTY + "d", "d", "d", "d", "d", "d", "d", "d", + ]); + const args = await runPicker(rl, null); + expect(args.find((a) => a.startsWith("gh-cli="))).toBeUndefined(); + }); + + it("uses @file syntax when operator picks file source", async () => { + const rl = mockRl([ + "b", "f", "/tmp/test-file", + "d", "d", "d", "d", "d", "d", "d", "d", + ]); + const args = await runPicker(rl, null); + const gh = args.find((a) => a.startsWith("gh-cli=")); + expect(gh).toBe("gh-cli=@/tmp/test-file"); + }); + + it("uses env: syntax when operator picks env source", async () => { + const rl = mockRl([ + "b", "e", "GH_TOKEN", + "d", "d", "d", "d", "d", "d", "d", "d", + ]); + const args = await runPicker(rl, null); + const gh = args.find((a) => a.startsWith("gh-cli=")); + expect(gh).toBe("gh-cli=env:GH_TOKEN"); + }); + + it("auto-skips persona-scoped creds when no persona", async () => { + const rl = mockRl(Array(15).fill("d")); + const args = await runPicker(rl, null); + // claude/gemini/codex are persona-scoped; auto-skip means NO prompt fired + // for them. With persona=null, only the global ones get bake/defer prompts. + expect(args.find((a) => a.startsWith("claude="))).toBeUndefined(); + expect(args.find((a) => a.startsWith("gemini="))).toBeUndefined(); + expect(args.find((a) => a.startsWith("codex="))).toBeUndefined(); + }); + + it("bakes persona-scoped cred when persona supplied", async () => { + // With persona, claude/gemini/codex are baked-capable. Order matches + // DEFAULT_MANIFEST iteration. + const rl = mockRl([ + "d", // gh-cli + "b", "l", '{"creds":"otto-claude"}', // claude bake literal + "d", "d", // gemini, codex + "d", "d", "d", "d", "d", "d", // remaining + ]); + const args = await runPicker(rl, "otto"); + expect(args.find((a) => a.startsWith("claude="))).toBe('claude={"creds":"otto-claude"}'); + }); + + it("treats empty choice as defer", async () => { + const rl = mockRl(Array(15).fill("")); + const args = await runPicker(rl, null); + expect(args.length).toBe(0); + }); + + it("treats unrecognized choice as defer", async () => { + const rl = mockRl(Array(15).fill("xyz")); + const args = await runPicker(rl, null); + expect(args.length).toBe(0); + }); + + it("skip explicit returns no bake", async () => { + const rl = mockRl(Array(15).fill("s")); + const args = await runPicker(rl, null); + expect(args.length).toBe(0); + }); +}); diff --git a/tools/installer/zeta-creds-picker.ts b/tools/installer/zeta-creds-picker.ts new file mode 100644 index 0000000000..62cf9fef17 --- /dev/null +++ b/tools/installer/zeta-creds-picker.ts @@ -0,0 +1,212 @@ +#!/usr/bin/env bun +// zeta-creds-picker.ts — interactive picker at install/setup time. +// +// B-0852.3a (operator 2026-05-27 device-flow-at-setup framing). +// +// For each cred in DEFAULT_MANIFEST, prompts operator: +// [b]ake-in now / [d]efer to device-flow at runtime / [s]kip +// For bake-in: sub-prompts for value-source (literal / @file / env:VAR) +// per the per-cred handler's supportedSources. +// +// Then invokes zeta-creds-persist with the collected --bake-cred args. +// +// Composes: +// - tools/installer/zeta-creds-manifest.ts (B-0852.5; iteration source) +// - tools/installer/zeta-cred-handlers.ts (B-0852.10; per-cred source validation) +// - tools/installer/zeta-creds-persist.ts (B-0852.2b; downstream consumer) +// +// Usage (called from zeta-install.sh Step 6.77 or operator terminal): +// bun tools/installer/zeta-creds-picker.ts \ +// --usb-uuid \ +// --output /esp/zeta-creds.enc \ +// ( --passphrase-file | --passphrase-env ) \ +// [--persona ] \ +// [--dry-run] (print persist invocation; don't exec) +// +// Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over +// own creds; no default-bake (operator must explicitly pick bake for each); +// passphrase NEVER logged; declined creds defer to device-flow at runtime. +// +// Exit codes: +// 0 success +// 2 arg parse error +// 3 picker abort (operator Ctrl+C) +// 4 persist invocation failure + +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { DEFAULT_MANIFEST } from "./zeta-creds-manifest"; +import { DEFAULT_HANDLERS } from "./zeta-cred-handlers"; + +interface PickerArgs { + readonly usbUuid: string; + readonly output: string; + readonly passphraseFile: string | null; + readonly passphraseEnv: string | null; + readonly persona: string | null; + readonly dryRun: boolean; +} + +export function parseArgs(argv: readonly string[]): PickerArgs | { readonly error: string } { + let usbUuid: string | null = null; + let output: string | null = null; + let passphraseFile: string | null = null; + let passphraseEnv: string | null = null; + let persona: string | null = null; + let dryRun = false; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + const next = (): string => { + if (i + 1 >= argv.length) throw new Error(`${arg} requires a value`); + return argv[++i]!; + }; + try { + if (arg === "--usb-uuid") usbUuid = next(); + else if (arg === "--output") output = next(); + else if (arg === "--passphrase-file") passphraseFile = next(); + else if (arg === "--passphrase-env") passphraseEnv = next(); + else if (arg === "--persona") persona = next(); + else if (arg === "--dry-run") dryRun = true; + else return { error: `unknown flag: ${arg}` }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + } + if (!usbUuid) return { error: "--usb-uuid required" }; + if (!output) return { error: "--output required" }; + if (!passphraseFile && !passphraseEnv) { + return { error: "passphrase source required: --passphrase-file or --passphrase-env " }; + } + return { usbUuid, output, passphraseFile, passphraseEnv, persona, dryRun }; +} + +/** + * Prompt operator per-cred and return the --bake-cred args list. + * Pure function over a readline interface (testable with mock). + */ +export async function runPicker( + rl: ReturnType, + persona: string | null, +): Promise { + const bakeArgs: string[] = []; + console.log("\n=== Credential picker (B-0852.3a setup-time integration) ==="); + console.log("For each declared credential: bake-in NOW, defer to device-flow at runtime, or skip."); + if (persona) console.log(`Persona: ${persona} (persona-scoped creds will use this slot).\n`); + else console.log("No --persona; only global creds will accept bake.\n"); + + for (const cred of DEFAULT_MANIFEST.credentials) { + const handler = DEFAULT_HANDLERS[cred.id]; + if (!handler) { + console.log(`-- ${cred.id}: no handler registered; skipping`); + continue; + } + if (cred.personaScoped && !persona) { + console.log(`-- ${cred.id}: persona-scoped + no --persona given; auto-skip`); + continue; + } + console.log(`\n-- ${cred.id} (${cred.personaScoped ? "persona-scoped" : "global"}; ${cred.required ? "required" : "optional"})`); + if (cred.notes) console.log(` ${cred.notes}`); + console.log(` paths: ${cred.paths.join(", ")}`); + if (handler.supportedSources.length === 0) { + console.log(` handler has NO supported sources (Phase 1 deferred); auto-defer`); + continue; + } + console.log(` supported sources: ${handler.supportedSources.join(", ")}`); + const choice = (await rl.question(" [b]ake-in / [d]efer to device-flow / [s]kip? ")).toLowerCase().trim(); + if (choice === "s" || choice === "skip") { + console.log(` → skip`); + continue; + } + if (choice === "d" || choice === "defer" || choice === "") { + console.log(` → defer (device-flow at runtime)`); + continue; + } + if (choice !== "b" && choice !== "bake") { + console.log(` → unrecognized "${choice}"; treating as defer`); + continue; + } + const sources = handler.supportedSources; + const sourceHint = sources.map((s) => `[${s[0]}]${s.slice(1)}`).join(" / "); + const sourceChoice = (await rl.question(` source ${sourceHint}? `)).toLowerCase().trim(); + let valueSpec: string; + if (sourceChoice.startsWith("l") && sources.includes("literal")) { + const v = await rl.question(` literal value (will NOT be logged): `); + if (v.length === 0) { + console.log(` → empty value; skip`); + continue; + } + valueSpec = v; + } else if (sourceChoice.startsWith("f") && sources.includes("file")) { + const p = await rl.question(` file path: `); + if (p.length === 0) { + console.log(` → empty path; skip`); + continue; + } + valueSpec = `@${p}`; + } else if (sourceChoice.startsWith("e") && sources.includes("env")) { + const v = await rl.question(` env var name: `); + if (v.length === 0) { + console.log(` → empty env var name; skip`); + continue; + } + valueSpec = `env:${v}`; + } else { + console.log(` → unrecognized or unsupported source; skip`); + continue; + } + bakeArgs.push(`${cred.id}=${valueSpec}`); + console.log(` → baked (source: ${valueSpec.startsWith("@") ? "@file" : valueSpec.startsWith("env:") ? "env" : "literal"})`); + } + return bakeArgs; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const parsed = parseArgs(argv); + if ("error" in parsed) { + console.error(`zeta-creds-picker: ${parsed.error}`); + return 2; + } + const rl = createInterface({ input, output }); + let bakeArgs: string[] = []; + try { + bakeArgs = await runPicker(rl, parsed.persona); + } catch (err) { + rl.close(); + console.error(`zeta-creds-picker: aborted: ${err instanceof Error ? err.message : String(err)}`); + return 3; + } finally { + rl.close(); + } + console.log(`\n=== Picker complete: ${bakeArgs.length} cred(s) selected for bake-in ===`); + for (const a of bakeArgs) { + const id = a.split("=", 1)[0]; + console.log(` --bake-cred ${id}=`); + } + const persistArgs = [ + "tools/installer/zeta-creds-persist.ts", + "--usb-uuid", parsed.usbUuid, + "--output", parsed.output, + ]; + if (parsed.passphraseFile) persistArgs.push("--passphrase-file", parsed.passphraseFile); + if (parsed.passphraseEnv) persistArgs.push("--passphrase-env", parsed.passphraseEnv); + if (parsed.persona) persistArgs.push("--persona", parsed.persona); + for (const a of bakeArgs) persistArgs.push("--bake-cred", a); + if (parsed.dryRun) { + console.log(`\n=== DRY RUN — would invoke: ===`); + console.log(` bun ${persistArgs.join(" ")}`); + return 0; + } + console.log(`\n=== Invoking zeta-creds-persist... ===`); + const result = spawnSync("bun", persistArgs, { stdio: "inherit" }); + if (result.status !== 0) { + console.error(`zeta-creds-picker: persist failed (exit ${result.status})`); + return 4; + } + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +} From b632c84a1acaed64f762d15216b7075adc59a2b1 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 09:49:05 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(B-0852.3a=20CI):=207=20Copilot+CodeQL?= =?UTF-8?q?=20findings=20=E2=80=94=20P0=20passphrase=20leak=20via=20bash?= =?UTF-8?q?=20-c=20interpolation;=20P0=20CodeQL=20clear-text-logging;=20su?= =?UTF-8?q?do=20arg=20ordering;=20eslint-disable;=20valueSpec=E2=86=92sour?= =?UTF-8?q?ceChoice=20source=20label;=20Step=206.94=E2=86=926.95-picker=20?= =?UTF-8?q?restructure=20(Aaron=202026-05-27=20USB=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 unresolved review threads on #5450 resolved: **P0 — Passphrase leak via bash -c arg-string interpolation (Copilot @1043)** Was: `bash -c "...ZETA_CREDS_PASSPHRASE='$ZETA_CREDS_PASSPHRASE' bun..."` The outer double-quote expanded $ZETA_CREDS_PASSPHRASE → literal passphrase appeared in process arglist visible to `ps`. Fix: use `sudo --preserve-env=ZETA_CREDS_PASSPHRASE -u USER HOME=... bash -c CMD` where CMD references `--passphrase-env ZETA_CREDS_PASSPHRASE` (var-NAME only). Passphrase never appears in arglist. **P0 — CodeQL clear-text-logging in DRY RUN output (line 198)** Was: `console.log(\` bun \${persistArgs.join(" ")}\`)` — persistArgs contains `--passphrase-env ` from operator input; the NAME is CodeQL-tainted. Fix: build displayArgs that maps position-after-`--passphrase-env` to `` literal. Same discipline as zeta-creds-persist/restore P0 fix on PR #5422. **P1 — sudo arg ordering (Copilot @1038)** Was: `sudo HOME=... -u ...` — HOME= before -u is invalid per sudo manpage (options must precede arguments). Fix: `sudo --preserve-env=... -u ... HOME=...` — options first, env-var assignment between -u and command per sudo manpage. **P1 — valueSpec in source-label ternary (Copilot @202)** Was: `valueSpec.startsWith("@") ? "@file" : valueSpec.startsWith("env:") ? "env" : "literal"` The output is just labels but Copilot flagged the value passing through the ternary as a leak risk. Fix: compute sourceLabel from operator's sourceChoice letter (l/f/e) NOT from valueSpec. valueSpec never reaches the log path. **P2 — eslint-disable for spawnSync (Copilot @201)** Added `// eslint-disable-next-line sonarjs/no-os-command-from-path` before the spawnSync("bun", ...) call per repo convention for TS tools spawning PATH-resolved bins. **P2 — Step 6.94 vs 6.95a-bootstrap ordering contradiction (Copilot @1052)** Was: Step 6.94 claimed to read manifest from pre-cloned repo, but the clone happened in 6.95a-bootstrap BELOW. Picker would fail at Step 6.94 (no repo, no bun). Fix: restructured — Step 6.94 is now a header stub reserving the number; ACTUAL picker invocation moved to NEW Step 6.95-picker INSIDE the 6.95 block, AFTER 6.95a-bootstrap (repo + bun + mise present) + BEFORE 6.95b device-flow logins (picker decides per-cred bake-vs-defer + device-flow handles the deferred subset). **P2 — Header references Step 6.77 (Copilot @18)** Was: picker file header said "Step 6.77" (speculative number from B-0852.3 row body). Fix: updated header to "Step 6.95-picker" matching the actual integration step. **Verification**: - `bash -n full-ai-cluster/usb-nixos-installer/zeta-install.sh` → OK - All 16 unit tests still pass Per .claude/rules/blocked-green-ci-investigate-threads.md: verify-then-fix discipline applied to each Copilot finding; one false-positive narrowed (P1 valueSpec was technically OK but tightened anyway for clarity). Per .claude/rules/non-coercion-invariant.md HC-8: passphrase NEVER logged + NEVER in arglist + redacted in DRY RUN; operator authority preserved. Per .claude/rules/methodology-hard-limits.md: clinical/security floor operative; P0 passphrase-leak fix lifts above the floor by removing the leak path entirely (sudo --preserve-env keeps passphrase in env, not arglist). Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: copilot-review-7-findings-on-pr-5450-resolved Action-Mode: substrate-fix-fwd-security Task: B-0852.3a Co-Authored-By: Claude Opus 4.7 --- .../usb-nixos-installer/zeta-install.sh | 67 +++++++++---------- tools/installer/zeta-creds-picker.ts | 19 +++++- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/full-ai-cluster/usb-nixos-installer/zeta-install.sh b/full-ai-cluster/usb-nixos-installer/zeta-install.sh index a85ddb1867..65b809ff97 100755 --- a/full-ai-cluster/usb-nixos-installer/zeta-install.sh +++ b/full-ai-cluster/usb-nixos-installer/zeta-install.sh @@ -1015,42 +1015,10 @@ sudo nixos-install \ cleanup_symlinks trap - EXIT -# ── Step 6.94: iter-6.x — B-0852.3a cred-picker (interactive bake-vs-defer at setup time) ── -# Operator 2026-05-27 framing: "human interactive at setup time" + "ask -# what declared creds you want to bake in vs go through device flow". -# -# Conditional on ZETA_CREDS_PICKER=1 + /etc/zeta/usb-uuid + a passphrase -# source (env or file). Skipping is the default for backward compat with -# automated/CI installs; opt-in for operator-driven USB flows. -# -# When invoked: reads tools/installer/zeta-creds-manifest.ts DEFAULT_MANIFEST -# from the pre-cloned Zeta repo (cloned in 6.95a-bootstrap below — so this -# step runs AFTER repo clone in the operator-invocation order; in practice -# operator runs picker post-install via interactive shell rather than during -# automated zeta-install.sh flow). -# -# This block documents the integration point + is conditional; default skip. -if [ -n "${ZETA_CREDS_PICKER:-}" ] && [ "$ZETA_CREDS_PICKER" = "1" ]; then - echo "[iter-6.x] ── B-0852.3a cred-picker (operator interactive) ──" - if [ -d "$ZETA_HOME/Zeta" ] && [ -f /etc/zeta/usb-uuid ]; then - USB_UUID="$(cat /etc/zeta/usb-uuid)" - if [ -n "${ZETA_CREDS_PASSPHRASE:-}" ]; then - sudo HOME="$ZETA_HOME" -u "#$ZETA_UID" \ - bash -c "cd $ZETA_HOME/Zeta && ZETA_CREDS_PASSPHRASE='$ZETA_CREDS_PASSPHRASE' \ - bun tools/installer/zeta-creds-picker.ts \ - --usb-uuid '$USB_UUID' \ - --output /esp/zeta-creds.enc \ - --passphrase-env ZETA_CREDS_PASSPHRASE" || \ - echo "[iter-6.x] WARN: picker exited non-zero; cred-blob may be partial" - else - echo "[iter-6.x] SKIP picker: ZETA_CREDS_PASSPHRASE not set" - fi - else - echo "[iter-6.x] SKIP picker: prereq missing ($ZETA_HOME/Zeta exists? /etc/zeta/usb-uuid exists?)" - fi -else - echo "[iter-6.x] SKIP B-0852.3a cred-picker (set ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid to enable)" -fi +# ── Step 6.94: B-0852.3a cred-picker stub ─────────────────────────── +# The actual picker invocation lives at Step 6.95-picker (below) which +# fires AFTER 6.95a-bootstrap clones the repo + installs bun. This +# header reserves the step number for forward references; no work here. # ── Step 6.95: iter-5.5.0 — claude-code install + credential persistence (B-0848 Phase 2) ── # Aaron 2026-05-27 ask: "wanna make this automatic on boot before i even @@ -1172,6 +1140,33 @@ if [ -d "$ZETA_HOME" ]; then bash -c 'set -o pipefail; eval "$(mise activate bash 2>/dev/null || true)"; bun install --global @openai/codex 2>&1 | tail -5' || \ echo "[iter-5.5.0] WARN: bun install codex FAILED — can retry post-reboot via 'bun install --global @openai/codex'" + # 6.95-picker — B-0852.3a cred-picker (operator interactive at setup time) + # Operator 2026-05-27 framing: "human interactive at setup time" + "ask what declared + # creds you want to bake in vs go through device flow". + # + # Runs AFTER 6.95a-bootstrap (repo + bun + mise present) and BEFORE 6.95b-* device-flow + # logins so picker decides per-cred bake-vs-defer + the device-flow steps handle the + # deferred subset. + # + # Default: SKIP (backward compat with automated installs). Opt-in via + # ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid. + # + # SECURITY (Copilot review on PR #5450): the passphrase is FORWARDED VIA SUDO + # --preserve-env=ZETA_CREDS_PASSPHRASE, NOT inlined in bash -c arg-string (the + # latter leaked the literal passphrase into the process arglist visible to ps). + # The picker reads it via --passphrase-env which references the env-var-NAME only. + if [ -n "${ZETA_CREDS_PICKER:-}" ] && [ "$ZETA_CREDS_PICKER" = "1" ] && \ + [ -f /etc/zeta/usb-uuid ] && [ -n "${ZETA_CREDS_PASSPHRASE:-}" ]; then + USB_UUID="$(cat /etc/zeta/usb-uuid)" + echo "[iter-5.5.0] ── 6.95-picker: B-0852.3a cred-picker (operator interactive) ──" + sudo --preserve-env=ZETA_CREDS_PASSPHRASE -u "#$ZETA_UID" \ + HOME="$ZETA_HOME" \ + bash -c "cd '$ZETA_HOME/Zeta' && bun tools/installer/zeta-creds-picker.ts --usb-uuid '$USB_UUID' --output /esp/zeta-creds.enc --passphrase-env ZETA_CREDS_PASSPHRASE" || \ + echo "[iter-5.5.0] WARN: picker exited non-zero; cred-blob may be partial" + else + echo "[iter-5.5.0] SKIP 6.95-picker (set ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid to enable)" + fi + # 6.95b — interactive claude login (mirror iter-5.4.0 gh auth login) CLAUDE_BIN="$ZETA_HOME/.bun/bin/claude" if [ -x "$CLAUDE_BIN" ]; then diff --git a/tools/installer/zeta-creds-picker.ts b/tools/installer/zeta-creds-picker.ts index 62cf9fef17..982944fe0b 100644 --- a/tools/installer/zeta-creds-picker.ts +++ b/tools/installer/zeta-creds-picker.ts @@ -15,7 +15,7 @@ // - tools/installer/zeta-cred-handlers.ts (B-0852.10; per-cred source validation) // - tools/installer/zeta-creds-persist.ts (B-0852.2b; downstream consumer) // -// Usage (called from zeta-install.sh Step 6.77 or operator terminal): +// Usage (called from zeta-install.sh Step 6.95-picker or operator terminal): // bun tools/installer/zeta-creds-picker.ts \ // --usb-uuid \ // --output /esp/zeta-creds.enc \ @@ -156,7 +156,12 @@ export async function runPicker( continue; } bakeArgs.push(`${cred.id}=${valueSpec}`); - console.log(` → baked (source: ${valueSpec.startsWith("@") ? "@file" : valueSpec.startsWith("env:") ? "env" : "literal"})`); + // SECURITY: source label computed from operator's choice letter, NOT + // from valueSpec (which contains the actual value/path/var-name). + // Avoids any chance of the value/path/var-name reaching console output + // through ternary-on-valueSpec inspection. + const sourceLabel = sourceChoice.startsWith("l") ? "literal" : sourceChoice.startsWith("f") ? "@file" : "env"; + console.log(` → baked (source: ${sourceLabel})`); } return bakeArgs; } @@ -194,11 +199,19 @@ async function main(): Promise { if (parsed.persona) persistArgs.push("--persona", parsed.persona); for (const a of bakeArgs) persistArgs.push("--bake-cred", a); if (parsed.dryRun) { + // SECURITY: build a redacted display variant that omits the env-var + // name (CodeQL clear-text-logging finding — passphraseEnv is tainted + // via env-access flow; do NOT echo its literal contents). Sibling + // discipline to zeta-creds-persist.ts + zeta-creds-restore.ts P0 fix. console.log(`\n=== DRY RUN — would invoke: ===`); - console.log(` bun ${persistArgs.join(" ")}`); + const displayArgs = persistArgs.map((v, i) => + i > 0 && persistArgs[i - 1] === "--passphrase-env" ? "" : v + ); + console.log(` bun ${displayArgs.join(" ")}`); return 0; } console.log(`\n=== Invoking zeta-creds-persist... ===`); + // eslint-disable-next-line sonarjs/no-os-command-from-path const result = spawnSync("bun", persistArgs, { stdio: "inherit" }); if (result.status !== 0) { console.error(`zeta-creds-picker: persist failed (exit ${result.status})`); From 00ec4545113c868a869832ccf93aae9cbeb5347d Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 09:52:36 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(B-0852.3a=20CodeQL=20P0=20re-fire):=20b?= =?UTF-8?q?uild=20DRY=20RUN=20display=20from=20known-safe=20primitives=20?= =?UTF-8?q?=E2=80=94=20never=20reference=20parsed.passphraseEnv=20in=20log?= =?UTF-8?q?ged=20string=20(CodeQL=20doesn't=20see=20runtime=20ternary=20br?= =?UTF-8?q?eaking=20taint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior fix used map-based redaction over persistArgs (which contains parsed.passphraseEnv tainted via env-var-name access). CodeQL data-flow analysis doesn't recognize runtime ternary as a sanitizer — the taint still flows from the input to the log call statically, so the warning re-fired. Stronger pattern (matches the sibling persist/restore CLIs): construct the display string from primitives only. NEVER reference parsed.passphraseEnv OR parsed.passphraseFile in the logged string; print literal placeholders like "" / "" instead. displayCmd = " bun tools/installer/zeta-creds-persist.ts --usb-uuid --output " + " --passphrase-file " (if --passphrase-file set) + " --passphrase-env " (if --passphrase-env set) + " --persona " (if --persona set) + " --bake-cred =" (per bake; id is OK; value redacted) All 16 tests still pass. Per .claude/rules/blocked-green-ci-investigate-threads.md verify-then-fix discipline: read line 210 directly, confirm the redaction was runtime- only (CodeQL doesn't sanitize), rewrite to static-safety pattern. Per .claude/rules/non-coercion-invariant.md HC-8: passphrase NEVER in log path; operator authority over what gets logged preserved by total redaction; / placeholders confirm presence without revealing content. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: codeql-re-fire-on-line-210-after-prior-redaction-insufficient Action-Mode: substrate-fix-fwd-security Task: B-0852.3a Co-Authored-By: Claude Opus 4.7 --- tools/installer/zeta-creds-picker.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tools/installer/zeta-creds-picker.ts b/tools/installer/zeta-creds-picker.ts index 982944fe0b..311beebcbb 100644 --- a/tools/installer/zeta-creds-picker.ts +++ b/tools/installer/zeta-creds-picker.ts @@ -199,15 +199,23 @@ async function main(): Promise { if (parsed.persona) persistArgs.push("--persona", parsed.persona); for (const a of bakeArgs) persistArgs.push("--bake-cred", a); if (parsed.dryRun) { - // SECURITY: build a redacted display variant that omits the env-var - // name (CodeQL clear-text-logging finding — passphraseEnv is tainted - // via env-access flow; do NOT echo its literal contents). Sibling - // discipline to zeta-creds-persist.ts + zeta-creds-restore.ts P0 fix. + // SECURITY: build display string from KNOWN-SAFE pieces only. + // Earlier map-based redaction kept persistArgs (tainted) in the + // dataflow; CodeQL doesn't recognize runtime ternary as breaking + // taint, so it kept flagging. Construct from primitives instead; + // NEVER reference parsed.passphraseEnv or parsed.passphraseFile in + // the logged string. Sibling discipline to zeta-creds-persist.ts + // + zeta-creds-restore.ts P0 fix on PR #5422. console.log(`\n=== DRY RUN — would invoke: ===`); - const displayArgs = persistArgs.map((v, i) => - i > 0 && persistArgs[i - 1] === "--passphrase-env" ? "" : v - ); - console.log(` bun ${displayArgs.join(" ")}`); + let displayCmd = ` bun tools/installer/zeta-creds-persist.ts --usb-uuid --output `; + if (parsed.passphraseFile) displayCmd += ` --passphrase-file `; + if (parsed.passphraseEnv) displayCmd += ` --passphrase-env `; + if (parsed.persona) displayCmd += ` --persona `; + for (const a of bakeArgs) { + const id = a.split("=", 1)[0]; + displayCmd += ` --bake-cred ${id}=`; + } + console.log(displayCmd); return 0; } console.log(`\n=== Invoking zeta-creds-persist... ===`); From 5b2f267c4e11c6ac5f3c6ff9c8474d2686528bdd Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 10:01:24 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(B-0852.3a=20CI=20Copilot):=20activate?= =?UTF-8?q?=20mise=20+=20BUN=5FINSTALL=20in=20picker=20bash=20-c=20?= =?UTF-8?q?=E2=80=94=20match=20sibling=206.95a=20install=20steps=20(Copilo?= =?UTF-8?q?t=20@1164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot finding: the picker invocation at Step 6.95-picker bash -c didn't activate mise the way sibling 6.95a-claude/gemini/codex steps do (lines 1119-1121 / 1129-1131 / 1139-1141 all `eval "$(mise activate bash 2>/dev/null || true)"; bun ...` inside the bash -c, with `BUN_INSTALL="$ZETA_HOME/.bun"` set). Without mise activate, `bun` is not on the subshell PATH because mise installs bun via shims; activate sets the PATH entry. Picker would fail with "bun: command not found" at Step 6.95-picker time. Fix: mirror the sibling pattern exactly: - Add `BUN_INSTALL="$ZETA_HOME/.bun"` to sudo env prefix - Add `set -o pipefail; eval "$(mise activate bash 2>/dev/null || true)";` prefix to bash -c - Preserve --preserve-env=ZETA_CREDS_PASSPHRASE for passphrase forward Verification: `bash -n full-ai-cluster/usb-nixos-installer/zeta-install.sh` returns syntax OK. Per .claude/rules/blocked-green-ci-investigate-threads.md verify-then-fix: read the sibling step patterns at lines 1119-1141, confirm they all follow same eval-mise-then-bun convention, apply the same to picker. Agency-Signature-Version: 1 Agent: Otto Agent-Runtime: Claude Code (auto mode) Agent-Model: claude-opus-4-7 Credential-Identity: aaron-otto-vscode Credential-Mode: operator-authorized Human-Review: pre-merge-pending Human-Review-Evidence: copilot-thread-PRRT_kwDOSF9kNM6FHfK8-on-pr-5450 Action-Mode: substrate-fix-fwd-correctness Task: B-0852.3a Co-Authored-By: Claude Opus 4.7 --- full-ai-cluster/usb-nixos-installer/zeta-install.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/full-ai-cluster/usb-nixos-installer/zeta-install.sh b/full-ai-cluster/usb-nixos-installer/zeta-install.sh index 65b809ff97..149a36c2ce 100755 --- a/full-ai-cluster/usb-nixos-installer/zeta-install.sh +++ b/full-ai-cluster/usb-nixos-installer/zeta-install.sh @@ -1159,9 +1159,13 @@ if [ -d "$ZETA_HOME" ]; then [ -f /etc/zeta/usb-uuid ] && [ -n "${ZETA_CREDS_PASSPHRASE:-}" ]; then USB_UUID="$(cat /etc/zeta/usb-uuid)" echo "[iter-5.5.0] ── 6.95-picker: B-0852.3a cred-picker (operator interactive) ──" + # mise activate inside bash -c matches sibling 6.95a-claude/gemini/codex + # patterns at lines 1119-1141; without it, bun is not on the PATH the + # subshell sees (mise installs bun via shims; activate sets PATH). + # BUN_INSTALL pin matches sibling pattern too. sudo --preserve-env=ZETA_CREDS_PASSPHRASE -u "#$ZETA_UID" \ - HOME="$ZETA_HOME" \ - bash -c "cd '$ZETA_HOME/Zeta' && bun tools/installer/zeta-creds-picker.ts --usb-uuid '$USB_UUID' --output /esp/zeta-creds.enc --passphrase-env ZETA_CREDS_PASSPHRASE" || \ + HOME="$ZETA_HOME" BUN_INSTALL="$ZETA_HOME/.bun" \ + bash -c "set -o pipefail; eval \"\$(mise activate bash 2>/dev/null || true)\"; cd '$ZETA_HOME/Zeta' && bun tools/installer/zeta-creds-picker.ts --usb-uuid '$USB_UUID' --output /esp/zeta-creds.enc --passphrase-env ZETA_CREDS_PASSPHRASE" || \ echo "[iter-5.5.0] WARN: picker exited non-zero; cred-blob may be partial" else echo "[iter-5.5.0] SKIP 6.95-picker (set ZETA_CREDS_PICKER=1 + ZETA_CREDS_PASSPHRASE + /etc/zeta/usb-uuid to enable)"