feat(secrets): exposure detection — exposed flag, chat badge, mask-on-render, exposure push (#108) #118

Merged
dominik.polakovics merged 1 commit from afk/108 into main 2026-07-10 19:52:19 +02:00

A secret value appearing in a run's transcript is a disclosure the operator learns about. The chat read paths now scan rendered content against the repo's secrets — exact plus base64/hex/urlencoded forms via the #104 matcher kernel — and contain the blast radius in lab's own UI. The provider-owned transcript on disk cannot be rewritten, so rotation stays the remediation; masking is defense-in-depth.

What ships

  • Detection + mask-on-render, one mechanism. A new whole-string secrets.Redactor (derived from the #104 matcher's form derivation, concurrency-safe, idempotent) masks every operator-visible string as [REDACTED:NAME] — message text, tool chip title/input/output, every dialog string, the pending dialog — and reports which secrets were hit in the same pass, so flag and mask can never diverge. Runs at BOTH read paths: Service.Read (HTTP, including ended runs' history) and the tailer tick, ordered before the notify gate snapshots a needs-input body, so no push payload can carry a plaintext value.
  • Sticky exposed flag. repo_secrets gains exposed_run_id/exposed_at (migration 0010, both dialects), set by an atomic first-writer-wins MarkRepoSecretExposed — the unset→exposed edge — cleared by rotate, removed by delete.
  • Exposure push (amendment). On the edge, one push through the existing injected notify seam (ADR-0039 pattern, no push import): title/body name the secret and the run, never the value; tag runID:exposed:NAME so tag-coalescing never swallows an exposure; route to the run's chat. The DB edge guarantees exactly-once per episode; a fresh hit after rotation is a new edge and pushes again. No notifyGate debounce — a disclosure shouldn't wait out a flap window.
  • UI. Repo settings secrets section: red "Exposed" chip with the run (linked) and timestamp, "rotate to clear". Run chat header: warning badge (dot <640px / chip ≥640px) naming the exposed secret(s); appears live via a run.changed publish on the edge.
  • Vault stays behind a narrow seam. secrets.Source (encrypted-blobs loader + decrypt closure, wired in cmd/lab) hands chat a per-repo redactor; chat never touches vault plumbing, and no log line, event payload, or API response carries a value — names and ids only. Zero-secret repos short-circuit to a nil redactor (no per-message overhead). Broker placeholders ([REDACTED:NAME] text) are inert by construction; contained hits (#105/#106) stay quiet.

Also fixes #117 — main is broken

#112 (#104) and #113 (#111) both claimed goose migration version 0008, and goose refuses duplicates: every Store.Open on merged main fails, taking lab startup and most test packages down. This PR renames 0008_run_title0009 (run_title merged last; a deployment that applied 0008 as repo_secrets picks up 0009 cleanly) and takes 0010 for the exposure columns.

Verification

  • go test ./... green (only exceptions: internal/tmuxx integration tests, which fail identically on a clean checkout in this sandbox — tmux sessions cannot start here; CI installs tmux).
  • golangci-lint run ./...: 0 issues; gofmt -l clean.
  • web: npm run build (tsc + vite) green, npm test 589/589 across 37 files, npm run lint clean.
  • New coverage: redactor forms/idempotence/longest-first nesting; store edge/sticky/rotate-clears; chat masking everywhere incl. exposure push payload equality, sticky no-refire, rotation re-arming, placeholder inertness, zero-secret passthrough, needs-input body masking, tick-level detection + run.changed; API exposure surfacing incl. list-endpoint absence; UI badge singular/plural/absent and rotate-clears flows.

Closes #108
Closes #117

🤖 Generated with Claude Code

https://claude.ai/code/session_01SrMDj8ayE71AzA6M46pri2

A secret value appearing in a run's transcript is a disclosure the operator learns about. The chat read paths now scan rendered content against the repo's secrets — exact plus base64/hex/urlencoded forms via the #104 matcher kernel — and contain the blast radius in lab's own UI. The provider-owned transcript on disk cannot be rewritten, so rotation stays the remediation; masking is defense-in-depth. ## What ships - **Detection + mask-on-render, one mechanism.** A new whole-string `secrets.Redactor` (derived from the #104 matcher's form derivation, concurrency-safe, idempotent) masks every operator-visible string as `[REDACTED:NAME]` — message text, tool chip title/input/output, every dialog string, the pending dialog — and reports which secrets were hit in the same pass, so flag and mask can never diverge. Runs at BOTH read paths: `Service.Read` (HTTP, including ended runs' history) and the tailer tick, ordered before the notify gate snapshots a needs-input body, so no push payload can carry a plaintext value. - **Sticky exposed flag.** `repo_secrets` gains `exposed_run_id`/`exposed_at` (migration 0010, both dialects), set by an atomic first-writer-wins `MarkRepoSecretExposed` — the unset→exposed edge — cleared by rotate, removed by delete. - **Exposure push (amendment).** On the edge, one push through the existing injected notify seam (ADR-0039 pattern, no push import): title/body name the secret and the run, never the value; tag `runID:exposed:NAME` so tag-coalescing never swallows an exposure; route to the run's chat. The DB edge guarantees exactly-once per episode; a fresh hit after rotation is a new edge and pushes again. No notifyGate debounce — a disclosure shouldn't wait out a flap window. - **UI.** Repo settings secrets section: red "Exposed" chip with the run (linked) and timestamp, "rotate to clear". Run chat header: warning badge (dot <640px / chip ≥640px) naming the exposed secret(s); appears live via a `run.changed` publish on the edge. - **Vault stays behind a narrow seam.** `secrets.Source` (encrypted-blobs loader + decrypt closure, wired in cmd/lab) hands chat a per-repo redactor; chat never touches vault plumbing, and no log line, event payload, or API response carries a value — names and ids only. Zero-secret repos short-circuit to a nil redactor (no per-message overhead). Broker placeholders (`[REDACTED:NAME]` text) are inert by construction; contained hits (#105/#106) stay quiet. ## Also fixes #117 — main is broken #112 (#104) and #113 (#111) both claimed goose migration version 0008, and goose refuses duplicates: every `Store.Open` on merged main fails, taking lab startup and most test packages down. This PR renames `0008_run_title` → `0009` (run_title merged last; a deployment that applied 0008 as repo_secrets picks up 0009 cleanly) and takes 0010 for the exposure columns. ## Verification - `go test ./...` green (only exceptions: `internal/tmuxx` integration tests, which fail identically on a clean checkout in this sandbox — tmux sessions cannot start here; CI installs tmux). - `golangci-lint run ./...`: 0 issues; `gofmt -l` clean. - web: `npm run build` (tsc + vite) green, `npm test` 589/589 across 37 files, `npm run lint` clean. - New coverage: redactor forms/idempotence/longest-first nesting; store edge/sticky/rotate-clears; chat masking everywhere incl. exposure push payload equality, sticky no-refire, rotation re-arming, placeholder inertness, zero-secret passthrough, needs-input body masking, tick-level detection + run.changed; API exposure surfacing incl. list-endpoint absence; UI badge singular/plural/absent and rotate-clears flows. Closes #108 Closes #117 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SrMDj8ayE71AzA6M46pri2
A secret value appearing in a run's transcript is a disclosure the
operator learns about. The chat read paths now scan rendered content
against the repo's secrets (exact + base64/hex/urlencoded forms via the
#104 matcher kernel) and contain the blast radius in lab's own UI — the
provider-owned disk copy cannot be rewritten, so rotation stays the
remediation.

- internal/secrets: whole-string Redactor (masks every derived form as
  [REDACTED:NAME], concurrency-safe, idempotent) and a narrow Source
  seam building per-repo redactors from sealed blobs; zero-secret repos
  short-circuit to a nil redactor.
- store: sticky exposed flag on repo_secrets (0010, both dialects) —
  exposed_run_id + exposed_at, set by an atomic first-writer-wins
  MarkRepoSecretExposed (the unset→exposed edge), cleared by rotate,
  removed by delete.
- chat: scanAndRedact at BOTH read paths — Service.Read (HTTP, incl.
  ended runs) and the tailer tick, before the notify gate snapshots a
  needs-input body — masking every operator-visible string (text, tool
  chips, dialogs, pending dialog). On the edge: one log line (never the
  value), one push through the injected notify seam (ADR-0039; tag
  runID:exposed:NAME so coalescing never swallows it), one run.changed.
  Broker placeholders ([REDACTED:NAME] text) are inert by construction.
- API/UI: secrets list carries exposed_run_id/exposed_at (settings page
  shows an Exposed chip — which run, when, rotate to clear); GET
  /runs/{id} carries exposed_secrets for the chat-header warning badge
  (list endpoints stay N+1-free).
- migrations: renumber 0008_run_title → 0009 — #112 and #113 both
  claimed goose version 0008, which fails every Store.Open on main
  (fixes #117); this feature's migration takes 0010.

Closes #108
Closes #117

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SrMDj8ayE71AzA6M46pri2
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!118
No description provided.