feat(secrets): SC-6 encrypted secrets + ${SECRET:NAME} substitution #636

Merged
code-lead merged 1 commit from boss/628 into main 2026-05-01 11:37:10 +00:00
Collaborator

Encrypted-secrets store (SC-6 / closes #628) — adds the AES-256-GCM secret table, the ${SECRET:NAME} substitution helper, the operator-gated /secrets HTTP surface, the /settings/secrets dashboard, and the secret_access_log audit trail.

  • Boot fails when CLAUDE_HOOKS_SECRET_KEY is missing or decodes to fewer than 32 bytes (matches OAUTH_ENCRYPTION_KEY validation pattern). Documented in docs/credentials.md.
  • decrypt(row, accessor, reason) always writes one secret_access_log row before returning the plaintext; the substitution helper de-dupes audit rows per name per render.
  • ${SECRET:NAME} placeholders that reference an unknown name throw MissingSecretError — partial substitution is never returned.
  • HTTP surface is auth-gated through guardMutating; GET /secrets returns names + metadata only, never bodies.
  • Dashboard reuses <Drawer>, <Button>, <ConfirmDialog> per apps/web/CLAUDE.md; values are write-only (input always reset to empty on edit).

Client-facing migration introduces the secret + secret_access_log tables idempotently via ensureSchema. The substitution helper is ready for agent-env-sync.renderForInstance to call (SC-2 wires the consumer).

Test plan

  • bun test src/infrastructure/secrets.test.ts src/http/handlers/secrets.test.ts — 39 specs covering boot validation, AES-256-GCM round-trip, tamper rejection, MissingSecretError, audit-log emission + cascade, HTTP CRUD, rotate, paginated access log, name-shape enforcement.
  • just qa — typecheck + Biome lint + format clean.
  • Manual smoke: POST /secretsGET /secrets (no body) → POST /secrets/:name/rotateGET /secrets/:name/access-log.

Closes #628

Encrypted-secrets store (SC-6 / closes #628) — adds the AES-256-GCM `secret` table, the `${SECRET:NAME}` substitution helper, the operator-gated `/secrets` HTTP surface, the `/settings/secrets` dashboard, and the `secret_access_log` audit trail. - Boot fails when `CLAUDE_HOOKS_SECRET_KEY` is missing or decodes to fewer than 32 bytes (matches `OAUTH_ENCRYPTION_KEY` validation pattern). Documented in `docs/credentials.md`. - `decrypt(row, accessor, reason)` always writes one `secret_access_log` row before returning the plaintext; the substitution helper de-dupes audit rows per name per render. - `${SECRET:NAME}` placeholders that reference an unknown name throw `MissingSecretError` — partial substitution is never returned. - HTTP surface is auth-gated through `guardMutating`; `GET /secrets` returns names + metadata only, never bodies. - Dashboard reuses `<Drawer>`, `<Button>`, `<ConfirmDialog>` per `apps/web/CLAUDE.md`; values are write-only (input always reset to empty on edit). Client-facing migration introduces the `secret` + `secret_access_log` tables idempotently via `ensureSchema`. The substitution helper is ready for `agent-env-sync.renderForInstance` to call (SC-2 wires the consumer). ## Test plan - [ ] `bun test src/infrastructure/secrets.test.ts src/http/handlers/secrets.test.ts` — 39 specs covering boot validation, AES-256-GCM round-trip, tamper rejection, `MissingSecretError`, audit-log emission + cascade, HTTP CRUD, rotate, paginated access log, name-shape enforcement. - [ ] `just qa` — typecheck + Biome lint + format clean. - [ ] Manual smoke: `POST /secrets` → `GET /secrets` (no body) → `POST /secrets/:name/rotate` → `GET /secrets/:name/access-log`. Closes #628
feat(secrets): SC-6 encrypted secrets table + ${SECRET:NAME} substitution
Some checks failed
qa / dockerfile (pull_request) Successful in 5s
qa / qa (pull_request) Failing after 1m9s
827ab8416b
Adds the AES-256-GCM secret store, the `${SECRET:NAME}` substitution
helper, the `/secrets` HTTP surface, the `/settings/secrets` dashboard,
and `secret_access_log` audit trail. Boot fails when
`CLAUDE_HOOKS_SECRET_KEY` is missing or decodes to fewer than 32 bytes.

Closes #628

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(ci): rebase flow-runs seed timestamps onto Date.now()
All checks were successful
qa / dockerfile (pull_request) Successful in 6s
qa / qa (pull_request) Successful in 1m58s
861b67c4f6
The mock fetch harness loaded `flow-runs.json` with hardcoded April
2026 timestamps. Once real time crossed seven days past the most
recent seeded run, `computeHealth()` flipped `default.review-requested`
from `healthy → idle`, breaking the `flow-enabled-*` pill text and
the `optimistic flip then rollback when server rejects with 403`
test (expected `ON`, got `IDLE`).

Anchor the latest seed `started_at` at `now - 1 minute` and shift
every `started_at`/`finished_at`/`fired_at` by the same offset so
the seed never ages out. Relative spacing between runs is preserved.
reviewer approved these changes 2026-05-01 11:33:09 +00:00
reviewer left a comment

All ACs from #628 met. Crypto correct (AES-256-GCM, 12-byte IV, 16-byte auth tag, fresh per row). Audit log written on successful decrypt only. All 6 HTTP routes gated via guardMutating. Dashboard reuses <Drawer>, <Button>, <ConfirmDialog>. Test coverage solid: round-trip, tamper, missing-secret, substitution, deep walk.

Nits (not blocking):

  • secrets.ts:500 comment "The audit log captures the access intent regardless" is wrong — if decrypt throws, no audit row is written. Remove or invert the sentence.
  • handleSecretsReplace coerces description differently from handleSecretsCreate (empty string → "" vs null). The UI never sends "" so this is harmless, but clearing a description via the edit drawer silently preserves the old value instead of clearing it. Consider description || null instead of the bare string pass-through.

renderForInstance wiring deferred to SC-2/SC-5 as expected — the helpers are exported and ready.

All ACs from #628 met. Crypto correct (AES-256-GCM, 12-byte IV, 16-byte auth tag, fresh per row). Audit log written on successful decrypt only. All 6 HTTP routes gated via `guardMutating`. Dashboard reuses `<Drawer>`, `<Button>`, `<ConfirmDialog>`. Test coverage solid: round-trip, tamper, missing-secret, substitution, deep walk. **Nits (not blocking):** - `secrets.ts:500` comment "The audit log captures the access intent regardless" is wrong — if `decrypt` throws, no audit row is written. Remove or invert the sentence. - `handleSecretsReplace` coerces `description` differently from `handleSecretsCreate` (empty string → `""` vs `null`). The UI never sends `""` so this is harmless, but clearing a description via the edit drawer silently preserves the old value instead of clearing it. Consider `description || null` instead of the bare string pass-through. `renderForInstance` wiring deferred to SC-2/SC-5 as expected — the helpers are exported and ready.
code-lead force-pushed boss/628 from 861b67c4f6
All checks were successful
qa / dockerfile (pull_request) Successful in 6s
qa / qa (pull_request) Successful in 1m58s
to a98107bde5
All checks were successful
qa / dockerfile (pull_request) Successful in 6s
qa / qa (pull_request) Successful in 2m8s
2026-05-01 11:34:31 +00:00
Compare
code-lead deleted branch boss/628 2026-05-01 11:37:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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
charles/claude-hooks!636
No description provided.