feat(B-0852.2b): persist + restore CLIs composing the full cred-persistence stack (19 integration tests; chains off B-0852.2a PR #5421)#5422
Conversation
…xt schema (17 unit tests)
B-0852 sub-row .2 first slice — the on-disk wire format that B-0852.2b
persist/restore CLIs will use to read/write /esp/zeta-creds.enc.
Pure functions; no I/O. Two layers:
1. Envelope serialization (binary; little-endian; v1 magic "ZCV1"):
- 8-byte header: 4-byte magic + 4-byte reserved
- Length-prefixed: salt (u16) + iv (u16) + tag (u16) + ciphertext (u32)
- Trailing bytes rejected in v1 (v2 will explicit-version-bump)
2. CredBundle plaintext schema (post-decryption JSON):
- schemaVersion: 1
- globalCreds: { id: bytes } (personaScoped=false manifest entries)
- personaCreds: { persona: { id: bytes } } (personaScoped=true)
- Bytes base64-encoded inside JSON for safe transport
- Composes with B-0852.5 manifest's personaScoped flag
Full pipeline (covered by 1 integration test):
CredBundle → encodeBundle → encrypt (B-0852.1) → serializeEnvelope
→ [disk write/read simulation] → parseEnvelope → decrypt → decodeBundle
→ CredBundle (byte-identical)
Test output: 17 pass / 0 fail / 29 expect() calls / 1.67s (scrypt
dominates timing per B-0852.1 OWASP-recommended N=2^17).
Composes with:
- B-0852 parent (cred persistence)
- B-0852.1 crypto module (merged PR #5411) — Envelope type producer
- B-0852.5 cred-manifest schema (merged PR #5414) — personaScoped semantics
- B-0852.10 per-cred handlers (merged PR #5418) — value producers feed
globalCreds/personaCreds maps
- B-0852.2b future — persist/restore CLIs consume this module
What this is NOT:
- NOT the persist CLI (next slice; needs FS + passphrase prompt)
- NOT the restore CLI (next slice; same)
- NOT zflash --bake-cred integration (B-0852.9)
…stence stack (19 integration tests)
B-0852 sub-row .2 final slice — operational CLI surface that composes
all 4 already-shipped modules:
- B-0852.1 crypto (encrypt/decrypt with scrypt+HKDF+AES-256-GCM)
- B-0852.5 manifest (declarative cred catalog)
- B-0852.10 per-cred handlers (--bake-cred parse + validate)
- B-0852.2a envelope (wire format + CredBundle plaintext schema)
THREE files:
1. tools/installer/zeta-creds-persist.ts (CLI)
Usage: bun zeta-creds-persist.ts --usb-uuid <uuid> --output /esp/zeta-creds.enc
--passphrase-{env VAR | file PATH} [--persona <name>]
[--bake-cred <id>=<value-source>]...
Composes --bake-cred args + manifest personaScoped flag → CredBundle →
encrypt via crypto module → serialize via envelope → write to ESP.
2. tools/installer/zeta-creds-restore.ts (CLI)
Usage: bun zeta-creds-restore.ts --usb-uuid <uuid> --input /esp/zeta-creds.enc
--passphrase-{env VAR | file PATH} [--persona <name>]
[--target-root /] [--dry-run]
Reads encrypted blob → parseEnvelope → decrypt → decodeBundle → writes
each cred to its manifest-declared paths under target-root.
Exit codes: 0=ok / 2=arg-parse / 3=file-read / 4=envelope-parse /
5=decrypt-fail (wrong pass/UUID/tampered) / 6=bundle-decode /
7=manifest-mismatch.
--dry-run prints plan without writing.
3. tools/installer/zeta-creds-persist-restore.test.ts (19 tests)
- parsePersistArgs / parseRestoreArgs (well-formed + each error path)
- composeBundle (global / persona / missing-persona / unknown-id)
- resolveCredPaths (~ expansion + absolute paths under target-root)
- persist→restore round-trips via tmpdir (gh-cli global + claude persona)
- wrong-passphrase / wrong-UUID / tampered-blob / invalid-magic each
surface their canonical exit code via planRestore
Note: passphrase-interactive-prompt deferred to a NixOS module wrapper
(B-0852.4); these CLIs require --passphrase-file or --passphrase-env so
the modules are scriptable + test-driveable. Interactive entry happens
at the install-script Step 6.77 (per B-0852 row body) which prompts +
exports to env before invoking these CLIs.
Test output: 19 pass / 0 fail / 28 expect() calls / 2.06s (scrypt-bound).
Format prettier-clean.
Composes with:
- B-0852 parent (cred persistence)
- B-0852.1 crypto module (merged PR #5411)
- B-0852.5 cred-manifest (merged PR #5414)
- B-0852.10 per-cred handlers (merged PR #5418)
- B-0852.2a envelope (PR #5421 — this PR chains off; will resolve cleanly
when #5421 merges to main)
- B-0852.4 future — NixOS module wraps these CLIs with passphrase prompt
- B-0852.3 future — zeta-install.sh Step 6.77 picker invokes these CLIs
- B-0852.9 future — zflash --bake-cred at flash-time invokes persist directly
What this is NOT:
- NOT the interactive passphrase prompt (B-0852.4 NixOS-module scope)
- NOT the zeta-install.sh Step 6.77 integration (B-0852.3)
- NOT the zflash --bake-cred at flash time (B-0852.9; same persist CLI
invoked from operator's Mac instead of target boot)
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Pull request overview
This PR adds the operational Bun CLIs that compose the B-0852 credential-persistence stack end-to-end: persisting a manifest-shaped credential bundle into an encrypted ESP blob, and restoring that blob back onto disk with explicit exit codes and a dry-run planner. It also includes an integration test suite covering argument parsing, bundle composition, path resolution, and round-trip/tamper/wrong-key cases.
Changes:
- Add
zeta-creds-persist.tsto compose--bake-credinputs into aCredBundle, encrypt, serialize, and write the ESP blob. - Add
zeta-creds-restore.tsto read, parse, decrypt, decode, plan, and restore creds (with--dry-run+ exit codes). - Add integration tests for persist/restore round-trips and failure modes.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/installer/zeta-creds-restore.ts | Restore CLI: arg parsing, path resolution, planning, and filesystem writes with exit codes |
| tools/installer/zeta-creds-persist.ts | Persist CLI: parse args, compose bundle from --bake-cred, encrypt + serialize, write blob |
| tools/installer/zeta-creds-persist-restore.test.ts | Integration tests for CLI parsing + bundle composition + restore planning + round-trips |
| tools/installer/zeta-creds-envelope.ts | Envelope wire format serializer/parser + plaintext CredBundle encode/decode |
| tools/installer/zeta-creds-envelope.test.ts | Unit tests for envelope framing and bundle encode/decode + full pipeline |
…-handling bugs + applyPlan refactor (single decrypt) + 0-byte ciphertext + docs PR #5422 Copilot review caught 14 findings; all valid. Comprehensive fix-pass before re-arming auto-merge. P0 CodeQL clear-text-logging (2 alerts; persist.ts L129 + restore.ts L240): CodeQL flagged the env-var NAME from --passphrase-env being included in error strings (taint tracker treats env[passphraseEnv] access as sensitive → var-name becomes tainted). Fix: omit env-var name from error message; generic "--passphrase-env target var is not set or is empty" instead. Same change in both files. Error-handling bugs: - readFileSync(passphrase-file) in parseArgs not wrapped → could throw on permission failure instead of returning {error}. Fixed in both persist + restore. - readFileSync(input) in restore main() not wrapped → could throw on permission failure instead of returning code 3. Fixed. - writeFileSync(output) in persist main() not wrapped → could throw on unwritable path. Fixed; new exit code 4 for write failure. applyPlan refactor (P1 design): Prior: planRestore + applyPlan each did full parse→decrypt→decode, doubling scrypt cost + extending passphrase-derived key lifetime in memory. Now: planRestore returns RestorePlan with embedded value Buffer per write entry; applyPlan takes the plan + just writes (no decrypt). Single scrypt invocation per restore. Also fixes the silent-skip mismatch — applyPlan now consumes the plan's pre-validated writes; can't accidentally bypass the manifest match checks. 0-byte ciphertext (P2 envelope): MIN_BLOB_LEN was header + lens + 1-byte ciphertext; AES-GCM allows empty plaintext/ciphertext. Fixed: drop the +1. Documentation: - parseArgs docs: removed "Pure (no I/O)" claim (does FS reads for --passphrase-file); accurate description in new doc comment - Usage docs: removed "(interactive prompt)" mention (not implemented in this entry-point per design); replaced with "interactive prompting is the wrapping NixOS module's responsibility (B-0852.4)" Unused import: - tools/installer/zeta-creds-restore.ts: removed unused `join` import from node:path Test updates: - applyPlan signature changed; updated 2 test cases to call planRestore first + pass plan to applyPlan. All 19 persist/restore tests + 17 envelope tests still pass. Resolves 14 Copilot threads on PR #5422.
|
Closed substrate-honestly: substrate landed via fresh #5425 (rebased onto origin/main after parent PR #5421 squash-merge produced chain-PR DIRTY state; force-push to this branch was blocked by auto-mode classifier per .claude/rules/classifier-bypass-research-do-not-deploy-without-zeta-safer-floor.md; non-destructive alternative used). All 14 Copilot findings from this PR addressed in #5425's commit history. Per .claude/rules/pr-triage-tiers.md Tier 3 (substrate-superseded). |
Pull request was closed
…ersistence stack (19 integration tests; replaces conflict-dirty #5422) (#5425) * feat(B-0852.2b): persist + restore CLIs composing the full cred-persistence stack (19 integration tests) B-0852 sub-row .2 final slice — operational CLI surface that composes all 4 already-shipped modules: - B-0852.1 crypto (encrypt/decrypt with scrypt+HKDF+AES-256-GCM) - B-0852.5 manifest (declarative cred catalog) - B-0852.10 per-cred handlers (--bake-cred parse + validate) - B-0852.2a envelope (wire format + CredBundle plaintext schema) THREE files: 1. tools/installer/zeta-creds-persist.ts (CLI) Usage: bun zeta-creds-persist.ts --usb-uuid <uuid> --output /esp/zeta-creds.enc --passphrase-{env VAR | file PATH} [--persona <name>] [--bake-cred <id>=<value-source>]... Composes --bake-cred args + manifest personaScoped flag → CredBundle → encrypt via crypto module → serialize via envelope → write to ESP. 2. tools/installer/zeta-creds-restore.ts (CLI) Usage: bun zeta-creds-restore.ts --usb-uuid <uuid> --input /esp/zeta-creds.enc --passphrase-{env VAR | file PATH} [--persona <name>] [--target-root /] [--dry-run] Reads encrypted blob → parseEnvelope → decrypt → decodeBundle → writes each cred to its manifest-declared paths under target-root. Exit codes: 0=ok / 2=arg-parse / 3=file-read / 4=envelope-parse / 5=decrypt-fail (wrong pass/UUID/tampered) / 6=bundle-decode / 7=manifest-mismatch. --dry-run prints plan without writing. 3. tools/installer/zeta-creds-persist-restore.test.ts (19 tests) - parsePersistArgs / parseRestoreArgs (well-formed + each error path) - composeBundle (global / persona / missing-persona / unknown-id) - resolveCredPaths (~ expansion + absolute paths under target-root) - persist→restore round-trips via tmpdir (gh-cli global + claude persona) - wrong-passphrase / wrong-UUID / tampered-blob / invalid-magic each surface their canonical exit code via planRestore Note: passphrase-interactive-prompt deferred to a NixOS module wrapper (B-0852.4); these CLIs require --passphrase-file or --passphrase-env so the modules are scriptable + test-driveable. Interactive entry happens at the install-script Step 6.77 (per B-0852 row body) which prompts + exports to env before invoking these CLIs. Test output: 19 pass / 0 fail / 28 expect() calls / 2.06s (scrypt-bound). Format prettier-clean. Composes with: - B-0852 parent (cred persistence) - B-0852.1 crypto module (merged PR #5411) - B-0852.5 cred-manifest (merged PR #5414) - B-0852.10 per-cred handlers (merged PR #5418) - B-0852.2a envelope (PR #5421 — this PR chains off; will resolve cleanly when #5421 merges to main) - B-0852.4 future — NixOS module wraps these CLIs with passphrase prompt - B-0852.3 future — zeta-install.sh Step 6.77 picker invokes these CLIs - B-0852.9 future — zflash --bake-cred at flash-time invokes persist directly What this is NOT: - NOT the interactive passphrase prompt (B-0852.4 NixOS-module scope) - NOT the zeta-install.sh Step 6.77 integration (B-0852.3) - NOT the zflash --bake-cred at flash time (B-0852.9; same persist CLI invoked from operator's Mac instead of target boot) * fix(B-0852.2b): 14 Copilot findings — P0 CodeQL secret-leak + 5 error-handling bugs + applyPlan refactor (single decrypt) + 0-byte ciphertext + docs PR #5422 Copilot review caught 14 findings; all valid. Comprehensive fix-pass before re-arming auto-merge. P0 CodeQL clear-text-logging (2 alerts; persist.ts L129 + restore.ts L240): CodeQL flagged the env-var NAME from --passphrase-env being included in error strings (taint tracker treats env[passphraseEnv] access as sensitive → var-name becomes tainted). Fix: omit env-var name from error message; generic "--passphrase-env target var is not set or is empty" instead. Same change in both files. Error-handling bugs: - readFileSync(passphrase-file) in parseArgs not wrapped → could throw on permission failure instead of returning {error}. Fixed in both persist + restore. - readFileSync(input) in restore main() not wrapped → could throw on permission failure instead of returning code 3. Fixed. - writeFileSync(output) in persist main() not wrapped → could throw on unwritable path. Fixed; new exit code 4 for write failure. applyPlan refactor (P1 design): Prior: planRestore + applyPlan each did full parse→decrypt→decode, doubling scrypt cost + extending passphrase-derived key lifetime in memory. Now: planRestore returns RestorePlan with embedded value Buffer per write entry; applyPlan takes the plan + just writes (no decrypt). Single scrypt invocation per restore. Also fixes the silent-skip mismatch — applyPlan now consumes the plan's pre-validated writes; can't accidentally bypass the manifest match checks. 0-byte ciphertext (P2 envelope): MIN_BLOB_LEN was header + lens + 1-byte ciphertext; AES-GCM allows empty plaintext/ciphertext. Fixed: drop the +1. Documentation: - parseArgs docs: removed "Pure (no I/O)" claim (does FS reads for --passphrase-file); accurate description in new doc comment - Usage docs: removed "(interactive prompt)" mention (not implemented in this entry-point per design); replaced with "interactive prompting is the wrapping NixOS module's responsibility (B-0852.4)" Unused import: - tools/installer/zeta-creds-restore.ts: removed unused `join` import from node:path Test updates: - applyPlan signature changed; updated 2 test cases to call planRestore first + pass plan to applyPlan. All 19 persist/restore tests + 17 envelope tests still pass. Resolves 14 Copilot threads on PR #5422. --------- Co-authored-by: Lior <lior@zeta.dev>
… bash -c interpolation; P0 CodeQL clear-text-logging; sudo arg ordering; eslint-disable; valueSpec→sourceChoice source label; Step 6.94→6.95-picker restructure (Aaron 2026-05-27 USB push) 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 <NAME>` from operator input; the NAME is CodeQL-tainted. Fix: build displayArgs that maps position-after-`--passphrase-env` to `<REDACTED>` 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 <noreply@anthropic.com>
…integration (16 tests; Aaron 2026-05-27 USB push) (#5450) * feat(B-0852.3a): interactive cred-picker + zeta-install.sh Step 6.94 integration (16 unit tests; consumes B-0852.2b persist CLI) 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 <noreply@anthropic.com> * fix(B-0852.3a CI): 7 Copilot+CodeQL findings — P0 passphrase leak via bash -c interpolation; P0 CodeQL clear-text-logging; sudo arg ordering; eslint-disable; valueSpec→sourceChoice source label; Step 6.94→6.95-picker restructure (Aaron 2026-05-27 USB push) 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 <NAME>` from operator input; the NAME is CodeQL-tainted. Fix: build displayArgs that maps position-after-`--passphrase-env` to `<REDACTED>` 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 <noreply@anthropic.com> * fix(B-0852.3a CodeQL P0 re-fire): build DRY RUN display from known-safe primitives — never reference parsed.passphraseEnv in logged string (CodeQL doesn't see runtime ternary breaking taint) 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 "<REDACTED>" / "<set>" instead. displayCmd = " bun tools/installer/zeta-creds-persist.ts --usb-uuid <set> --output <set>" + " --passphrase-file <REDACTED>" (if --passphrase-file set) + " --passphrase-env <REDACTED>" (if --passphrase-env set) + " --persona <set>" (if --persona set) + " --bake-cred <id>=<REDACTED>" (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; <set>/<REDACTED> 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 <noreply@anthropic.com> * fix(B-0852.3a CI Copilot): activate mise + BUN_INSTALL in picker bash -c — match sibling 6.95a install steps (Copilot @1164) 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 <noreply@anthropic.com> --------- Co-authored-by: Lior <lior@zeta.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
B-0852 sub-row .2 final slice — operational CLI surface composing all 4 already-shipped modules:
Chained off PR #5421 (B-0852.2a envelope)
Branch is based on #5421's branch (envelope module imports). Once #5421 merges to main, this PR's diff will collapse to just the new files. No rebase needed if #5421 merges first.
Two CLIs + integration test suite
Restore exit codes
Test output
```
19 pass
0 fail
28 expect() calls
Ran 19 tests across 1 file. [2.06s]
```
What this is NOT
Composes with
🤖 Generated with Claude Code