Skip to content

feat(B-0852.2b): persist + restore CLIs composing the full cred-persistence stack (19 integration tests; chains off B-0852.2a PR #5421)#5422

Closed
AceHack wants to merge 3 commits into
mainfrom
feat/b-0852-2b-persist-restore-cli-scripts
Closed

feat(B-0852.2b): persist + restore CLIs composing the full cred-persistence stack (19 integration tests; chains off B-0852.2a PR #5421)#5422
AceHack wants to merge 3 commits into
mainfrom
feat/b-0852-2b-persist-restore-cli-scripts

Conversation

@AceHack
Copy link
Copy Markdown
Member

@AceHack AceHack commented May 27, 2026

Summary

B-0852 sub-row .2 final slice — operational CLI surface composing all 4 already-shipped modules:

  • B-0852.1 crypto (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)

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

File Purpose
`tools/installer/zeta-creds-persist.ts` Compose `--bake-cred` args → CredBundle → encrypt → serialize → write to ESP
`tools/installer/zeta-creds-restore.ts` Read ESP blob → parseEnvelope → decrypt → decodeBundle → write each cred per manifest paths
`tools/installer/zeta-creds-persist-restore.test.ts` 19 integration tests covering CLI args + bundle composition + path resolution + round-trips + security rejections

Restore exit codes

  • 0 success
  • 2 arg parse error
  • 3 file read failure
  • 4 envelope parse failure (invalid magic / truncation)
  • 5 decrypt failure (wrong passphrase / wrong UUID / tampered blob)
  • 6 bundle decode failure (post-decrypt schema parse)
  • 7 manifest mismatch (blob contains unknown cred id)

Test output

```
19 pass
0 fail
28 expect() calls
Ran 19 tests across 1 file. [2.06s]
```

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)

Composes with

🤖 Generated with Claude Code

Lior added 2 commits May 27, 2026 03:43
…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)
Copilot AI review requested due to automatic review settings May 27, 2026 07:46
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@AceHack AceHack enabled auto-merge (squash) May 27, 2026 07:46
Comment thread tools/installer/zeta-creds-restore.ts Fixed
Comment thread tools/installer/zeta-creds-persist.ts
Comment thread tools/installer/zeta-creds-restore.ts
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts to compose --bake-cred inputs into a CredBundle, encrypt, serialize, and write the ESP blob.
  • Add zeta-creds-restore.ts to 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

Comment thread tools/installer/zeta-creds-restore.ts Outdated
Comment thread tools/installer/zeta-creds-restore.ts Outdated
Comment thread tools/installer/zeta-creds-restore.ts Outdated
Comment thread tools/installer/zeta-creds-restore.ts Outdated
Comment thread tools/installer/zeta-creds-restore.ts Outdated
Comment thread tools/installer/zeta-creds-persist.ts
Comment thread tools/installer/zeta-creds-persist.ts Outdated
Comment thread tools/installer/zeta-creds-envelope.ts Outdated
Comment thread tools/installer/zeta-creds-restore.ts Outdated
Comment thread tools/installer/zeta-creds-persist.ts Outdated
@AceHack AceHack disabled auto-merge May 27, 2026 07:52
…-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.
@AceHack
Copy link
Copy Markdown
Member Author

AceHack commented May 27, 2026

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).

@AceHack AceHack closed this May 27, 2026
auto-merge was automatically disabled May 27, 2026 08:01

Pull request was closed

AceHack added a commit that referenced this pull request May 27, 2026
…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>
AceHack pushed a commit that referenced this pull request May 27, 2026
… 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>
AceHack added a commit that referenced this pull request May 27, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants