# 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.quote`d; 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.
