Files
session-history/ohm/0026/SESSION-0026.5-TRANSCRIPT-2026-05-28T13-46--2026-05-28T13-50.md
Ben Stull 9e6756f678 ohm: migrate orphaned subagent transcripts (0019.x, 0026.x) + renumber dup 0046.0 -> 0073
Backfills transcripts that existed only locally in ohm-infra:
- 0019.1/.2/.3 - UX-polish wave subagent transcripts (driver 0019.0 never finalized)
- 0026.1-.9 - security-audit-0026 subagent transcripts (driver abandoned/closed-out by 0068; audit drove published 0030 remediation)
- 0073.0 - PPE/progressive-delivery + engineering-handbook session, originally drafted as a duplicate 0046.0; reassigned next free number (0072 taken by a concurrent session)

sessions.json: add 0019/0073 titles, update 0026 title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:28:59 -07:00

6.6 KiB

SESSION-0026.5 — flotilla operator-CLI security audit (READ-ONLY subsession)

  • Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
  • Role: subsession 0026.5 of OHM security-audit driver session 0026
  • Surface: /Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla (the flotilla operator CLI)
  • Scope: READ-ONLY. No edits, no deploys, no secret rotation, no git mutation. Only file written = this transcript.
  • Focus: §3 invariant 1 ("no secret bytes in the build pipeline / state / log / stdout"), overlay-vs-secret separation, SSH command safety, secret set stdin-only.

Pre-state

Branch main, clean except untracked .claude/. Audited the canonical tree under ohm_rfc_app_flotilla/ (NOT the .claude/worktrees/ copies, which a naive find surfaced first — pivoted to the real package dir). Read SPEC.md §1–§13 + CLAUDE.md.

Turn-by-turn arc

  1. Read SPEC.md (§3 invariants, §6 overlay, §7 secrets, §8 deploy gesture, §10 log) and the binding CLAUDE.md "never ask for secret bytes" / two-layer rules.
  2. Listed canonical source; read secrets.py, ssh.py, deploy.py in one batch.
  3. Read deploy_log.py, db.py, registry.py, plan.py, pin.py.
  4. Grepped cli.py for stdin/tty/secret/overlay output paths; read the overlay + secret CLI verbs in full; read secret set (TTY guard + stdin).
  5. Checked migrations for any secret_value column (none — confirmed) and skimmed test_secrets.py + test_deploy.py for hardcoded/echoed real secrets (none; all stub bytes like b"NEVER-LEAK-...").
  6. Adversarial pass on candidate findings:
    • KILLED "snapshot hash leaks overlay values" — hash is sha256, one-way; values not recoverable. Not a leak.
    • KILLED "SSH command injection via target_version" — pin.py validates strict SemVer regex ^\d+\.\d+\.\d+$ before it becomes v<ver>; all VM coords and vite values go through shlex.quote. No injection.
    • KILLED "secret could be set as overlay" as a code bug — downgraded to informational: overlay/secret are distinct tables + distinct CLI paths; the non-secret-overlay boundary is operator discipline per CLAUDE.md two-layer rule, and there's no automated key-name guard, but that's a documented design boundary, not a defect. Noted F3.
    • CONFIRMED the phase-detail redaction gap (F1) by tracing append_phase → persisted phases[].detail vs. the later error_summary-only redaction.
    • Held F2 (gcloud --quiet host-key TOFU) as needs-verification.

Cut state — findings

  • F1 (MEDIUM, high confidence): Per-phase detail is persisted to the deploys.phases JSON column UNREDACTED. deploy.py _PhaseRunner.run writes detail=e.detail (e.g. phase-3 f"git fetch/checkout failed: {r.stderr...[:240]}") via deploy_log.append_phase at the moment of failure. The redact_live scrub runs LATER and ONLY on the separate error_summary string (_finalize_failure). A secret that surfaces in a command's stderr lands unredacted in flotilla state. Violates §3 invariant 1 / §10.2. The existing test (test_deploy.py:248) checks only outcome.error_summary, never the persisted phases[].detail — so the gap is untested. Remediation: redact phase detail at append_phase time (pass the live _ResolvedSecrets into _PhaseRunner), or redact in _phaseN before raising _PhaseFailure.

  • F2 (LOW, needs-verification): ssh.py invokes gcloud compute ssh ... --quiet. --quiet auto-accepts prompts including first-connect host-key confirmation (TOFU silently accepted). gcloud still manages google_compute_known_hosts and pulls host keys from instance metadata when present, so steady-state MITM exposure is limited; residual risk is first-connect. Verify exact gcloud host-key behavior before treating as actionable. Remediation if confirmed: rely on GCP guest-attr host keys / IAP (the §19.2 "harden SSH via IAP" candidate already tracks this).

  • F3 (INFORMATIONAL): No code guard prevents an operator from placing a sensitive value into the non-secret overlay (e.g. as a VITE_* key, which deploy.py phase-5 interpolates into the build command AND bundle-embeds). This is the documented two-layer boundary (operator discipline), not a defect — flagged so the driver knows the enforcement is human, not mechanical.

  • F4 (LOW, edge): redact() / redact_live() match needles via bytes.decode("utf-8", errors="replace"). A secret containing non-UTF-8 bytes would decode to a different string than what landed in (replace-decoded) stderr, so redaction could miss it. OHM secrets are random ASCII/UTF-8 tokens, so low real risk; noted for completeness.

What's solid (controls correctly enforcing §3 invariant 1)

  • No secret_value column anywhere; migration 002 secret_refs holds only project/id/version refs. DB stores references, not bytes.
  • secret set is stdin-only with an explicit sys.stdin.isatty() TTY refusal (cli.py) — matches CLAUDE.md exactly; bytes never enter shell history/args.
  • read_secret returns bytes that the deploy/plan paths use transiently; _ResolvedSecrets holds them in bytearrays scrubbed-to-zero on scope exit (incl. exception unwind), env_body bytearray zeroed in a finally.
  • .env written atomically over SSH stdin (never local disk, never a command arg): umask 077 + cat > tmp + mv as sudo -u <user>, mode 0600, owner = service user. Verified env body travels via input= (stdin), not the command string (test_deploy.py:166 asserts bytes are in stdin, not in any recorded command).
  • Snapshot hash and plan render use ONLY ref strings, never bytes.
  • error_summary redaction path IS applied (the F1 gap is the phase detail, a separate persisted field).
  • All SSH-interpolated values shlex.quoted; pin is strict-SemVer-validated.
  • secret list / overlay show render [secret: <project>/<id>@<version>], never resolved bytes.
  • Tests use only stub bytes (b"NEVER-LEAK-...", b"abc123"); no real secret hardcoded or echoed.

Driver needs to know

  • F1 is the one worth fixing: it's a real (if narrow — requires a secret to appear in remote stderr) §3-invariant-1 leak into flotilla state, and it's untested.
  • F2 ties into the already-tracked §19.2 "harden SSH via IAP" item.

§19.2 candidates surfaced

  • Mechanical overlay-key denylist / secret-key collision guard (relates F3) — make the two-layer rule enforced, not just disciplined.
  • Redaction hardening: redact at every state-write boundary (phase detail included), not only at finalize (relates F1, F4).

No secret bytes appear anywhere in this transcript.