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

113 lines
6.6 KiB
Markdown

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