feat(secrets): per-repo secrets — vault to labctl broker exec (tracer bullet) (#104) #112

Merged
dominik.polakovics merged 1 commit from afk/104 into main 2026-07-10 18:43:45 +02:00

Per-repo secrets, foundation slice: operator-managed named values an agent can use without ever holding them in ambient env.

Closes #104

What landed

  • Store + migration (0008_repo_secrets, both dialects): repo_secrets — name ^[A-Z][A-Z0-9_]*$ unique per repo, operator description, value sealed with the existing master key (AES-256-GCM nonce||ciphertext, ADR-0006 style). Metadata reads never select the blob.
  • Operator API + UI (write-only, ADR-0006 discipline): GET/POST /api/v1/repos/{id}/secrets, PATCH/DELETE .../secrets/{sid} (rotate = wholesale new value; delete immediate). Secrets section on the repo settings page; rotate reveals a blank field — the old value is never fetched. Tests assert no response body ever carries a value.
  • Agent surface (run-token scoped — repo comes from the run row only): GET /agent/v1/secrets (names + descriptions), POST /agent/v1/secrets/values (partial miss → 404 naming the missing, no values). labctl secret list; labctl secret exec <NAME...> -- <cmd> injects each value into the child process env only (never argv, never the session env, never disk), preserves the child's exit code (documented deviant exit contract, like pr checks), and fetches at exec time — rotation is live, no respawn.
  • Seeder (ADR-0035): conditional ## Secrets section in the generated context file — the norm (never echo/persist; use only inside secret exec), the single-quote sh -c pattern incl. the double-quote trap, the spawn-time inventory, and the labctl secret list pointer. Repos with zero secrets render byte-identically (frozen goldens untouched; new golden pins the with-secrets render).
  • internal/secrets matcher (shared kernel for #105–#108, no consumer yet): streaming detection of a value in exact, base64 (all 4 alphabets), hex (both cases), and URL-encoded forms, exactly-once across arbitrary chunk boundaries (carry window + end-offset threshold; boundary-sweep tested at every split point down to 1-byte feeds).

Verification

  • go test ./... green except pre-existing sandbox internal/tmuxx failures (tmux cannot spawn sessions here; verified pre-existing on base via stash).
  • golangci-lint run ./... (pinned 2.12.2): 0 issues. gofmt clean. go build -tags ui ./cmd/... with embedded dist: OK.
  • web: eslint, prettier, vitest 576/576, tsc && vite build all green.
  • Acceptance-criteria-shaped integration tests (real sqlite store + agentapi over httptest + real vault + real child exec): list output, $NAME injection, live rotation, exit-code passthrough, unknown-name failure, argv exactness, cross-repo token scoping.

Out of scope (per issue): the four harness layers (#105 redaction, #106 pre-push guard, #107 tracker-write sanitization, #108 exposure detection), audit log, cross-repo sharing, any read-back path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SvDTdGmhGMUr46tATHc4AY

Per-repo secrets, foundation slice: operator-managed named values an agent can *use* without ever holding them in ambient env. Closes #104 ## What landed - **Store + migration** (`0008_repo_secrets`, both dialects): `repo_secrets` — name `^[A-Z][A-Z0-9_]*$` unique per repo, operator description, value sealed with the existing master key (AES-256-GCM `nonce||ciphertext`, ADR-0006 style). Metadata reads never select the blob. - **Operator API + UI** (write-only, ADR-0006 discipline): `GET/POST /api/v1/repos/{id}/secrets`, `PATCH/DELETE .../secrets/{sid}` (rotate = wholesale new value; delete immediate). Secrets section on the repo settings page; rotate reveals a blank field — the old value is never fetched. Tests assert no response body ever carries a value. - **Agent surface** (run-token scoped — repo comes from the run row only): `GET /agent/v1/secrets` (names + descriptions), `POST /agent/v1/secrets/values` (partial miss → 404 naming the missing, no values). `labctl secret list`; `labctl secret exec <NAME...> -- <cmd>` injects each value into the **child process env only** (never argv, never the session env, never disk), preserves the child's exit code (documented deviant exit contract, like `pr checks`), and fetches at exec time — **rotation is live, no respawn**. - **Seeder** (ADR-0035): conditional `## Secrets` section in the generated context file — the norm (never echo/persist; use only inside `secret exec`), the single-quote `sh -c` pattern incl. the double-quote trap, the spawn-time inventory, and the `labctl secret list` pointer. Repos with zero secrets render **byte-identically** (frozen goldens untouched; new golden pins the with-secrets render). - **`internal/secrets` matcher** (shared kernel for #105–#108, no consumer yet): streaming detection of a value in exact, base64 (all 4 alphabets), hex (both cases), and URL-encoded forms, exactly-once across arbitrary chunk boundaries (carry window + end-offset threshold; boundary-sweep tested at every split point down to 1-byte feeds). ## Verification - `go test ./...` green except pre-existing sandbox `internal/tmuxx` failures (tmux cannot spawn sessions here; verified pre-existing on base via stash). - `golangci-lint run ./...` (pinned 2.12.2): 0 issues. `gofmt` clean. `go build -tags ui ./cmd/...` with embedded dist: OK. - web: eslint, prettier, vitest 576/576, `tsc && vite build` all green. - Acceptance-criteria-shaped integration tests (real sqlite store + agentapi over httptest + real vault + real child exec): list output, `$NAME` injection, live rotation, exit-code passthrough, unknown-name failure, argv exactness, cross-repo token scoping. Out of scope (per issue): the four harness layers (#105 redaction, #106 pre-push guard, #107 tracker-write sanitization, #108 exposure detection), audit log, cross-repo sharing, any read-back path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SvDTdGmhGMUr46tATHc4AY
feat(secrets): per-repo secrets — vault to labctl broker exec (tracer bullet) (#104)
All checks were successful
ci / native (pull_request) Successful in 5m6s
4dea46ce58
End-to-end foundation slice: an operator creates a named secret on the
repo's settings page; an instance uses it through the broker without the
value ever entering ambient env, argv, or any readable API.

- store: repo_secrets table (0008, both dialects) — name ^[A-Z][A-Z0-9_]*$
  unique per repo, description, value sealed by the master-key vault
  (AES-256-GCM nonce||ciphertext); metadata reads never select the blob.
- operator API + UI: write-only create/rotate/delete + metadata list under
  /api/v1/repos/{id}/secrets, Secrets section on repo settings; no endpoint
  or UI path ever returns a value (credentials-vault discipline, ADR-0006).
- agent surface: GET /agent/v1/secrets (names+descriptions) and
  POST /agent/v1/secrets/values, both scoped by the run token's run row;
  labctl secret list / secret exec <NAME...> -- <cmd> injects values into
  the child process env only, preserves the child's exit code, and fetches
  at exec time — rotation is live, no respawn.
- seeder: conditional Secrets section in the generated context file
  (ADR-0035): usage norm, single-quote sh -c pattern, spawn-time inventory,
  labctl secret list pointer; secret-less repos render byte-identically
  (goldens untouched).
- internal/secrets: shared streaming matcher detecting a value in exact,
  base64 (4 alphabets), hex (2 cases), and URL-encoded forms, correct
  across arbitrary chunk boundaries — the kernel for #105–#108, no
  consumer yet.

Closes #104

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvDTdGmhGMUr46tATHc4AY
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!112
No description provided.