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>
This commit is contained in:
Ben Stull
2026-06-05 03:28:59 -07:00
parent 4521fdebc0
commit 9e6756f678
14 changed files with 1727 additions and 1 deletions
@@ -0,0 +1,158 @@
# SESSION-0019.1 — App.css + index.css token sweep
Parent: SESSION-0019.0-TRANSCRIPT-… (UX-polish wave, rfc-app v0.21.0, roadmap item #31)
## Goal
Sweep `frontend/src/App.css` (2434 lines) and `frontend/src/index.css`
onto the design-token system established in `frontend/src/styles/tokens.css`:
hardcoded hex → nearest semantic/primitive token, font-size literals →
`--text-*` scale, border-radius literals → `--radius-*` scale, safe
spacing where it maps cleanly, a coherent transition vocabulary + a
consistent `:focus-visible` ring, and base rules pointed at semantic
tokens. Hard constraint: never rename/remove a selector (parallel
siblings reference these class names); values + additive rules only.
## Pre-state
- Worktree: `/Users/benstull/git/rfc-app-v0.21.0-appcss`
- Branch `feature/v0.21.0-appcss-sweep`, based on `5be2c48` (token-module
foundation).
- index.css used literal `#1a1a1a` / `#fafaf8` / inline font stack.
- App.css had ~98 distinct hex (66× `#fff`, 51× `#1a1a1a`, plus a long
tail of near-duplicate grays: #888/#999/#777/#666/#555 etc.), 16
distinct font-size px values, 12 distinct radius px values.
## Turn-by-turn arc
1. Read tokens.css (the foundation) + both target files in full
(App.css needed two reads — 2434 lines). Confirmed branch/base.
2. Extracted the full distinct-hex inventory via grep+uniq across both
files to build the mapping deterministically rather than guessing.
3. Round-1 script (`/tmp/sweep.py`): regex word-boundary,
case-insensitive, longest-key-first replacement of all hex that map
cleanly to a ramp step or status/accent token. 615 replacements.
- One dict-key collision worth noting: `#ddd` appears as both a
gray-300 candidate and an on-dark-muted candidate. Resolved to
`--c-gray-300` (the consolidation target); the header user-name
`#ddd`-on-dark shifts imperceptibly to #d1d5db.
4. Re-grepped remainder; 35 distinct hex left — all genuinely off-token
(true blues, violet tints/shades, functional state dots, deep
diff-contrast shades, warm oranges with no token).
5. Round-2 script (`/tmp/sweep2.py`): conservative — only the 7 hex that
are unambiguously the same ROLE as an existing token and visually
near it (light-red error bgs/borders → danger-bg/-border, warm soft
bgs → warning-bg-soft). 15 replacements. Left everything else.
6. index.css base `:root``var(--font-sans)`, `var(--color-text)`,
`var(--color-bg)`.
7. Round-3 script (`/tmp/sweep3.py`): font-size px → `--text-*` (280),
border-radius px → `--radius-*` (128), `rgba(255,255,255,.15/.25)`
`--color-on-dark-soft/-hover` (5), the three 48px header-band values
`--header-height` (3). Note: script rewrite invalidated an Edit I
had queued for `.app-header`; re-read + re-applied.
8. Promoted `.app-header` to semantic `--color-header-bg` /
`--color-text-inverse`.
9. Appended (not interleaved) the interaction-polish layer: a
transition vocab on hover-reactive selectors + one `:focus-visible`
ring using `--color-focus-ring`. Additive only — no resting-state
change, reduced-motion handled by the token module's media query.
10. Build: first attempt failed on `VITE_APP_NAME is required` (env
gate, not CSS). Re-ran with `VITE_APP_NAME=OHM` → built in 249ms,
only the expected ~500kB chunk-size warning. CSS is sound.
11. Committed; wrote this transcript.
## Hex → token MAPPING TABLE
Grays (→ neutral ramp):
| literal(s) | token | note |
|---|---|---|
| #ffffff, #fff | --c-white | |
| #fafafa, #fafaf8, #fafaf9, #fafbfc, #f9f9f9, #f9fafb | --c-gray-50 | |
| #f3f4f6, #f5f5f5, #f6f6f4, #f7f7f5, #f7f6f0 | --c-gray-100 | |
| #f0f0ee, #fcfcfb | --c-gray-150 | warm canvas |
| #e5e5e5, #e5e7eb, #e5e5e0, #e7e5e4, #eee | --c-gray-200 | |
| #d1d5db, #d4d4d4, #ddd, #ccc | --c-gray-300 | **#ccc#d1d5db slightly lighter; #ddd#d1d5db imperceptible** |
| #9ca3af, #aaa, #b0b0b0, #94a3b8 | --c-gray-400 | #94a3b8 is slightly cooler→neutral, minor |
| #6b7280, #888, #999, #777, #666, #555 | --c-gray-500 | **#555#6b7280 and #999#6b7280 are the most noticeable gray shifts (the desired consolidation)** |
| #4b5563, #444, #475569 | --c-gray-600 | **#444#4b5563 noticeable; #475569#4b5563 slightly warmer** |
| #374151, #333 | --c-gray-700 | **#333#374151 slightly cooler, noticeable** |
| #1f2937, #222 | --c-gray-800 | **#222#1f2937 noticeable** |
| #111 | --c-gray-900 | |
| #1a1a1a | --c-ink (header→--color-header-bg, base text→--color-text) | |
Accent / status (→ family tokens):
| literal | token |
|---|---|
| #5b5bd6 | --c-accent |
| #4338ca | --c-accent-strong |
| #7c3aed | --c-violet |
| #166534 | --c-success-fg |
| #dcfce7 | --c-success-bg |
| #991b1b | --c-danger-fg |
| #b91c1c | --c-danger-fg-strong |
| #fef2f2, #fff5f5 | --c-danger-bg |
| #fecaca, #fca5a5, #fcc | --c-danger-border |
| #fee2e2, #fee | --c-danger-bg (light red error bg; **slightly pinker→#fef2f2**) |
| #92400e | --c-warning-fg |
| #b45309 | --c-warning-accent |
| #fef3c7 | --c-warning-bg |
| #fffbeb, #fff7ed, #fff8e0 | --c-warning-bg-soft (**#fff7ed warm→#fffbeb, minor**) |
Font-size (→ --text-* scale; round-to-nearest flagged):
10→2xs · 11→xs · 12→sm · 13→base · 14→md · **15→lg(16)** ·
16→lg · **17→xl(18)** · 18→xl · **19→xl(18)** · 22→2xl ·
**24→2xl(22)** · **26→3xl(28)** · 28→3xl. 9px left as literal
(below the 10px scale floor — the .beta-chip micro-tag).
Border-radius (→ --radius-* scale):
2→xs · **3→sm(4)** · 4→sm · **5→md(6)** · 6→md · 8→lg ·
**10→xl(12)** · 12→xl · **14→xl(12)** · **20→pill** (model-pill,
already pill-shaped at its height) · 99→pill · 999→pill. `50%`
(circle dots) left untouched.
On-dark + layout:
rgba(255,255,255,0.15)→--color-on-dark-soft;
rgba(255,255,255,0.25)→--color-on-dark-hover;
48px header band (×3)→--header-height.
rgba(255,255,255,0.08) (header link hover) LEFT — no token.
Left as literals (no clean token, intentional): blues #3b82f6 #2563eb
#1e40af #dbeafe #bfdbfe #eff6ff #eef2ff #c7d2fe; violet tints/shades
#c4b5fd #ede9fe #ddd6fe #faf5ff #4c1d95 #3737a0 #3730a3; status dots /
functional #ef4444 #dc2626 #fbbf24 #f59e0b #d97706 #10b981; deep
diff-contrast #14532d #7f1d1d #78350f #bbf7d0 #f0fdf4; warm/misc
#9a3412 #c2410c #4a3f00 #cb6a6a #cfc8a8 #e6dca0 #fde68a #fdba74
#fed7aa #f1f5f9. Spacing (--space-*) left untouched this pass —
prioritized color/type/radius per the brief; no spacing literal was
both clearly mappable and zero-layout-risk enough to be worth it.
## Cut state
- Branch: `feature/v0.21.0-appcss-sweep`
- Final commit: `cbf02d5507f91cc5bf16a68b3f8a8b6b52990849`
- Files changed: `frontend/src/App.css`, `frontend/src/index.css` (only).
- Replacements: ~630 color, 280 font-size, 128 radius, 5 on-dark, 3
header-height. Build green (`VITE_APP_NAME=OHM npm run build`, 249ms,
expected chunk-size warning only).
## What the driver needs to know
- No selector renamed/removed — safe for parallel sibling subagents.
- Gray consolidation deliberately shifts a handful of mid-grays by a
visually-noticeable amount (#555/#999→gray-500, #444→gray-600,
#333→gray-700, #222→gray-800). This IS the intended polish; operator
should eyeball muted-text and secondary-button text before deploy.
- 15px reading-body → 16px (lg): markdown/entry/landing body text gets
1px larger. Intentional rounding; check it reads well.
- The appended polish layer adds a global `:focus-visible` ring and
transitions to broad element selectors (button/a/input/…). Low risk
(focus-visible never fires on pointer), but it is the one place that
touches elements beyond the named classes.
## §19.2 candidates
- The on-dark translucent-white pattern (`--color-on-dark-soft/-hover`)
and the focus-ring/transition vocabulary are deployment-agnostic and
would belong in a future `flotilla-core`-adjacent shared UI layer if
rfc-app's frontend ever extracts one — but that's an rfc-app concern,
not flotilla. No flotilla-side extraction implied by this work.
@@ -0,0 +1,96 @@
# SESSION-0019.2 — header rename + inbox icon + light inbox UX
Parent: SESSION-0019.0-TRANSCRIPT-… (UX-polish wave, rfc-app v0.21.0)
Subsession: 0019.2
Window: 2026-05-28T11-27 → 2026-05-28T11-31 (PST)
## Goal
Roadmap #24 (rename), #25 (inbox icon + light inbox UX), and the
header's share of #31 polish — confined to the App.jsx header region,
Inbox.jsx, and a new Inbox.css. No version/changelog/tag/merge/push.
## Pre-state
- Branch `feature/v0.21.0-header-inbox` at the foundation commit (token
module `frontend/src/styles/tokens.css` present).
- Header link text was "About" (already routing to /philosophy).
- Inbox trigger used a 📮 emoji with title "Notifications inbox (§15.2)".
- Inbox.jsx: working panel; row click marks-read + navigates; one
"Mark all read (under filter)" button; terse empty copy; no per-row
mark-read; unread indicated only by `.inbox-row.unread` bg tint.
- No Amplitude `track()` calls inside Inbox.jsx (only api.js calls:
listNotifications, markNotificationRead, markNotificationsReadByFilter).
So "preserve §15 tracking" = don't break the data path; nothing to add.
## Turn-by-turn arc (honest)
1. Read tokens.css, the App.jsx header block (~150238), Inbox.jsx full,
and the §15 inbox block of App.css (15731641) to learn existing class
names + property values before touching anything.
2. #24: one-word change "About" → "Philosophy" (kept the title attr).
3. #25 icon: replaced `<span>📮</span>` with a hand-rolled inline SVG
envelope (18×18, viewBox 0 0 24 24, stroke=currentColor, ~1.75 stroke,
rounded). Set `aria-label="Inbox"`, retitled "Inbox (§15.2)". Badge
block untouched.
4. #25 UX: refactored Inbox.jsx. Extracted `markOneRead(item)` (used by
both row-click and the new per-row affordance) which also decrements
the local unreadCount. Added an `inbox-unread-dot` span + `read`/`unread`
row class, a per-row "Mark as read" button (inline check SVG) that sits
OUTSIDE the Link and `preventDefault/stopPropagation`s so it marks
without navigating, shown only for unread rows. Simplified the
mark-all label to "Mark all read" with an explanatory title. Replaced
empty copy with a "You're all caught up." state that branches on
whether filters are active.
5. Wrote Inbox.css (tokenized). Discovered via import order that Inbox.jsx
is imported at App.jsx line 6, BEFORE App.css at line 30 — so under
ESM depth-first eval Inbox.css injects FIRST and App.css wins ties.
Wrote override rules one notch more specific (`.inbox-list .inbox-row…`)
and `.inbox-empty .muted` to beat global `.muted`. New classes need no
guard. All values via var(--space/-text/-color/-radius/-motion/-ease).
6. Build: first `npm run build` failed — VITE_APP_NAME required (config
guard, unrelated to my change; no .env in worktree). Re-ran with
`VITE_APP_NAME="OHM RFC" npm run build` → green (expected ~971kB
chunk warning only).
7. Verified `git status` shows only App.jsx + Inbox.jsx modified and
Inbox.css new (node_modules symlink left untracked). Committed exactly
those three.
## Cut state
- Branch: `feature/v0.21.0-header-inbox`
- Final commit: 317738ed7998a7af5abe0b64769941cb11808ab2
- Files changed: frontend/src/App.jsx (header region only),
frontend/src/components/Inbox.jsx, frontend/src/components/Inbox.css (new)
- Build: green (chunk-size warning only).
## What the driver needs to know (inbox UX review pointers)
The inbox pass is LIGHT — same layout, panel, filters, bundle toggle,
deep-links, and API calls as before. Exactly what changed for review:
- Unread/read: unread rows now get an accent left-bar (inset box-shadow)
+ warm tint + a filled accent dot + medium-weight summary; read rows
drop the dot (space reserved, stays aligned) and mute the summary.
- Per-row affordance: a check-icon button appears on row hover/focus
(always visible on touch via `@media (hover:none)`) that marks the row
read without navigating. Uses the same markOneRead path.
- Mark-all: relabeled "Mark all read" (+ title clarifying it's
filter-scoped). Same `markNotificationsReadByFilter` call, same
disabled-when-all-read guard.
- Empty state: "You're all caught up." with filter-aware sub-copy.
- Behavior preserved: row-click still marks-read then navigates +
closes; unreadCount now also decremented locally on single marks (was
only reset to 0 on mark-all before — minor, improves badge accuracy,
SSE still authoritative per §15.3).
## §19.2 candidates
- Full inbox redesign deferred to a #25-followup: the operator's
reference screenshot did not transmit, so this stayed a polish pass
within the existing structure rather than a re-layout. Revisit with
the screenshot.
- The inbox-trigger / inbox-* base rules still live in App.css with
hardcoded hex (a sibling is tokenizing App.css this wave). If that
sweep lands, the few overrides in Inbox.css could shed their
extra-specificity guards once import order is normalized.
@@ -0,0 +1,141 @@
# Session 0019.3 — Transcript
Parent: SESSION-0019.0-TRANSCRIPT-… (UX-polish wave, rfc-app v0.21.0)
Subagent ordinal: .3 — roadmap item #32 (session/transcript page
polish) + the /docs surfaces' share of #31.
## Goal
In the isolated worktree `/Users/benstull/git/rfc-app-v0.21.0-docs`
on branch `feature/v0.21.0-docs-32`:
(a) Collapse single-transcript session roots — render the transcript
inline at `/docs/sessions/:nnnn` instead of a dead-end placeholder;
multi-transcript sessions inline the `.0` driver transcript and
list the siblings.
(b) Add a transcript metadata header (title, started/ended parsed from
the filename ISO segments, derived duration, optional `tldr` from
the manifest, external view-source link).
(c) Docs-surface token polish + consistent loading/empty/error states +
aria-labels on icon-only controls.
## Pre-state
- Foundation commit carried `frontend/src/styles/tokens.css` (the #31
design-token module). Build requires `VITE_APP_NAME` (no default).
- `/docs/sessions/:nnnn` (DocsSessionIndex.jsx) rendered a
`.docs-session-overview` placeholder: "N transcript(s) in this
session. Select one from the navigation."
- DocsSessionTranscript.jsx rendered body via MarkdownPreview with a
breadcrumb but no metadata header.
- Backend `/api/docs/sessions/manifest` (api.py:177-178) returns
`result["manifest"]` verbatim — the full `sessions.json` object —
so arbitrary per-entry fields already reach the frontend.
## Turn-by-turn arc (honest)
1. Read tokens.css, SESSION-PROTOCOL.md (§1 naming + the 0017.0
folder-layout amendment + the sessions.json shape), all seven owned
docs components, api.js docs helpers, docs_sessions.py, and the
api.py manifest route.
2. Confirmed the manifest endpoint is a pass-through → **no backend
change needed** for `tldr`. The decision branch in the task was
resolved in favor of "already passes through".
3. Read the existing docs CSS block in App.css (read-only) to learn
which classes a sibling owns (.docs-article, .docs-empty,
.docs-error, .docs-source-link, .docs-session-overview, etc.) so
Docs.css would not collide.
4. Rewrote DocsSessionTranscript.jsx: exported `gitSourceUrl`,
`transcriptOrdinal`, `parseTranscriptMeta`, and `TranscriptMetaHeader`;
added a manifest fetch for title+tldr; rendered the header above the
body.
5. Rewrote DocsSessionIndex.jsx to inline-render the primary transcript
(driver `.0` preferred, else `files[0]`) and list siblings; reused
the imported header + ordinal helpers.
6. Created Docs.css (new classes only, all token-valued).
7. Brought DocsUserGuide's bare `<p className="error">` onto the shared
`.docs-error` + retry convention.
8. Build failed first run (VITE_APP_NAME unset — env requirement, not
my change); re-ran with `VITE_APP_NAME="OHM Verify" npm run build`
→ green (974 kB index chunk; >500 kB warning expected/benign).
9. Staged the four files explicitly (avoided the `frontend/node_modules`
symlink), committed.
## Design decisions
**Inline-collapse (a).** URL stays stable to the session number — the
session root fetches the index, picks a primary file, fetches its body,
and renders the same `TranscriptMetaHeader` + `philosophy-body` the
standalone route uses. No `<Navigate>`, no 301. Primary selection:
`SESSION-NNNN.0-TRANSCRIPT…` if present, else `files[0]` (backend
returns the list pre-sorted). For multi-transcript sessions, a
`.docs-session-siblings` strip lists every transcript ordinal — the
primary marked "(shown below)" with `aria-current`, the rest linking to
`/docs/sessions/:nnnn/:filename`. No empty placeholder remains on any
path that has at least one transcript.
**Metadata header (b).** `parseTranscriptMeta` regexes the
`-TRANSCRIPT-<start>--<end>.md` ISO segment (`YYYY-MM-DDTHH-MM` x2,
colons-as-dashes per protocol). Times are built as **local** Dates and
rendered as wall-clock via `toLocaleString` — the filename encodes no
timezone (PST implied), and duration is a difference of two same-basis
Dates so the implied-TZ ambiguity cancels. Legacy renamed-letter
transcripts (no segment) → all derived fields null → header shows just
the title + source link (graceful degrade). Duration omitted when
start==end or unparseable.
**tldr schema (b).** Consumed as a **string** field named `tldr` on the
per-session entry of `sessions.json`, i.e. keyed by the 4-digit session
number alongside `title`:
```json
{ "0019": { "title": "UX-polish wave …", "tldr": "One-line summary." } }
```
Read via `getSessionsManifest()``payload[nnnn].tldr`; rendered only
when `typeof === 'string'` and truthy. Absent ⇒ no TL;DR line.
**Polish (c).** Docs.css styles ONLY the two new element families
(`.docs-transcript-meta*`, `.docs-session-siblings*`), every value a
tokens.css custom property, to avoid racing the #31 App.css sweep for
the pre-existing docs selectors. DocsUserGuide normalized to the shared
error convention. All icon-only controls already carried aria-labels
(verified the drawer toggle `☰`, scrim) — no aria gaps found in owned
files.
## Cut state
- Branch: `feature/v0.21.0-docs-32`
- Final commit SHA: `adb5d2571532ad5992900791d295ef214ddfba0d`
- Files changed (4):
- `frontend/src/components/DocsSessionIndex.jsx` (rewrite — inline collapse)
- `frontend/src/components/DocsSessionTranscript.jsx` (rewrite — meta header + exports)
- `frontend/src/components/DocsUserGuide.jsx` (error-state normalize)
- `frontend/src/components/Docs.css` (new)
- **Backend: NOT changed.** docs_sessions.py untouched — manifest
pass-through already exposes `tldr`. (No py_compile needed.)
- Build: GREEN (`VITE_APP_NAME=… npm run build`; 974 kB chunk warning benign).
## What the driver needs to know
- **No backend coordination required** for this item — frontend renders
`tldr` when present; the field's value-backfill is the documented
out-of-scope follow-up.
- **sessions.json `tldr` schema to backfill** (driver/operator task in
`wiggleverse/ohm-session-history`): add an optional `"tldr": "<one
line>"` string to any per-session entry next to `"title"`. The
frontend already consumes it on both the inline session-root view and
the standalone transcript view. Absent ⇒ no-op.
- Inline-collapse changes the `/docs/sessions/:nnnn` semantics: it is no
longer nav-only. The left-nav transcript rows + sibling links still
point at the standalone `/docs/sessions/:nnnn/:filename` route, which
is unchanged and now also carries the metadata header.
- Build needs `VITE_APP_NAME` set (env requirement, not from this item).
## §19.2 candidates
- `parseTranscriptMeta` / `transcriptOrdinal` are filename-shape logic
that mirrors the backend `_TRANSCRIPT_FILENAME_RE` and the nav's own
`transcriptOrdinal` in DocsLayout.jsx — there are now two frontend
copies of the ordinal regex (DocsLayout owns one; I export one). A
future consolidation could lift a single `lib/transcripts.js` and have
DocsLayout import it (DocsLayout is sibling-owned, so I did not touch
it). Worth flagging for a follow-up de-dup.
@@ -0,0 +1,200 @@
# SESSION-0026.1 — AuthN/AuthZ security audit subsession (rfc-app v0.24.0)
Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Date: 2026-05-28T13-46 → 2026-05-28T13-50 (America/Los_Angeles)
Role: subsession 0026.1 of OHM security-audit driver session 0026.
Mode: READ-ONLY. No edits, no deploys, no mutations. Only file written is this transcript.
## Goal
Audit the authentication / authorization surface of the rfc-app codebase
(`/Users/benstull/git/rfc-app`, tag v0.24.0): the email/OTC login flow,
passcodes, device-trust, Gitea OAuth + core auth, session/cookie security,
the admin role model, and per-endpoint authz guards across every
`api*.py` write/admin/owner endpoint. Verify deployment guarantee #4
("anonymous users are off-limits for any write/mutation"). Hunt for missing
guards, IDOR, and OTC/passcode brute-force/replay.
## Outcome
One High finding (OTC verify has no attempt limit / lockout — asymmetric
with the passcode path which does). Two Low/Info findings (session cookie
`https_only=False`; email-bounce webhook unauthenticated when env unset —
both documented dev contracts). Every enumerated write/admin/owner endpoint
enforces auth; no missing guards, no IDOR found. Admin role model is sound.
## Pre-state (what I found at the start)
- `auth.py` defines the dependency ladder: `require_user` (401 if anon) →
`require_contributor` (adds muted + permission_state='granted' gate) →
`require_admin` (role in owner/admin). Plus per-RFC predicates
(`can_contribute_to_rfc`, `can_discuss_rfc`, `can_invite_to_rfc`,
`is_rfc_owner`). `current_user` re-reads role + permission_state from DB
every request (so grants/revokes take effect next request — good).
- Credentials: OTC 6-digit codes (bcrypt at rest, 10-min TTL, 60s request
cooldown, single live code per email); passcodes (bcrypt, 5-fail/15-min
per-account lockout, denylist, 420 chars); device-trust (256-bit CSPRNG,
bcrypt, 30-day, HttpOnly+Secure+SameSite=Lax cookie); invite tokens
(256-bit CSPRNG, bcrypt, 7-day, single-use).
## Turn-by-turn arc
1. Read auth.py + config.py. Dependency ladder is clean. Noted SECRET_KEY
is `_required` (good — no insecure default). OAuth provisioning sets
owner role only via OWNER_GITEA_LOGIN match; OTC/invite users get
gitea_login="" / gitea_id=0 coalesced.
2. Read otc.py + passcode.py side by side. **Caught the asymmetry**:
`passcode.verify_passcode` has a per-account failed-attempts counter +
15-min lockout; `otc.verify_code` has NOTHING — it walks the last 5
rows, bcrypt-checks, and returns "wrong" with no counter, no lockout,
no per-IP throttle. The 60s cooldown lives in `request_code`, not
`verify_code`. This is the headline finding.
3. Read device_trust.py + email_otc.py. Device-trust is solid (constant-time
bcrypt walk, no early-exit, token never logged, cookie flags correct,
user-scoped revoke). email_otc always returns 202 / logs failures —
no email enumeration via the request path.
4. Read main.py (middleware + all /auth endpoints). Confirmed:
- SessionMiddleware uses `https_only=False` (line 150) — itsdangerous-
signed cookie, HttpOnly by default, but Secure flag off. Dev parity
comment present. Behind HTTPS+proxy in prod this is low-impact but
worth flagging.
- `/auth/otc/verify` has NO rate-limit dependency, confirming finding #1
at the endpoint layer too.
- Turnstile gates `/auth/otc/request` ONLY, not verify.
5. Refutation pass on finding #1: Is there a global limiter? Grepped for
RateLimit/slowapi/limiter/429 across backend/app — only tag_suggest.py
(per-user, in-memory) and the OTC *request* cooldown 429. No global
middleware, no limiter on verify. SPEC §ahead-of (line ~4100) discusses
per-IP throttle for the *passcode* verify (which already has a per-
account lockout) but never notes the OTC verify has no per-account limit
at all. Quantified: single live code per email, bcrypt ~tens of ms,
10^6 keyspace, 10-min TTL. Full sweep not possible in one TTL, but
attacker re-requests (60s cooldown) to keep a live code and runs
parallel verifies; expected ~500k guesses. bcrypt cost + TTL + single-
live-code = real friction but NO attempt cap. Downgraded Critical→High.
6. Read api_admin.py end to end. Every endpoint calls require_admin; the
user-search typeahead intentionally uses require_user (no privileged
data). Role escalation well-guarded: only owner grants/changes owner;
self-downgrade, self-mute, self-permission-flip, self-invite all refused;
admins/owners not write-mutable. No spoof/escalation path. SOLID.
7. Enumerated routes+guards across api.py, api_prs.py, api_branches.py,
api_graduation.py, api_invitations.py, api_notifications.py,
api_discussion.py via grep. Every write/mutation route has require_user
/ require_contributor / require_admin, and the per-RFC write routes
additionally call can_contribute_to_rfc / can_discuss_rfc /
can_invite_to_rfc. GET read routes use current_user (nullable) — anon
read is by design (guarantee #4 is about writes).
8. IDOR hunt — checked every owner/id-comparing helper:
- api_prs `_can_merge`/`_can_withdraw`/`_can_edit_pr_text`: compare
viewer.gitea_login against RFC frontmatter owners/arbiters cache and
pr_row["opened_by"] — server-side data, not user-supplied. Safe.
- api_branches `_require_branch_owner`/`_can_contribute`/
`_can_read_branch`: branch grants keyed on grantee_user_id (session
id), creator compared to gitea_login. Safe.
- notification mute (`/api/users/{user_id}/notification-mute`): SQL
scoped to viewer.user_id; only the {user_id} *target* is user-supplied
and it's just the mute subject, not an ownership pivot. Self-mute
refused. Safe.
- invitations accept: requires auth + case-insensitive email match +
refuses empty email; collaborator row keyed on viewer.user_id. Safe.
- Considered: OTC/invite users have gitea_login="". Could "" match an
owners list? Only if frontmatter literally contained "" — impossible
from gitea_login frontmatter. `"" in []` is False. Killed as non-issue.
9. Checked anonymous-reachable mutation surfaces:
- `/api/email/unsubscribe` (GET+POST): itsdangerous URLSafeSerializer
signed token (SECRET_KEY, salted) → user_id+category not forgeable;
idempotent, low-stakes; no expiry but URLSafeSerializer is fine here.
- `/api/webhooks/email-bounce`: optional X-Webhook-Secret (constant-time
compare) when WEBHOOK_EMAIL_BOUNCE_SECRET set; UNAUTHENTICATED when
unset (documented v1 dev contract). Impact: flip email opt-out flags +
mark outbound rows bounced. Low.
- `/api/webhooks/...` Gitea receiver: mandatory HMAC-SHA256, constant-
time compare, refuses to start without secret unless
RFC_APP_INSECURE_WEBHOOKS=1. SOLID.
- `/api/invites/claim`, `/auth/device-trust/start`: anon by design (they
establish the session); both gated by 256-bit bcrypt-hashed tokens.
10. Confirmed db is a single shared sqlite connection (check_same_thread=
False, isolation_level=None) — no incidental concurrency limiter that
would mitigate the OTC brute-force.
## Cut state (final findings)
### High
- **F1 — OTC verify has no attempt limit / lockout / per-IP throttle.**
`backend/app/otc.py:211 verify_code` (endpoint `backend/app/main.py:279
/auth/otc/verify`). The passcode path (`passcode.py:262`) has a 5-fail/
15-min per-account lockout; the OTC path has none. 60s cooldown is on
*request* only. 6-digit code (10^6), 10-min TTL, single live code per
email. Remediation: add a per-account failed-OTC-attempt counter +
lockout mirroring passcode.py, and/or a per-IP 429 throttle on verify;
consider invalidating the live code after N failures. Confidence: HIGH
the control is absent (verified across module + endpoint + global grep).
Severity argued down from Critical to High because bcrypt cost + 10-min
TTL + single-live-code give real friction; still a genuine gap and an
inconsistency with the passcode path.
### Low / Info
- **F2 — Session cookie `https_only=False`.** `backend/app/main.py:150`
SessionMiddleware. Cookie is itsdangerous-signed + HttpOnly, but Secure
is off "for dev parity." The device-trust cookie correctly sets
Secure=True. In prod behind HTTPS the session cookie should also be
Secure. Remediation: drive https_only from an env flag, true in prod.
Confidence: HIGH it's set false; impact Low given HTTPS + signed cookie.
- **F3 — Email-bounce webhook unauthenticated when env unset.**
`backend/app/api_notifications.py:543`. Documented v1 dev contract;
impact limited to email opt-out flag flips + outbound-row status. Info/
Low. Confidence HIGH on behavior, low on impact.
### What's solid (controls correctly implemented)
- Three-tier dependency ladder; `current_user` re-reads role +
permission_state from DB every request (revokes take effect immediately).
- Admin role model: owner-only owner grants, self-downgrade/self-mute/
self-permission/self-invite refusals, admins/owners not write-mutable.
No escalation/spoof path. Every /api/admin/* endpoint require_admin.
- Every write/mutation endpoint across all api*.py files enforces auth;
per-RFC writes additionally enforce can_*_to_rfc. No missing guard.
- No IDOR: owner/permission checks compare against server-side frontmatter
cache / session user_id / pr opened_by, never a trusted user-supplied id.
- Passcode lockout (5/15min), denylist; OTC single-live-code + consume-
before-provision (no replay, no double-sign-in).
- All tokens (OTC, passcode, device-trust, invite) bcrypt-hashed at rest;
device-trust/invite tokens 256-bit CSPRNG; constant-time bcrypt walks,
no early-exit timing leak; raw tokens never logged.
- Gitea webhook mandatory HMAC-SHA256 + constant-time compare; OAuth state
param checked with secrets.compare_digest.
- SECRET_KEY is _required (no insecure default); unsubscribe tokens signed
with it (salted, not forgeable).
- OTC request returns 202 regardless (no email enumeration); verify
collapses wrong/unknown to one 400 (no enumeration); passcode verify
collapses wrong/unknown likewise.
## What the driver needs to know
- F1 is the only finding worth a remediation slice. It is a real,
exploitable-with-effort gap AND an internal inconsistency (passcode has a
lockout, OTC doesn't). Recommend the driver fold it in at High; if the
driver judges the bcrypt+TTL friction insufficient given OHM is publicly
reachable, it could argue back toward Critical — I left it at High after
refutation.
- F2/F3 are documented dev contracts; flag them but they are low priority.
Worth a one-line confirmation that OHM's prod overlay sets
WEBHOOK_EMAIL_BOUNCE_SECRET and serves over HTTPS (the device-trust
cookie is Secure-only, so if OHM weren't HTTPS, device trust would
already be broken — implying HTTPS is in fact present, which de-risks F2).
- The authz guard coverage is genuinely complete — guarantee #4 holds for
every write/admin/owner endpoint I enumerated. No anon-write hole found.
## §19.2 candidates surfaced
- Per-account OTC-verify lockout + per-IP verify throttle (pairs with the
SPEC's already-listed per-IP passcode-verify candidate). This is the
remediation for F1 and is a flotilla-irrelevant, rfc-app-core change.
@@ -0,0 +1,136 @@
# SESSION-0026.2 — rfc-app Injection & Input-Handling Security Audit
Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Subsession: 0026.2 (read-only audit leg of security-audit driver session 0026)
Date: 2026-05-28 (PST)
Target: rfc-app at /Users/benstull/git/rfc-app, tag v0.24.0 (HEAD 28015ed)
Scope: SQL injection, XSS, SSRF, path traversal, email/markdown sanitization.
Mode: READ-ONLY. No edits, no deploys, no git mutations. No secret bytes recorded.
## Pre-state
Driver assigned the injection & input-handling surface. Repo confirmed at
tag v0.24.0. Backend is FastAPI + SQLite (backend/app/*.py); frontend is
React 19 + Vite (frontend/src). Markdown via `marked` v18; no sanitizer
library in frontend/package.json dependencies.
## Turn-by-turn arc
1. Enumerated backend modules and grepped all 321 `execute*` call sites for
f-string / `.format` / `%` / concat SQL. Pulled the ~16 dynamic-SQL hits.
2. SQL injection — examined every dynamic-SQL site:
- api_notifications.py:274 (`UPDATE users SET {', '.join(sets)}`) — `sets`
contains only hardcoded column-name literals; values parameterized via
`args`. Safe.
- api_notifications.py:479/483 (`UPDATE users SET {column} = ...`) —
`column` from static `_CATEGORY_COLUMN` allowlist. Safe.
- api_admin.py:413, notify.py:535, digest.py:201, email.py:610,
notify.py:942/964/1000 — all IN-clauses built via `",".join("?" * len)`,
the correct parameterized-IN idiom; values in the tuple. Safe.
- notify.py:638/643 — `{now}` is the literal `datetime('now')`, not input.
- api_admin.py:629/711/754 — `WHERE` built from `clauses` lists holding
only fixed `col = ?` strings; user values parameterized; `limit` is
FastAPI `int` with ge/le bounds. Safe.
- db.py — connection/migration layer; migrations read trusted on-disk .sql.
- Swept for dynamic ORDER BY / LIMIT / table-name injection: none found.
Verdict: parameterization is consistent and correct across the backend.
3. XSS — found the markdown pipeline:
- frontend/package.json has NO DOMPurify / rehype-sanitize / any sanitizer.
- MarkdownPreview.jsx:101-102 — `previewMarked.parse(content)`
`hostRef.current.innerHTML = html`. The custom `Marked` instance only
overrides the ```mermaid code-fence renderer; everything else is marked
default (raw HTML passthrough). `escapeHtml` is applied ONLY to the
mermaid placeholder text and error text, NOT the document body.
- ProposalView.jsx:164 & 172 — `dangerouslySetInnerHTML` with the GLOBAL
unconfigured `marked.parse(data.entry?.body)` / `proposed_use_case`.
- Content sources: RFCView passes RFC body (previewContent/editorContent)
into MarkdownPreview; DocsSessionTranscript / DocsSpec / DocsUserGuide /
Philosophy / DocsSessionsAbout/Index also feed body through it. RFC body
and proposal body/use-case are free-text authored by any invited user.
- Backend: no sanitization on write (api.py — only Field max_length caps).
- Self-refutation step: ran `marked.parse` on
`<img src=x onerror=...> <script> [xss](javascript:...)`. Output passes
all three through verbatim. With innerHTML/dangerouslySetInnerHTML the
`<img onerror>`, `<svg onload>`, event handlers and `javascript:` links
execute (the bare `<script>` won't via innerHTML, but the rest do).
Confirmed stored XSS. Killed nothing — sink reachable, input
attacker-controlled, no upstream sanitizer.
- Editor.jsx:125 feeds marked output into TipTap `setContent`. NOT a raw
sink — ProseMirror's schema normalizes/drops unknown nodes & attrs.
Marked this path as solid, not a finding.
4. SSRF — checked every outbound-fetch module:
- gitea.py: httpx client `base_url` fixed from config; owner/repo/path are
gitea API path segments, not arbitrary hosts.
- docs_sessions.py / docs_specs.py: fetch from raw/contents bases that come
from env (operator) or framework defaults; the per-spec `url` is taken
only from the operator-configured OHM_DOCS_SPECS manifest, never from
user input — user can only select a pre-registered slug.
- providers.py: provider base_url/model from env only.
- turnstile.py: siteverify URL from env or fixed constant.
- email.py: SMTP host from env.
No user-controlled fetch URL anywhere. No SSRF.
5. Path traversal — docs_sessions / docs_specs:
- api.py:242-280 validates `nnnn` against `^\d{4}$` and `filename` against
a fully-anchored `_TRANSCRIPT_FILENAME_RE` (only SESSION-...md shape, no
`/`/`..`) BEFORE any network call. docs_specs uses anchored
`^[a-z0-9-]+$` slug guard, re-checked inside fetch_spec as defense in
depth. FastAPI `{filename}` path param won't match `/` by default; even a
decoded `%2f` fails the anchored regex. No traversal.
6. Email / markdown sanitization on backend:
- email_invite.py / email_otc.py / email.py / digest.py all call
build_envelope with body_plain only. Grep confirms NO caller passes
`body_html=` anywhere — the multipart/alternative HTML branch in
email_envelope.py:134-140 is dead in practice. All mail is plain text;
user content (custom_message, display names) is plain-text interpolated.
No email HTML-injection vector at v0.24.0.
## Cut state — findings
- F1 [High] Stored XSS via unsanitized markdown→HTML. marked + raw HTML sink
(innerHTML / dangerouslySetInnerHTML), no sanitizer in pipeline or deps.
Sinks: MarkdownPreview.jsx:101-102; ProposalView.jsx:164,172. Sources:
RFC body, proposal body/use-case (any invited user), session transcripts.
Confidence: HIGH (empirically confirmed marked passthrough; sink reachable;
no upstream sanitization; backend stores raw). Invite-gating narrows the
attacker pool but stored XSS by an invited user against admins/others
(incl. admin session/cookie theft) remains. Fix: add DOMPurify and sanitize
marked output before every innerHTML/dangerouslySetInnerHTML write (single
shared helper), or render via a sanitizing markdown component; disable raw
HTML and screen javascript:/data: URIs.
- F2 [Info] Dead HTML-email branch. email_envelope.py supports a text/html
alternative but no caller supplies body_html. Not exploitable today; if
HTML email is ever added, user content must be HTML-escaped first. Track so
it's not turned on without escaping.
## What's solid
- SQL: consistent, correct parameterization across all ~16 dynamic-SQL sites;
IN-clauses use the `"?" * len` idiom; dynamic column/where fragments are
hardcoded allowlists; no ORDER BY/LIMIT/table-name injection; limits are
bounded ints.
- SSRF: zero user-controlled outbound-fetch URLs; all bases env/config-fixed.
- Path traversal: anchored-regex validation at the route layer before any
network call on both docs surfaces; defense-in-depth re-check in docs_specs.
- Editor.jsx markdown flows through TipTap/ProseMirror schema (not a raw sink).
- Email is plain-text only today.
## What the driver needs to know
- One real, shippable finding: F1 stored XSS. It is the highest-impact item
on this surface. Recommend a single sanitize helper (DOMPurify) wrapping
every marked render; covers MarkdownPreview, ProposalView, and all docs
views at once.
- Everything else on this surface is clean. SQL/SSRF/path-traversal are well
built and consistent — worth calling out as defensive strengths in the
combined audit report.
## §19.2 candidates (flotilla-core extraction)
- None from this audit — findings are rfc-app application-layer, not flotilla
CLI / deployment-shape concerns.
@@ -0,0 +1,81 @@
# SESSION-0026.3 — OHM security audit subsession: webhooks, CAPTCHA, email
Parent: [SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md](./SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md)
Subsession: 0026.3
Mode: READ-ONLY security audit. No mutations. Only file written: this transcript.
Target: rfc-app @ v0.24.0 (commit 28015ed), /Users/benstull/git/rfc-app
Date: 2026-05-28, America/Los_Angeles
## Surface
Webhooks (backend/app/webhooks.py), Turnstile/CAPTCHA (backend/app/turnstile.py),
email (email.py, email_otc.py, email_invite.py, email_envelope.py + the
api_invitations.py / api_notifications.py call/receive sites).
## Pre-state
Came in with the three-part brief (webhook HMAC/replay/unknown-repo; Turnstile
verify/fail-open/single-use/coverage; email header-injection/redirect/SSRF).
No prior knowledge of these modules.
## Turn-by-turn arc
1. Confirmed tag v0.24.0, read webhooks.py + turnstile.py in full.
- Webhook: HMAC-SHA256 over the *raw* `await request.body()` bytes (good),
`hmac.compare_digest` (good), secret required at config-load (config.py
refuses to start without GITEA_WEBHOOK_SECRET unless RFC_APP_INSECURE_WEBHOOKS=1).
Verification happens BEFORE any json.loads / cache action. Unknown-repo path
(#18) only logs — no unsafe action. No replay/dedup (delivery-id/timestamp).
- Turnstile: success field checked; network/parse failure -> reason="network"
(fail-CLOSED only when TURNSTILE_REQUIRED=true; fail-OPEN by default when
secret unset). Token never cached -> single-use enforced by Cloudflare.
2. Read all email modules + envelope builder. Found a 5th sender (api_invitations.py
RFC-invite) beyond the 4 named in the brief — also routes through build_envelope.
3. Located enforcement sites: Turnstile wired ONLY to /auth/otc/request (main.py:255).
Checked beta-request (api.py:441, require_user) and propose (api.py:766,
require_contributor) — both require an authenticated session, so Turnstile is
correctly unneeded there. The unauthenticated abuse hot path (OTC request) is
the one that needs it, and it's covered. -> KILLED the "Turnstile missing on
beta/propose" candidate.
4. Header-injection probe. Empirically tested Python 3.13 EmailMessage:
- `m["To"]=`, `m["Subject"]=`, and `formataddr((display,addr))` assignment all
raise ValueError on embedded CR/LF (default EmailPolicy). Code uses
EmailMessage (NOT legacy email.mime), so the classic header-injection vector
is closed at the stdlib layer for To/From/Subject/display-name.
-> KILLED "CRLF header injection / BCC smuggling" as exploitable. Downgraded to
a minor availability note (a malicious display name -> uncaught ValueError ->
500), needs-verification on whether that path is reachable with attacker text.
5. email-bounce webhook (api_notifications.py:543): unauthenticated when
WEBHOOK_EMAIL_BOUNCE_SECRET unset (the documented v1 dev default); flips
email_opt_out_all=1 for any matching user email. Constant-time compare when set.
6. Confirmed unsubscribe tokens: itsdangerous URLSafeSerializer signed with
SECRET_KEY, salt "email-unsubscribe", scoped to (user_id, category). No expiry
(intentional, revocable by rotating SECRET_KEY). app_url is operator-controlled
(no open-redirect via user input). siteverify URL is fixed (no SSRF).
## Cut state — findings
- F1 (LOW/Medium-conf): email-bounce webhook unauthenticated by default
(WEBHOOK_EMAIL_BOUNCE_SECRET unset) -> unauth attacker can force global
email opt-out for any known address (DoS on a user's mail). Mitigated in prod
IF the secret is wired; needs-verification that OHM's overlay sets it.
- F2 (INFO): no webhook replay protection (no delivery-id/timestamp dedup) on
the Gitea receiver. Low impact — handlers are idempotent cache refreshes, no
state mutation from payload contents. Acceptable; documented for completeness.
- F3 (LOW/availability, needs-verification): malicious display-name containing
CR/LF reaching build_envelope raises uncaught ValueError -> 500. Not injection.
- KILLED: CRLF header injection / BCC smuggling (stdlib guard).
- KILLED: Turnstile missing on beta-request/propose (both auth-gated).
- KILLED: Turnstile token replay (not cached; Cloudflare enforces single-use).
## Driver needs to know
- Verify OHM overlay sets WEBHOOK_EMAIL_BOUNCE_SECRET (F1) and
TURNSTILE_REQUIRED=true (else Turnstile fails OPEN if the secret regresses).
- These are env/overlay checks for the flotilla side, not rfc-app code bugs.
## §19.2 candidates
- Webhook delivery-id dedup could become a shared flotilla-core concern if more
webhook receivers land. Not urgent.
@@ -0,0 +1,172 @@
# SESSION-0026.4 — OHM security-audit subsession (newest endpoints, rate-limiting/abuse, frontend posture)
- **Role:** subsession 0026.4 of OHM security-audit driver session 0026.
- **Parent transcript:** SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
- **Mode:** READ-ONLY. No mutations. No secret bytes printed.
- **Target:** rfc-app @ /Users/benstull/git/rfc-app, tag v0.24.0 (HEAD 28015ed).
- **Window:** 2026-05-28T13-46 → 13-50 (America/Los_Angeles).
## Surface assigned
Newest endpoints from session 0022 work (proposed_use_cases writes #26,
PUT /api/me/last-state #29, Claude Haiku tag suggestions #27); rate
limiting / abuse on OTC + beta-request + suggest-tags + propose; frontend
token/secret handling, VITE_ public-vs-secret discipline, token storage,
CSP/security headers, npm audit.
## Pre-state
Fresh read-only checkout at v0.24.0. No prior findings in this subsession.
## Turn-by-turn arc
1. Enumerated backend/app + frontend/src layout, confirmed v0.24.0 tag.
2. Read tag_suggest.py in full, useLastState.js, main.py (full).
- tag_suggest: output strictly constrained to the corpus tag universe
(parse_reply maps replies back to canonical spellings, drops invented
tags). Prompt-injection into the Haiku call can't escape the tag set;
worst case is a junk/empty suggestion list. Per-user in-process rate
limit (TAG_SUGGEST_RATE_MAX default 30/60s) + require_contributor gate
+ draft truncation (title 300, pitch/use_case 4000) + universe cap 200
bound the LLM cost surface. SOLID.
3. Located + read the #26 use_case writes (api.py propose, api_prs.py
open-pr), the #29 last-state route, the suggest-tags route.
- last-state: keyed on user.user_id from the session in BOTH read
(/api/auth/me) and write (PUT /api/me/last-state). No user-supplied
id anywhere → no cross-user read/write (IDOR). require_user gate.
resume_enabled opt-out respected. route bounded 2048, state is a
dict serialized as JSON. SOLID on authz.
4. Checked propose + open-pr authz: both require_contributor (granted,
non-muted, authenticated). use_case bounded max_length=8000. SOLID.
5. Frontend rendering of proposed_use_case: grep found
ProposalView.jsx:172 renders it via marked.parse() into
dangerouslySetInnerHTML. No DOMPurify anywhere in the project
(confirmed by grep across frontend/src + package.json).
6. KILLED a refutation attempt: verified marked default does NOT sanitize.
Ran node one-liner: marked.parse('<img src=x onerror=alert(1)>') emits
the raw <img onerror> tag, and [a](javascript:alert(2)) emits a live
javascript: href. So this is a real stored-XSS sink, not theoretical.
7. Confirmed the XSS audience: GET /api/proposals/{pr_number} is
ANONYMOUS-readable (no require_ gate; uses current_user only for
affordances). Admins reviewing a proposal (who hold merge/decline)
render the payload → privileged-victim XSS.
8. Frontend token storage: grep for localStorage/sessionStorage/
document.cookie/Authorization/Bearer in api.js → ZERO hits. Auth is
server cookie-session (credentials:'include'); device-trust + session
cookies are HttpOnly+Secure+SameSite=Lax (main.py). No token in JS
reach → XSS can't exfil an auth token directly. SOLID.
9. VITE_ discipline: all VITE_ vars are genuinely public — APP_NAME,
BETA_CONTACT, TURNSTILE_SITE_KEY (public by design), AMPLITUDE_API_KEY
(Amplitude browser ingestion key, public by design; .env.example
explicitly documents it as `flotilla overlay set`, NOT secret), policy
URLs. No secret shipped as a VITE_ var. SOLID.
10. npm audit (frontend): 0 vulnerabilities (info/low/moderate/high/
critical all 0). SOLID.
11. OTC abuse: request_code has a per-email cooldown
(OTC_REQUEST_COOLDOWN_SECONDS default 60) AND the route runs
turnstile.verify_token BEFORE request_code (so a failed challenge
spends no budget / sends no mail). Email-bomb + LLM-cost surfaces
mitigated. Cooldown is per-email not per-IP, but Turnstile covers the
bot/IP dimension. SOLID with one caveat (below).
12. beta-request: require_user gated (not anon), admin notification fires
once via the was_already_complete guard. Not anon-abusable.
13. Security headers: app has only SessionMiddleware — no CSP / X-Frame /
X-Content-Type / HSTS. nginx conf (deploy/nginx/ohm.wiggleverse.org.conf)
sets only Cache-Control — NO security headers either. So nothing
backstops the XSS sinks at the header layer.
## Cut state — findings
### F1 — Stored XSS via proposed_use_case rendered through marked + dangerouslySetInnerHTML (no sanitizer)
- **Location:** frontend/src/components/ProposalView.jsx:172 (sink);
source written at backend/app/api.py:841-854 (#26 propose) and
backend/app/api_prs.py:186-199 (#26 open-pr); served unsanitized via
GET /api/proposals/{pr_number} (api.py:716, anon-readable);
proposed_use_case max_length=8000.
- **Issue:** marked does not sanitize by default (verified live: raw
`<img onerror>` and `javascript:` hrefs pass through), and the project
has NO DOMPurify. A granted contributor's use_case text renders as
live HTML in any viewer's browser, including admins who hold
merge/decline on that proposal.
- **Exploit:** contributor proposes an RFC with
`<img src=x onerror=...>` in the use case; an admin opens the proposal
to review it; script runs in the admin's authenticated session
(CSRF-style writes via the admin's cookie, UI defacement). Auth-token
exfil is blocked (HttpOnly cookies), but cookie-authenticated state-
changing requests from the victim's browser are not.
- **Remediation:** sanitize all marked output with DOMPurify before
dangerouslySetInnerHTML (or configure a safe renderer), at minimum on
the new proposed_use_case sink. Same pattern should cover the
pre-existing entry-body sink (F2).
- **Severity:** High. **Confidence:** High (live-verified marked
passthrough; confirmed no sanitizer; confirmed privileged victim).
### F2 — Pre-existing same-sink XSS on entry body (in-scope by adjacency)
- **Location:** ProposalView.jsx:164 (also Editor.jsx:125 preview path);
marked.parse(data.entry?.body) → dangerouslySetInnerHTML.
- **Issue:** identical unsanitized sink; the proposed entry body is
proposer-controlled markdown. Predates v0.24.0 but shares the root
cause and remediation with F1.
- **Severity:** High. **Confidence:** High. (Flagged as the root-cause
class so a fix covers both, not just the #26 field.)
### F3 — No security headers at app or nginx layer (defense-in-depth gap)
- **Location:** backend/app/main.py (only SessionMiddleware);
deploy/nginx/ohm.wiggleverse.org.conf (only Cache-Control).
- **Issue:** no Content-Security-Policy, X-Frame-Options/frame-ancestors,
X-Content-Type-Options, or HSTS. A CSP would meaningfully blunt F1/F2
(block inline/onerror script execution). Absence means the XSS sinks
have no header-layer backstop, and the app is clickjackable.
- **Remediation:** add CSP (script-src self, no unsafe-inline),
X-Frame-Options DENY / frame-ancestors none, X-Content-Type-Options
nosniff, HSTS — at the nginx layer is simplest.
- **Severity:** Medium. **Confidence:** High.
### F4 (minor / needs-verification) — OTC cooldown is per-email, not per-IP
- **Location:** backend/app/otc.py:152-164.
- **Issue:** the cooldown keys on email, so one attacker can rotate
through many target emails (mail-bomb across recipients) without
tripping a single email's cooldown. Mitigated by the mandatory
Turnstile gate on /auth/otc/request (main.py:255) when TURNSTILE is
wired; if TURNSTILE_REQUIRED=false and no secret is set (the default
dev posture), the gate opens and only the per-email cooldown remains.
- **Remediation:** keep Turnstile required in production; consider an
IP-scoped request ceiling as belt-and-suspenders.
- **Severity:** Low (prod-config-dependent). **Confidence:** Medium —
hinges on the deployment's TURNSTILE_REQUIRED + secret state, which
this subsession did not inspect (operator/§13 record, not code).
## What's solid
- last-state #29: no IDOR (server-session user_id only), no open-redirect
(resume only navigates client-side react-router routes from "/", with a
NON_RESUMABLE_PREFIXES skip list; no external URL navigation), state
blob is bounded + JSON-parsed defensively. Resume redirect is
one-shot. SOLID.
- tag_suggest #27: prompt-injection contained (output constrained to
canonical tag universe), cost bounded (per-user rate limit + input
truncation + universe cap + require_contributor gate), degrades to
silence on any failure. SOLID.
- Frontend secrets: no auth token in JS-reachable storage; cookies are
HttpOnly+Secure+SameSite=Lax; no secret shipped as a VITE_ var
(AMPLITUDE/TURNSTILE keys are public by design). SOLID.
- npm audit: 0 advisories. SOLID.
- OTC/beta-request authz + cost: Turnstile-gated OTC with per-email
cooldown; beta-request require_user + once-only admin notify. SOLID
(modulo F4 caveat).
## Driver-needs-to-know
- F1 is the headline: a real, live-verified stored XSS in the #26
use-case field that can land on an admin reviewer. F2 is the same sink
on the entry body — fix the sanitizer once, cover both. F3 (no CSP)
removes the only header-layer backstop. Recommend a single remediation
wave: add DOMPurify around every marked→dangerouslySetInnerHTML sink +
add a CSP at nginx.
- F4 depends on the OHM deployment's TURNSTILE_REQUIRED + secret state
(a §13 record / operator question, not in this code), so verify that
before downgrading or upgrading it.
## §19.2 candidates (flotilla-core / cross-cutting)
- A shared "render-trusted-markdown" frontend helper (marked + DOMPurify
in one place) so no future sink reintroduces F1. Not flotilla-core
(it's rfc-app frontend), but a cross-cutting rfc-app refactor worth a
roadmap item.
- A security-headers block as a reusable nginx snippet shipped with the
deploy/ templates — could become a flotilla overlay default.
@@ -0,0 +1,112 @@
# 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.
@@ -0,0 +1,72 @@
# SESSION-0026.6 TRANSCRIPT — OHM security audit subsession (systemd / nginx / TLS / GCP IAM / firewall / secrets)
- **Subsession:** 0026.6 of OHM security-audit driver session 0026
- **Parent transcript:** SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
- **Start:** 2026-05-28T13-46 (America/Los_Angeles) **End:** 2026-05-28T13-52
- **Mode:** READ-ONLY audit of LIVE infrastructure (project `wiggleverse-ohm`, host ohm.wiggleverse.org)
- **Mandate surface:** systemd unit hardening, service user, file perms, nginx/TLS/headers, GCP IAM + firewall + secrets + VM SA/scopes + static IP, session-history secret-byte spot-check.
- **No secret bytes appear anywhere in this transcript** (§3 invariant 1 / conversation corollary). `.env` was read for variable NAMES + perms/owner only; values never echoed.
---
## Pre-state
- gcloud authed (active accounts incl. ben.stull@wiggleverse.org), project `wiggleverse-ohm`.
- Coordination: sessions 0022.0 and 0026.0 marked ACTIVE/INPROGRESS in ohm-session-history. 0022.0 was mid VM-rename (ohm-app -> ohm-rfc-app) + deploy. I observed only; did not interfere.
- VM rename observed COMPLETE by sample time: instance `ohm-rfc-app` (us-central1-a, RUNNING, NAT 136.116.40.66); systemd unit `ohm-rfc-app.service` active (restarted ~1m41s before sampling — i.e. 0022's deploy had just landed); legacy `ohm-app.service` no longer present.
## Turn-by-turn arc
1. Claimed a session ID via `claim-session-id.sh --start 2026-05-28T13-46`. **NOTE/ARTIFACT:** as a *subsession* (0026.6) I should not have run the top-level claim — it computed + PUSHED a top-level `0027` placeholder (`SESSION-0027.0-...--INPROGRESS.md`) to the shared repo. This is a stray placeholder that needs retraction/finalization by the driver. (Read-only-infra mandate honored; this is the session-history repo, not infra. I did not push further changes to retract it to avoid racing 0022.0/0026.0.)
2. Read `ssh.py` (gcloud compute ssh wrapper, no keys held, OS Login) + `registry.py` (deployment schema). `flotilla deployment show ohm-rfc-app` gave VM name/zone/user/install dir/unit.
3. `gcloud compute instances list` + `describe`: default compute SA, default GCE scope set (NO cloud-platform), `enable-oslogin` instance metadata set, Shielded (vTPM + integrity ON, **Secure Boot OFF**), tags http-server/https-server.
4. `gcloud projects get-iam-policy`: only ben.stull = owner + secretmanager.admin + secretAccessor; service agents only otherwise. **No Editor sprawl, no default-SA project role, no SA user-managed keys, no extra humans.** (Refuted the standard "default SA has Editor" finding — it has no project role here.)
5. `gcloud compute firewall-rules list`: default-allow-ssh (22) and default-allow-rdp (3389) open to 0.0.0.0/0; http/https to world (expected); internal 10.128.0.0/9.
6. `gcloud secrets list`: 7 `ohm-rfc-app-*` named secrets + 4 hash-named (likely Certbot/other). Per-secret `get-iam-policy` for several = EMPTY (no per-secret bindings) -> secret access is project-IAM-only (ben.stull). VM default SA has NO secretAccessor (consistent with flotilla deploy-time resolution via operator ADC).
7. `gcloud compute addresses list`: static IP `ohm-app-ip` 136.116.40.66 IN_USE; USERS self-link still references `.../instances/ohm-app` (stale label after rename; IP correctly attached). Cosmetic.
8. SSH read-only batch #1 (systemctl cat/status, unit file perms): unit hardening baseline present; backend `uvicorn --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1`.
9. SSH batch #2 (`systemctl show` effective props, service user, app/.env/DB perms, `ss -tlnp`): see findings. `.env` 600 owner-only (solid); SQLite DB **644 world-readable**; CapabilityBoundingSet = full default set; several Protect*/Restrict*/SystemCallFilter unset.
10. SSH batch #3 (`nginx -T` greps, site files): proxy_pass localhost, client_max_body_size 4M, certbot TLS1.2/1.3 + modern ciphers in server block; nginx.conf http-block line 34 lists TLSv1/1.1 (latent). No security `add_header` except Cache-Control. server_tokens commented out.
11. Local TLS/header checks (openssl s_client, curl -sI): LE E7 ECDSA cert valid 2026-05-26..08-24, HTTP->HTTPS 301. **Adversarially verified TLS1.0 and TLS1.1 are REJECTED (alert 70), TLS1.2 OK** -> KILLED the "TLS1.0/1.1 enabled" finding as a live exposure (server-block override wins over http-block line 34; line 34 downgraded to latent/informational). Confirmed NO security headers (no HSTS/CSP/X-Frame-Options/nosniff/Referrer-Policy) on / and /api/health. Server version banner leaks `nginx/1.22.1`.
12. SSH batch #4 (sshd -T, nginx 443 default, unattended-upgrades, fail2ban): sshd permitrootlogin no / passwordauth no / pubkey-only -> heavily mitigates world-open :22 (DOWNGRADED that finding). unattended-upgrades enabled. fail2ban inactive (low value given no password auth).
13. Cloned ohm-session-history (30 transcripts), grepped for sk-ant/AKIA/private-key/ghp_/xox + secret-name=longvalue assignments: only `FILL_FROM_*` placeholders matched -> session-history secret-byte spot-check CLEAN.
## Cut state — findings
(severity / confidence after self-refutation)
- **F1 — SQLite DB world-readable.** `/opt/ohm-rfc-app/backend/data/ohm-app.db` mode `644` owner `ohm-rfc-app`. Any local user/process can read all app data (users, OAuth identities, sessions, content). Remediation: `chmod 640` (or 600) the DB + ensure umask in deploy. **MEDIUM, HIGH conf.** (mitigated: single-tenant host, few local users.) Filename still pre-rename `ohm-app.db` — cosmetic.
- **F2 — No HTTP security headers.** No HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy on ohm.wiggleverse.org (verified via curl on / and /api/health; nginx has only `add_header Cache-Control` on static). Clickjacking + SSL-strip-on-first-visit + MIME-sniff exposure for an app doing OAuth/auth. Remediation: add HSTS (with preload once confident), X-Frame-Options DENY / CSP frame-ancestors, nosniff, Referrer-Policy in the ohm server block. **MEDIUM(-HIGH), HIGH conf.**
- **F3 — systemd CapabilityBoundingSet not restricted.** Effective bounding set = full default (cap_sys_admin, cap_sys_module, cap_bpf, cap_sys_ptrace, etc.). Unit sets no `CapabilityBoundingSet=`. Real impact LOW (process runs non-root uid 999 + NoNewPrivileges=true, so no effective caps), but violates least-privilege. Remediation: `CapabilityBoundingSet=` (empty) — service binds 8000 so needs none. **LOW-MEDIUM, HIGH conf.**
- **F4 — systemd deeper sandbox knobs unset.** PrivateDevices=no, ProtectKernelTunables=no, ProtectKernelModules=no, ProtectControlGroups=no, RestrictAddressFamilies unset, SystemCallFilter unset, LockPersonality=no, MemoryDenyWriteExecute=no, RestrictNamespaces=no. (Unit comment self-describes as "modest defaults.") Defense-in-depth gaps. Remediation: add the above (RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX; SystemCallFilter=@system-service; etc.). **LOW, HIGH conf.**
- **F5 — Firewall: SSH(22) + RDP(3389) open to 0.0.0.0/0.** DOWNGRADED: SSH is well-mitigated (OS Login enabled instance-level + sshd passwordauth=no, permitrootlogin=no, pubkey-only) so it requires a valid GCP IAM identity; remaining risk is brute-force noise / SSH 0-day surface. RDP rule is pure dead surface on a Linux host (nothing listens on 3389). Remediation: delete default-allow-rdp; restrict :22 to IAP range (35.235.240.0/20) or known admin IPs, prefer --tunnel-through-iap. **LOW (SSH) / LOW (RDP), HIGH conf.**
- **F6 — VM uses default compute service account.** `727327350842-compute@developer.gserviceaccount.com`. Mitigated: it holds NO project IAM role and NO secretAccessor, and scopes are the default set (no cloud-platform). Best practice is a dedicated minimal SA, but blast radius today is small. **LOW, HIGH conf.**
- **F7 — Shielded VM Secure Boot OFF.** vTPM + integrity monitoring ON, Secure Boot OFF. Remediation: enable Secure Boot (requires stop/start; coordinate). **LOW, MED conf.**
- **F8 — nginx version banner disclosure.** `Server: nginx/1.22.1` leaked; `server_tokens off;` is commented out. Remediation: enable `server_tokens off`. **LOW, HIGH conf.**
- **F9 — nginx.conf http-block lists TLSv1/TLSv1.1 (line 34).** LATENT only — overridden by the certbot server-block (TLS1.0/1.1 verified rejected live). Risk: a future vhost without the certbot include would inherit weak protocols. Remediation: fix the http-block default to TLSv1.2 TLSv1.3. **INFORMATIONAL/LOW, HIGH conf.**
- **F10 — Static IP / DB stale naming.** `ohm-app-ip` address USERS self-link references old `ohm-app` instance; DB file `ohm-app.db`. Cosmetic post-rename drift, no security impact. **INFO.**
### Killed / refuted
- "TLS 1.0/1.1 enabled" — refuted: live handshake rejects both (alert 70).
- "Default SA has project Editor" — refuted: default SA has no project-level role at all.
- "Backend :8000 exposed" — refuted: binds 127.0.0.1 only; firewall has no :8000 rule.
- "World-open SSH is high" — downgraded: OS Login + pubkey-only + no root login.
- "Secrets readable by VM/others" — refuted: per-secret IAM empty, project-IAM grants accessor only to ben.stull.
### What's solid
- `.env` 600 owner-only; service runs as dedicated nologin uid 999 with no extra groups, owns only its app dir.
- Backend bound to 127.0.0.1:8000, nginx proxy_pass localhost, --forwarded-allow-ips 127.0.0.1, client_max_body_size 4M.
- TLS: LE ECDSA cert valid, TLS1.2/1.3 only (verified), modern ciphers, session tickets off, HTTP->HTTPS 301.
- sshd: pubkey-only, no root login, no password/empty-password auth; OS Login on.
- IAM minimal (single owner, no SA keys, no editor sprawl); VM SA has no project role + no secretAccessor; default (not cloud-platform) scopes.
- unattended-upgrades enabled.
- ohm-session-history spot-check: no secret bytes across 30 transcripts (only FILL_FROM_* placeholders).
- systemd baseline hardening present (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, ReadWritePaths scoped to data dir).
## Driver-needs-to-know
- **Stray `0027` INPROGRESS placeholder** was pushed to ohm-session-history by my claim-script call (I should not have claimed as a subsession). Driver should retract/finalize it.
- Top remediations by value: F1 (chmod DB 640) and F2 (security headers) first; both low-effort, real defense-in-depth wins. Then F3/F4 (systemd caps + sandbox) as a single unit edit, F5 (drop RDP rule, restrict/IAP SSH).
- 0022's deploy had just landed at sample time; everything observed is post-rename steady state. No deploy was in progress during my SSH samples; no locks touched.
## §19.2 candidates (flotilla-core extraction)
- A `flotilla harden check` verb could codify the systemd-unit + nginx-header + DB-perms assertions as a post-deploy audit (generic across deployments). The hardening expectations (F1F4, F8) are deployment-shape-agnostic and would live in flotilla-core, with per-deployment exceptions in §13.
@@ -0,0 +1,110 @@
# SESSION-0026.7 — Secret-byte sweep of the OHM session-history corpus
Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Subsession of OHM security-audit driver session 0026.
Role: READ-ONLY assurance pass. Mission: sweep every published session
transcript (plus the local working transcripts that get published) for
any leaked SECRET BYTE — the §3-invariant-1 conversation-layer corollary.
## Pre-state
Two scan surfaces:
1. PUBLISHED corpus — cloned read-only to `/tmp/ohm-hist-scan-0026`
from `ssh://git@git.wiggleverse.org:2222/wiggleverse/ohm-session-history.git`
(clone exit 0; reachable).
2. LOCAL working transcripts at `/Users/benstull/git/ohm-infra/SESSION-*.md`.
File counts:
- Published: 29 transcript `.md` files (sessions 00010027, including
the `.1/.2/.3` subsession files), plus `README.md`. = 30 `.md` total.
- Local: 40 `SESSION-*.md` (includes INPROGRESS placeholders,
SESSION-PROTOCOL.md, SESSION-L). Superset of published.
ABSOLUTE RULE held throughout: never reproduce a suspected secret value;
report only pattern-name + location + masked form. No secret bytes were
written here or in the report.
## Turn-by-turn arc
1. Cloned the published repo (exit 0) and listed both surfaces. Counted
files.
2. **High-confidence shape sweep** (`grep -rniE`, both surfaces,
`.git` excluded):
- Provider prefixes: `sk-ant-`, `sk-…` (OpenAI), `AIza…` (Google),
`ghp_/gho_/ghs_`, `glpat-`**0 hits**.
- PEM `BEGIN … PRIVATE KEY`**0 hits**.
- GCP SA JSON (`"private_key"`, `"private_key_id"`,
`"type":"service_account"`) → **0 hits**.
- JWT `eyJ….eyJ…`**0 hits**.
3. **Named-secret-var = value sweep** (SECRET_KEY, GITEA_BOT_TOKEN,
OAUTH_CLIENT_SECRET, GITEA_WEBHOOK_SECRET, etc. followed by `=`/`:`
and a 6+ char value), plus a generic `*_TOKEN/_SECRET/_PASSWORD/_KEY
= value` sweep. Hits were all adjudicated:
- SESSION-0002 lines 950/961/967/971/974/1111 — placeholders:
`FILL_FROM_PASSWORD_MANAGER`, `FILL_FROM_OPENSSL_OUTPUT_ABOVE`, and
a literal `openssl rand -hex 32` gesture, and a doc example
`GITEA_BOT_TOKEN=abc123...`. Not real values. CLEAN.
- SESSION-0008 line 316 — a shell `grep '^OAUTH_CLIENT_SECRET='`
command line, no value. CLEAN.
- SESSION-0008 lines 684/685 — the CRLF-bug writeup showing
`OAUTH_CLIENT_SECRET=" gto_oge…\r"` and `SECRET_KEY="2e05383a849…\r"`.
Opened lines 665709. Both values are **deliberately truncated with
an ellipsis** (`…`): only a ~3-char prefix of the gitea token and
~11 hex chars of the 64-char SECRET_KEY are shown, as illustration
of the trailing-`\r` taint. The remaining bytes are NOT present.
Verified `gto_oge` appears at lines 70/291/684/689 and is the same
truncated 3-char suffix every time — never full length. CLEAN
(truncated, sub-threshold).
- SESSION-0001 line 984 — `GITEA_SIGNING_KEY="7C9E…1592E2"` (40-hex).
Opened lines 972993: it's inside `/tmp/gitea-install.sh`, used with
`gnupg`/`dirmngr` to verify the downloaded Gitea release signature.
This is the **public GPG key fingerprint** Gitea publishes. Public
by definition. CLEAN.
4. **High-entropy string sweep** (standalone 48+ char base64/hex blobs in
published corpus). Two non-trivial strings, both adjudicated:
- 64-hex `15783e…574d` (SESSION-0006 line 503) — explicitly labeled
`overlay snapshot sha256:…`. A content hash of the (non-secret)
overlay config. CLEAN.
- `AAAAC3NzaC1lZDI1NTE5…` (SESSION-0001 line 683) — the standard
`ssh-ed25519` **public-key** wire-format prefix. Public key, not a
private key. CLEAN. (Consistent with the PEM sweep finding zero
`BEGIN OPENSSH PRIVATE KEY` blocks.)
5. **Final confirmation sweeps:**
- bcrypt `$2[aby]$NN$`**0 hits**.
- `password/passwd/pass/pwd = value` (excluding prose, placeholders,
`--password` flags, "password manager") → **0 hits**.
- README.md → only prose about the no-secret-bytes policy. CLEAN.
- `PRIVATE KEY / BEGIN OPENSSH / BEGIN RSA / BEGIN EC` → all matches
are **prose discussing** private keys (e.g. "client unlocking that
private key", "No private key material … nothing sensitive", and a
grep-pattern example inside a transcript). No actual key blocks.
CLEAN.
## Cut state — VERDICT: CLEAN
Zero secret bytes leaked across the entire corpus.
- 29 published transcript `.md` files scanned (+README).
- 40 local `SESSION-*.md` files scanned (published superset).
- The only "value-shaped" hits were all expected/safe: placeholders,
a public GPG fingerprint, a public ssh-ed25519 key, a sha256 overlay
snapshot hash, doc examples, and two deliberately-ellipsis-truncated
illustration values in the CRLF-bug writeup (sub-threshold, remainder
never present). No secret REFERENCES were mistaken for bytes; no
references were flagged as leaks.
## Driver needs to know
- Corpus is CLEAN. No remediation needed. The §3-invariant-1
conversation-layer corollary is holding in practice across all
published sessions to date.
- One thing worth a glance (NOT a leak): SESSION-0008's CRLF-bug
narrative quotes truncated `…`-elided forms of OAUTH_CLIENT_SECRET and
SECRET_KEY. They are safely below any useful threshold, but if the
team wants zero value-fragments at all, those two could be masked to
pattern-name only. Optional, low priority.
- Published repo was reachable and freshly cloned; no "could not reach"
caveat applies.
@@ -0,0 +1,138 @@
# SESSION-0026.8 — v0.25.0 security hardening config edits
Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Subsession 0026.8 of OHM session 0026 (REMEDIATION mode). Scope was
narrow and bounded: make exactly two in-repo config edits in the
`rfc-app` repo (branch `feature/v0.25.0-security-hardening`) to close
audit findings M2, L8 (nginx) and L4 (systemd). No git commit, no
push, no VM access, no other files touched. The driver commits and
applies to the VM later.
## What I changed and why
### TASK 1 — nginx security headers (M2 + L8)
File: `deploy/nginx/ohm.wiggleverse.org.conf`
Added to the server block (which is the certbot-promoted HTTPS block on
the VM — see below): `server_tokens off;` (L8) plus five `add_header
... always;` response headers (HSTS, X-Frame-Options DENY,
X-Content-Type-Options nosniff, Referrer-Policy
strict-origin-when-cross-origin, and a Content-Security-Policy) (M2).
All carry `always` so they apply to nginx error responses too.
Important structural note I verified before editing: the in-repo
`.conf` is the **pre-certbot template** and contains only the port-80
`server` block. Per the file's own install comment, RUNBOOK.md, and
DEPLOY-NEW-SESSION-PROMPT.md, `certbot --nginx` rewrites this file on
the VM, promoting this single server block to `listen 443 ssl` and
adding a separate port-80 → 443 redirect block. So headers placed in
this block ride into the HTTPS server block on the VM exactly as the
task intended. I documented that relationship in an inline comment so a
future reader isn't confused about why "the HTTPS headers" live in what
looks like the port-80 block in-repo.
Did NOT touch the existing TLS/proxy/`Cache-Control`/`client_max_body_size`
directives.
### TASK 2 — systemd hardening (L4)
File: `deploy/systemd/rfc-app.service`
Kept the existing NoNewPrivileges / ProtectSystem=strict / ProtectHome
/ PrivateTmp / scoped ReadWritePaths. Added the full defense-in-depth
block under a `# v0.25.0 security hardening (audit 0026 L4)` comment:
empty `CapabilityBoundingSet=` + `AmbientCapabilities=` (service binds
127.0.0.1:8000, needs no caps), PrivateDevices, ProtectKernel{Tunables,
Modules,Logs}, ProtectControlGroups, RestrictAddressFamilies (AF_INET
AF_INET6 AF_UNIX), RestrictNamespaces, LockPersonality,
MemoryDenyWriteExecute, RestrictRealtime, RestrictSUIDSGID,
SystemCallFilter=@system-service, SystemCallErrorNumber=EPERM,
SystemCallArchitectures=native, UMask=0077.
## The CSP I settled on + reasoning
```
default-src 'self';
script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com;
worker-src 'self' blob:;
frame-src https://challenges.cloudflare.com;
frame-ancestors 'none';
base-uri 'self';
object-src 'none'
```
Diffs from the suggested starting policy, all evidence-driven from
reading the frontend:
- **Added `connect-src https://challenges.cloudflare.com`.** Turnstile
(TurnstileWidget.jsx loads `https://challenges.cloudflare.com/turnstile/v0/api.js`)
makes XHR/fetch calls back to that origin during the challenge, not
just an iframe. Without it the challenge can fail.
- **Added `worker-src 'self' blob:`.** This is the load-bearing change.
Amplitude analytics is `@amplitude/unified`, which ships **Session
Replay**, and analytics.js initializes it at **`sampleRate: 1`** (100%
of consenting sessions recorded — see lib/analytics.js lines 219222).
Amplitude Session Replay spins up a Web Worker from a `blob:` URL for
DOM capture/compression. With a strict `default-src 'self'` and no
`worker-src`, `blob:` workers are blocked and session replay breaks
for every consenting user. I could not introspect node_modules to
prove the exact blob usage (deps not installed in this checkout), so
I relied on documented Amplitude Session Replay behavior and added
`blob:` defensively — it's low-risk and prevents a real regression.
- **script-src / connect-src amplitude:** Amplitude AND mermaid are
both **bundled** (`import('@amplitude/unified')`, `import('mermaid')`
— dynamic imports from node_modules, served from `'self'`), so
script-src for them isn't strictly required. I kept
`script-src ... https://*.amplitude.com` defensively (harmless) but
the truly necessary amplitude grant is `connect-src https://*.amplitude.com`
(event ingestion + session replay upload, all under `*.amplitude.com`).
mermaid needs no external origin at all — it renders inline SVG.
- **`style-src 'unsafe-inline'` retained and documented as required:**
the JSX uses inline `style={...}` attributes throughout and mermaid
injects `<style>` at render time. Tightening to nonces/hashes is a
build-side follow-up. **`script-src` deliberately omits
`'unsafe-inline'`** — no inline `<script>` in the app (index.html has
only `<script type="module" src="/src/main.jsx">`), so inline-script
XSS stays blocked.
- `img-src 'self' data: https:` and `font-src 'self' data:` keep
markdown/RFC remote images and data: URIs (and svg/mermaid data:
output) working.
## MemoryDenyWriteExecute judgment call
I included `MemoryDenyWriteExecute=true` **active, not commented out**.
The stack is stock CPython + FastAPI/uvicorn + sqlite3 + bcrypt + httpx
— no JIT, no PyPy, nothing that mmaps W+X pages. MDWX is safe for that
profile. The risk is theoretical (a C-extension doing W+X mmap), so I
judged the security win worth keeping it on. I added an inline comment
flagging it as the single line to comment out if uvicorn fails to come
up on restart. Driver action: **watch the first `systemctl restart
rfc-app` journal** — if uvicorn crashes on boot, comment out the one
`MemoryDenyWriteExecute=true` line and `daemon-reload` + restart;
everything else in the block is restart-safe for this stack.
## Verify
Re-read both files after editing.
- nginx: brace-balanced (1 `server {` + 4 `location {` = 5 opens, 5
closes). Could not run `nginx -t` (no live nginx) — visual sanity
check only; CSP is a single quoted `add_header` value, well-formed.
- systemd: valid `key=value` ini throughout; the two empty-value
capability clears (`CapabilityBoundingSet=`, `AmbientCapabilities=`)
are intentional and valid systemd syntax.
No secret bytes touched or printed. No commit/push/VM action taken.
## Cut state
Both edits complete and verified in-repo on
`feature/v0.25.0-security-hardening`. Handed back to driver for commit
+ VM apply. Open driver watch-item: MDWX on first restart (above).
@@ -0,0 +1,76 @@
# SESSION-0026.9 — flotilla audit finding L1 remediation
Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Mode: REMEDIATION (operator authorized). Subsession of OHM session 0026.
Scope: fix flotilla audit finding L1 in
`ohm-rfc-app-flotilla/ohm_rfc_app_flotilla/deploy.py` + a test.
No commit, no push, no VERSION bump, no VM/deploy actions.
## Finding L1 (recap)
`_PhaseRunner.run` persisted each phase's `detail` (which can embed a
failed command's `stderr[:240]`) into the `deploys.phases` JSON column
via `deploy_log.append_phase` WITHOUT redaction. Secret redaction
(`_ResolvedSecrets.redact_live`) was applied only to the separate
`error_summary` field in `_finalize_failure`. A secret surfacing in a
command's stderr would land unredacted in flotilla state — a §3
invariant 1 / §10.2 leak.
## Design choice — how the redactor was threaded
- Added an OPTIONAL field `redactor: _ResolvedSecrets | None = None` to
the `_PhaseRunner` dataclass, plus a small `_redact(text)` helper that
calls `redactor.redact_live(text)` when the redactor is present and
falls back to the raw text when it's None.
- Chose OPTIONAL over REQUIRED because it's the lower-churn option:
it preserves the ability to construct a `_PhaseRunner` without a
resolved-secrets context (none of the current tests do, but the
nullable style matches the rest of the module's injected-dependency
shape), and required no signature changes at any other call site.
- `run_deploy` constructs the only production runner and now passes
`redactor=resolved`. Phases run inside the `_ResolvedSecrets`
`with`/try scope, so the bytearrays are still live when `_redact`
runs during the phase — redaction happens before `scrub()` zeroes
them at scope exit, exactly as the finding requires.
- Both the success-path detail and the failure-path detail are now
redacted before they reach the `PhaseRecord` (persisted JSON) AND
before the `on_progress(...)` strings that interpolate the detail
(those can reach operator terminals/logs).
- `_finalize_failure` still redacts the summary it builds from the
last phase's detail; since that detail is now already redacted, the
second pass is idempotent (no behavior change).
## Diff summary
deploy.py:
- `_PhaseRunner`: new `redactor` field + `_redact` helper; `run`
computes `safe_detail = self._redact(...)` on both paths and uses it
for the `PhaseRecord.detail`, the `on_progress` FAILED message, and
the returned detail.
- `run_deploy`: `_PhaseRunner(...)` construction now passes
`redactor=resolved`.
tests/test_deploy.py:
- Extended `test_run_deploy_redacts_secret_bytes_from_error_summary`
to ALSO load the persisted row and assert the failing phase's
`detail` contains `[REDACTED]` and not the stub secret, and that no
phase detail anywhere carries the raw stub bytes.
## Test added / extended
Reused the existing redaction test (which drives a real `run_deploy`
with a `LeakyFail` ssh stub that vomits a fake secret into stderr) and
added the `deploys.phases` assertions. Stub value is the module's
`STUB_SECRET_BYTES = b"NEVER-LEAK-stub-secret-payload"` — a fake, never
a real secret.
## Test-run result
`.venv/bin/pytest tests/ -q` → 157 passed in 0.28s. Suite was green
before the change (full-suite run, not a known-broken baseline).
## Cut state
Code change + test in place. No commit, no push, no VERSION bump, no
VM/deploy action. Driver handles release.
@@ -0,0 +1,228 @@
# Session 0073.0 — Transcript — PPE / progressive-delivery design, engineering handbook, ROADMAP Phase F
> **Renumber note (2026-06-05):** this session was originally drafted as
> `0046.0`, but that ordinal was already taken by a different, published
> session (the two-tier-testing-refinement driver). On migration to
> `wiggleverse/session-history` it was reassigned the next free number,
> `0073`. Body text below is verbatim and may still refer to "0046".
- **Start:** 2026-05-29T21-30 *(approximate — the harness could not
recover the exact session-open time; the prior session's placeholder
0045 is stamped 21-26, and this session's first git action was the
engineering-handbook seed commit at 2026-05-29 22:44 -0400)*
- **End:** 2026-05-30T06-19
- **Operator:** Ben Stull
- **Driver:** Claude (claude-opus-4-8 via Claude Code)
- **Repos touched:** `wiggleverse/engineering` (created + seeded + §10.3
update), `ben.stull/ohm-rfc` (ROADMAP Phase F via PR #3; two-tier
refinement via PRs #4 and #5), plus read-only clones of `rfc-app`,
`ohm-content`
- **Pin moves:** none — no rfc-app release, no `flotilla deploy`
- **TL;DR:** What began as "create a pre-prod env so we can run full
automated browser tests without screwing up production data" grew, in
conversation, into a full progressive-delivery design (PPE → automated
test gate → blue/green slots → weighted sticky canary → bake monitoring
→ auto-rollback, all on the one existing VM with a shared forward-only
SQLite). That design was written up as a new org-level engineering
handbook (`wiggleverse/engineering`), and sliced into the OHM roadmap as
**Phase F / Track Δ, items #37#42**. A late operator refinement —
two-tier testing (local Docker in the dev loop, then PPE on the VM) —
was folded into both. No code shipped; PPE itself is not built.
---
## How the scope grew
The opening ask was narrow: a pre-prod environment to run automated
browser tests without endangering production data. Over several exchanges
the operator widened it deliberately:
1. First to a real environment with isolation guarantees.
2. Then: "plan for deployment to deploy to PPE, then run automated tests
(including browser automation for core scenarios), then do a blue/green
deployment and monitor real user behavior and roll back if there's any
sign of trouble."
3. Then a key architectural question: "Can we run all three instances
(ppe, blue, green) on a single VM? PPE would have its own database.
blue/green wouldn't but databases are roll-forward only. A portion of
users are sent to the newer version and the cohort increases as
confidence grows."
4. Then the insight that anchors the safety model: "Schema changes should
be backwards compatible with the last version of code though, right?
We should be testing that when we deploy to PPE."
5. "We'll also need infra for e2e tests in ppe. Specifically, email
accounts for invites, login, notifications."
6. And, late: "Could automated tests run against a local docker container
first during development and then on PPE on the VM when we're actually
deploying?"
The driver's job became: pressure-test the design honestly, fill the
gaps, and write it down.
## The design that settled (handbook §10)
Verified against the real repos (after cloning `rfc-app`, which was not
initially on the machine): the OHM stack is a single GCE VM, **nginx**
reverse proxy, **systemd** units, a **Vite** frontend built on the VM,
and a single-file **SQLite** database (WAL). rfc-app migrations are
numbered and **forward-only** (`backend/migrations/001…024`, no
down-migrations) — exactly what makes the proposed canary safe.
- **Three instances on one VM.** PPE (own DB, seedable/wipeable) + prod
**blue** and **green** code slots that *share* the one prod SQLite file
(WAL: many readers, one serialized writer). Isolation is by install dir
/ systemd unit / DB path, not new hardware. Per-unit
`MemoryMax`/`CPUQuota` so a runaway slot (esp. the on-VM frontend build)
can't OOM the box.
- **The safety invariant** is stronger than "roll-forward only": it's
**expand/contract** (parallel-change). Because both slots hit the same
schema during a ramp, every migration must keep the *previous* code
version working (N schema must not break N1 code). Additive changes are
safe; rename/drop/retype ship as expand-now / contract-next-release.
- **The gate tests the combination a normal deploy skips.** A vanilla
"deploy N and test" only exercises N-code-on-N-schema; the canary runs
N1-code-on-N-schema for the whole ramp. So the gate seeds the N1
baseline → applies N's migrations → runs the suite against N1 code on
the migrated DB (compat) *and* N code (forward), exercising writes both
directions. Fail compat ⇒ not canary-safe ⇒ block the ramp.
- **Canary, not binary flip.** nginx `split_clients` on a stable per-user
cookie → consistent slot assignment; ramp = edit % + reload. Rollback is
DB-free (weight bad slot to 0 + reload; schema only moved forward
compatibly).
- **Monitoring/rollback** trips on a *fast technical* signal (5xx /
health / latency), not product analytics. The genuine open fork: an
always-on watcher next to prod vs. a bounded bake during the deploy
session — in tension with flotilla's laptop-only commitment. Left as an
explicit OPEN decision, not defaulted.
- **Email infra for e2e.** A self-hosted **mail sink** (Mailpit). The
e2e suite reads OTC codes / invite links back via the sink's API.
Catches mail to any address, so multi-user invite scenarios need no
provisioned mailboxes. Deliverability (SPF/DKIM/DMARC) is kept separate
and non-gating (already roadmap #20).
- **Two tiers, one suite** (operator's final refinement). The e2e suite
is **environment-agnostic** — one `BASE_URL` (+ mail-sink URL).
**Tier 1**: a local `docker compose` stack (backend + built frontend +
fresh SQLite + Mailpit) run in the dev loop and in CI on every PR —
fast, offline, and **not dependent on #37**, so the suite can be green
locally before PPE on the VM exists. **Tier 2**: the *same* suite vs
PPE as the deploy gate (prod-like confirmation). The N1/N compat gate
runs cheaply in Tier 1 (old image → migrate → test → new image), with
PPE as confirmation. The discipline: the suite is the unit of value;
each environment is just another `BASE_URL`.
## Artifacts produced
1. **`wiggleverse/engineering`** — a new org-level repo holding a
project-independent handbook, "How We Build Software at Wiggleverse"
(the repo's `README.md`, ~610 lines). Covers: vibe-code prototypes
first → write the first SPEC from the prototype → spec-driven
development → git/repo conventions → sessions & transcripts (linked to
the spec) → roadmap + parallel-agent sessions → the standard OHM stack
and how flotilla deploys it → three infra stages (Stage 1 pre-v1
single-prod; Stage 2 v1+users PPE/blue-green/canary/rollback; Stage 3
deferred, name-the-trigger). The progressive-delivery design is its
§10; the two-tier testing refinement is §10.3 (commit `0bbf759` on
`main`). Repo created empty by the operator; seeded by pushing the
guide straight to `main` (an empty repo has no base branch to PR
against), then default branch set to `main` and the leftover seed
branch deleted via the Gitea API.
2. **`ohm-rfc/ROADMAP.md` Phase F (#37#42)** — the design sliced into
roadmap items (item bodies + version-targets table + a new **Track Δ**
in the parallelization plan):
- #37 PPE pre-prod env (`ppe.ohm.wiggleverse.org`) — incl. the mail sink
- #38 Automated + browser E2E suite (rfc-app) + PPE test gate
- #39 Blue/green prod slots on the VM (slot model)
- #40 Forward-only migration phase + N1/N compatibility gate
- #41 Weighted, sticky canary routing + promote/rollback
- #42 Bake monitoring + auto-rollback (open: watcher vs bounded bake)
All marked TBD and **gated on v1**, with handbook §10 as the binding
design and an explicit "proposal, not yet built" note (flotilla v1
defers blue/green + auto-rollback per its SPEC §19.2). Landed via
**PR #3**. The two-tier-testing refinement was then folded in across
two follow-up PRs: **PR #4** (#40 body + #38 table row) and **PR #5**
(#38 body) — #38 Tier 1 explicitly does NOT depend on #37. Final
`ohm-rfc` `main` after all three: `2d6f6d6`.
## Wrong turns (left in, per the honest-transcript rule)
- **Harness output corruption — pervasive this session.** Bash stdout and
Read repeatedly returned garbled or *fabricated* content for larger
outputs — at one point inventing a plausible-but-fake Playwright config,
seed scripts, and Caddyfile for rfc-app *before that repo was even
cloned*; later injecting junk tokens and fabricated sentences into a
`git diff`, a Read result, a grep over this very transcript, and script
reads at finalize time. Several `Edit` calls also silently failed
(string-not-found, because a corrupted earlier Read had given the driver
slightly-wrong "current" text) — so a handbook §10.3 edit and a roadmap
#38-body edit each had to be retried after re-reading. Each time the
driver caught it, discarded the bad output, and re-verified via small
file-based commands and the public raw URLs. This transcript was written
wholesale via `Write` so its bytes did not depend on an unreadable
on-disk state. **This is the single most important caveat for the next
session: do not trust large tool dumps here; verify with minimal
commands and authoritative remotes.**
- **"ohm-infra doesn't exist" — false, caused by the corruption.** Early
on, the driver concluded `~/git/ohm-infra/` (and the session tooling)
was absent on this machine, and even hand-built a throwaway
`~/projects/wiggleverse/ohm-session-history` clone to work around it.
That was wrong — `~/git/ohm-infra/` exists, with
`scripts/claim-session-id.sh`, `scripts/publish-transcript.sh`, and
`SESSION-PROTOCOL.md`, and is the canonical home for transcripts
(0043/0044/0045 present). This transcript was written to the correct
location and published via the real script.
- **Roadmap item-numbering.** A corrupted early read of ROADMAP.md stopped
at item #17, so the driver first drafted the new items as #18#23
which would have collided with the real, longer backlog (items run
through #36). The edits happened to fail (string-not-found), nothing
landed wrong, and Phase F was renumbered to the correct **#37#42**.
- **Wrong Gitea token.** First PR attempt used the GCP secret
`ohm-rfc-app-gitea-bot-token`, which authenticates as `ohm-bot`*not*
a collaborator on `ben.stull/ohm-rfc` → 403 "user must be a
collaborator." Correct credential: the macOS **Keychain** PAT
(`security find-generic-password -s ohm-gitea-token`), Ben-owned,
`write:repository`. Memory corrected.
- **Merge conflict (PR #3).** `origin/main` had advanced ~10 commits since
the branch base (the #36 meta-only settlement + pin bumps), so the PR
came up `mergeable:false`. Resolved by merging `origin/main` into the
feature branch (one ROADMAP.md conflict — kept the now-struck-through
#36 row from main, appended #37#42), pushing, then merging.
## Git operations (all verified against the public remotes)
- `wiggleverse/engineering`: seeded to `main`; default branch set to
`main`; leftover `seed-engineering-handbook` deleted; §10.3 two-tier
section committed `0bbf759` and pushed (confirmed live on the public
raw README).
- `ben.stull/ohm-rfc`: Phase F via PR #3 → merge; two-tier refinement via
PR #4 (#40 + #38 table row) and PR #5 (#38 body); all three feature
branches deleted on remote; local `main` fast-forwarded to `2d6f6d6`;
tree clean.
## Secrets discipline
No secret bytes entered the conversation or any committed artifact. Tokens
used for API calls were read into `/tmp` files / shell vars, never echoed,
and the `/tmp` token files were truncated to zero at session end; only
env-var *names* and GCP secret *resource paths* were ever surfaced.
## Not done / open
- **PPE is not built.** This session only designed and recorded it. #37 is
the next concrete build, but its live-on-VM parts are **gated on v1**
confirm timing with the operator. Note #38 Tier 1 (local Docker suite)
is NOT gated on #37 and can be started independently.
- **Open design decisions** in the roadmap: #42 auto-rollback model
(watcher vs bounded bake); #37 OAuth-per-host caveat (dissolves if #5
email/OTC has shipped).
- **Session 0045 is unfinalized** (`SESSION-0045.0-TRANSCRIPT-…--INPROGRESS.md`
in `~/git/ohm-infra/`) — a prior pin-bump session's loose end, not this
session's. Flagged for cleanup; was unrelated to 0046's publish.
- **Stray read-only clone:** the driver made a
`~/projects/wiggleverse/ohm-session-history` clone while (wrongly)
believing the canonical tooling was absent; harmless and uncommitted,
can be removed.
+7 -1
View File
@@ -50,6 +50,9 @@
"0018": { "0018": {
"title": "Wave 9 \u2014 /docs/specs + nested flyout nav + transcript body-ref rewrite (#30 follow-up, rfc-app v0.20.0)" "title": "Wave 9 \u2014 /docs/specs + nested flyout nav + transcript body-ref rewrite (#30 follow-up, rfc-app v0.20.0)"
}, },
"0019": {
"title": "UX-polish wave \u2014 subagent transcripts only (App.css/header/docs; rfc-app v0.21.0, #31/#24/#25/#32; driver 0019.0 never finalized)"
},
"0021": { "0021": {
"title": "Roadmap-capture \u2014 UX-polish items #31 + #32 added" "title": "Roadmap-capture \u2014 UX-polish items #31 + #32 added"
}, },
@@ -63,7 +66,7 @@
"title": "Claude Haiku propose-RFC tag suggestions (#27, rfc-app v0.24.0 built)" "title": "Claude Haiku propose-RFC tag suggestions (#27, rfc-app v0.24.0 built)"
}, },
"0026": { "0026": {
"title": "(abandoned \u2014 claimed but never finalized; closed out by 0068)" "title": "Security-audit-0026 \u2014 subagent audit transcripts (driver abandoned/closed-out by 0068; audit drove remediation in 0030)"
}, },
"0027": { "0027": {
"title": "(abandoned \u2014 claimed but never finalized; closed out by 0068)" "title": "(abandoned \u2014 claimed but never finalized; closed out by 0068)"
@@ -202,5 +205,8 @@
}, },
"0072": { "0072": {
"title": "" "title": ""
},
"0073": {
"title": "PPE / progressive-delivery design + engineering handbook seed + ohm-rfc ROADMAP Phase F (renumbered from a duplicate 0046.0)"
} }
} }