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

Closed
opened 2026-07-10 17:36:27 +02:00 by dominik.polakovics · 2 comments

What to build

Per-repo secrets: named, operator-managed values an agent can use in commands without ever holding them in ambient env. End-to-end slice: operator creates a secret in the repo's settings UI → an instance uses it via labctl secret exec → the wrapped command sees it as an env var, nothing else does.

Threat model (from the 2026-07-10 grill): accidental leakage by a trusted-but-sloppy agent — not hard isolation. This slice is the foundation; the harness layers (redaction, git guard, body sanitization, exposure detection) are separate follow-up issues.

Design:

  • Data model: new per-repo table — name (env-var-shaped, ^[A-Z][A-Z0-9_]*$, unique per repo), description (operator-written, shown to the agent), value encrypted at rest with the existing master key (AES-256-GCM, ADR-0006 style, nonce||ciphertext). Secrets are NOT vault credential kinds and have no cross-repo sharing.
  • Operator API + UI: repo settings page section. Write-only discipline per ADR-0006: create / rotate (set new value) / delete; the value is never readable back through API or UI. Delete is immediate (nothing references a per-repo secret). No audit log (explicitly declined).
  • Agent surface (run token, agentapi): labctl secret list → names + descriptions only, never values. labctl secret exec <NAME...> -- <cmd> → fetches the named values via LAB_TOKEN, injects each as $NAME into the child process env only (never argv, never the session env, never files), execs the command. Rotation is live: the broker fetches at exec time, no respawn.
  • Seeder: append a Secrets section to the generated context file (CLAUDE.local.md; rides the lab-owned context-file rule of ADR-0035 for other providers): the norm (never echo/cat/persist a secret; use it only inside labctl secret exec), the -- sh -c '... $NAME ...' single-quote pattern (double quotes expand in the parent shell, to empty), spawn-time names + descriptions, and a pointer to labctl secret list for live inventory. Repos with zero secrets get no section.
  • Matcher package: a shared, unit-tested matcher (e.g. internal/secrets) that detects a secret value in a byte stream as its exact form plus base64, hex, and URL-encoded forms. Landed here (with tests, no consumer yet) as the shared kernel so the four harness follow-ups can proceed in parallel.

Acceptance criteria

  • Migration + store: per-repo secrets with encrypted values; name validated and unique per repo
  • Operator API is write-only (create/rotate/delete); no endpoint returns a value; UI section on repo settings matches
  • labctl secret list shows names + descriptions; labctl secret exec NAME -- sh -c 'echo $NAME' works inside an instance; env in the session shows no secret; value appears in no argv
  • Rotating a secret changes what the next secret exec injects, without respawn
  • Seeded context file documents the norm, the sh -c pattern, and the inventory (only when the repo has secrets)
  • Matcher package detects exact/base64/hex/urlencoded forms, unit-tested incl. rolling-window chunk boundaries
  • Run-token scoping: an instance can only fetch its own repo's secrets

Blocked by

None - can start immediately

## What to build Per-repo **secrets**: named, operator-managed values an agent can *use* in commands without ever holding them in ambient env. End-to-end slice: operator creates a secret in the repo's settings UI → an instance uses it via `labctl secret exec` → the wrapped command sees it as an env var, nothing else does. Threat model (from the 2026-07-10 grill): accidental leakage by a trusted-but-sloppy agent — not hard isolation. This slice is the foundation; the harness layers (redaction, git guard, body sanitization, exposure detection) are separate follow-up issues. Design: - **Data model**: new per-repo table — `name` (env-var-shaped, `^[A-Z][A-Z0-9_]*$`, unique per repo), `description` (operator-written, shown to the agent), value encrypted at rest with the existing master key (AES-256-GCM, ADR-0006 style, `nonce||ciphertext`). Secrets are NOT vault credential kinds and have no cross-repo sharing. - **Operator API + UI**: repo settings page section. Write-only discipline per ADR-0006: create / rotate (set new value) / delete; the value is never readable back through API or UI. Delete is immediate (nothing references a per-repo secret). No audit log (explicitly declined). - **Agent surface** (run token, agentapi): `labctl secret list` → names + descriptions only, never values. `labctl secret exec <NAME...> -- <cmd>` → fetches the named values via `LAB_TOKEN`, injects each as `$NAME` into the **child process env only** (never argv, never the session env, never files), execs the command. Rotation is live: the broker fetches at exec time, no respawn. - **Seeder**: append a **Secrets** section to the generated context file (CLAUDE.local.md; rides the lab-owned context-file rule of ADR-0035 for other providers): the norm (never echo/cat/persist a secret; use it only inside `labctl secret exec`), the `-- sh -c '... $NAME ...'` single-quote pattern (double quotes expand in the parent shell, to empty), spawn-time names + descriptions, and a pointer to `labctl secret list` for live inventory. Repos with zero secrets get no section. - **Matcher package**: a shared, unit-tested matcher (e.g. `internal/secrets`) that detects a secret value in a byte stream as its exact form **plus base64, hex, and URL-encoded forms**. Landed here (with tests, no consumer yet) as the shared kernel so the four harness follow-ups can proceed in parallel. ## Acceptance criteria - [ ] Migration + store: per-repo secrets with encrypted values; name validated and unique per repo - [ ] Operator API is write-only (create/rotate/delete); no endpoint returns a value; UI section on repo settings matches - [ ] `labctl secret list` shows names + descriptions; `labctl secret exec NAME -- sh -c 'echo $NAME'` works inside an instance; `env` in the session shows no secret; value appears in no argv - [ ] Rotating a secret changes what the next `secret exec` injects, without respawn - [ ] Seeded context file documents the norm, the sh -c pattern, and the inventory (only when the repo has secrets) - [ ] Matcher package detects exact/base64/hex/urlencoded forms, unit-tested incl. rolling-window chunk boundaries - [ ] Run-token scoping: an instance can only fetch its own repo's secrets ## Blocked by None - can start immediately
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Per-repo, operator-managed secrets that an agent can use only through labctl secret exec (broker injection into the child process env) — the foundation slice, including the shared multi-encoding secret matcher.

Current behavior:
Lab has a credential vault for git auth (ADR-0006: write-only, master-key AES-256-GCM encryption), but no way for an operator to hand an instance a named secret such as an API key. There is no ambient-env mechanism for repo secrets, no labctl secret surface, no secrets section in repo settings, and no secret-value matcher anywhere in the codebase.

Desired behavior:
An operator creates a named secret in the repo's settings; an instance discovers it (labctl secret list — names and descriptions only) and uses it (labctl secret exec <NAME...> -- <cmd> — value injected into the wrapped child's env as $NAME, nothing else). The value is never readable back through any API or UI, never appears in argv, the session env, or on disk. Rotation is live: the broker fetches values at exec time, so the next secret exec picks up a rotated value without respawn. Threat model is accidental leakage by a trusted-but-sloppy agent, not hard isolation.

Key interfaces:

  • Store entity — per-repo secret: name (env-var-shaped, ^[A-Z][A-Z0-9_]*$, unique per repo), description (operator-written, shown to agents), value encrypted at rest with the existing master key (AES-256-GCM, nonce||ciphertext, ADR-0006 style). Not a vault credential kind; no cross-repo sharing.
  • Operator API + UI — repo-settings section with create / rotate (set new value) / delete; write-only discipline: no endpoint or UI path ever returns a value. Delete is immediate.
  • Agent surface (run token / agentapi) — list endpoint returning names + descriptions; a value-fetch path used only by secret exec, scoped so a run token can fetch only its own repo's secrets.
  • labctl secret exec — fetches named values via LAB_TOKEN at exec time, injects each as $NAME into the child process env only, execs the command, preserves exit code.
  • Seeder — appends a Secrets section to the generated context file (lab-owned context-file rule, ADR-0035): the norm (never echo/cat/persist a secret; use values only inside secret exec), the -- sh -c '... $NAME ...' single-quote pattern (double quotes expand in the parent shell, to empty), spawn-time names + descriptions, and a pointer to labctl secret list. Repos with zero secrets get no section.
  • Matcher package — a shared, unit-tested package (suggested home: an internal secrets package) that detects a secret value in a byte stream in its exact form plus base64, hex, and URL-encoded forms, correct across chunk boundaries (rolling window). Ships here with tests but no consumer — it is the shared kernel the four harness follow-ups (#105–#108) build on.

Acceptance criteria:

  • Migration + store: per-repo secrets with encrypted values; name validated and unique per repo
  • Operator API is write-only (create/rotate/delete); no endpoint returns a value; repo-settings UI section matches
  • labctl secret list shows names + descriptions; labctl secret exec NAME -- sh -c 'echo $NAME' works inside an instance; env in the session shows no secret; the value appears in no argv
  • Rotating a secret changes what the next secret exec injects, without respawn
  • Seeded context file documents the norm, the sh -c pattern, and the inventory — only when the repo has secrets
  • Matcher detects exact/base64/hex/URL-encoded forms, unit-tested including rolling-window chunk boundaries
  • Run-token scoping: an instance can only fetch its own repo's secrets

Out of scope:

  • The four harness layers: broker output redaction (#105), git pre-push leak guard (#106), tracker-write sanitization (#107), exposure detection / chat masking (#108)
  • Any audit log of secret access (explicitly declined in design)
  • Cross-repo secret sharing or vault credential kinds
  • Any read-back path for a secret value, ambient env injection, or writing values to files
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Per-repo, operator-managed secrets that an agent can use only through `labctl secret exec` (broker injection into the child process env) — the foundation slice, including the shared multi-encoding secret matcher. **Current behavior:** Lab has a credential vault for git auth (ADR-0006: write-only, master-key AES-256-GCM encryption), but no way for an operator to hand an instance a named secret such as an API key. There is no ambient-env mechanism for repo secrets, no `labctl secret` surface, no secrets section in repo settings, and no secret-value matcher anywhere in the codebase. **Desired behavior:** An operator creates a named secret in the repo's settings; an instance discovers it (`labctl secret list` — names and descriptions only) and uses it (`labctl secret exec <NAME...> -- <cmd>` — value injected into the wrapped child's env as `$NAME`, nothing else). The value is never readable back through any API or UI, never appears in argv, the session env, or on disk. Rotation is live: the broker fetches values at exec time, so the next `secret exec` picks up a rotated value without respawn. Threat model is accidental leakage by a trusted-but-sloppy agent, not hard isolation. **Key interfaces:** - **Store entity** — per-repo secret: `name` (env-var-shaped, `^[A-Z][A-Z0-9_]*$`, unique per repo), `description` (operator-written, shown to agents), value encrypted at rest with the existing master key (AES-256-GCM, `nonce||ciphertext`, ADR-0006 style). Not a vault credential kind; no cross-repo sharing. - **Operator API + UI** — repo-settings section with create / rotate (set new value) / delete; write-only discipline: no endpoint or UI path ever returns a value. Delete is immediate. - **Agent surface (run token / agentapi)** — list endpoint returning names + descriptions; a value-fetch path used only by `secret exec`, scoped so a run token can fetch only its own repo's secrets. - **`labctl secret exec`** — fetches named values via `LAB_TOKEN` at exec time, injects each as `$NAME` into the child process env only, execs the command, preserves exit code. - **Seeder** — appends a **Secrets** section to the generated context file (lab-owned context-file rule, ADR-0035): the norm (never echo/cat/persist a secret; use values only inside `secret exec`), the `-- sh -c '... $NAME ...'` single-quote pattern (double quotes expand in the parent shell, to empty), spawn-time names + descriptions, and a pointer to `labctl secret list`. Repos with zero secrets get no section. - **Matcher package** — a shared, unit-tested package (suggested home: an `internal` secrets package) that detects a secret value in a byte stream in its exact form plus base64, hex, and URL-encoded forms, correct across chunk boundaries (rolling window). Ships here with tests but no consumer — it is the shared kernel the four harness follow-ups (#105–#108) build on. **Acceptance criteria:** - [ ] Migration + store: per-repo secrets with encrypted values; name validated and unique per repo - [ ] Operator API is write-only (create/rotate/delete); no endpoint returns a value; repo-settings UI section matches - [ ] `labctl secret list` shows names + descriptions; `labctl secret exec NAME -- sh -c 'echo $NAME'` works inside an instance; `env` in the session shows no secret; the value appears in no argv - [ ] Rotating a secret changes what the next `secret exec` injects, without respawn - [ ] Seeded context file documents the norm, the sh -c pattern, and the inventory — only when the repo has secrets - [ ] Matcher detects exact/base64/hex/URL-encoded forms, unit-tested including rolling-window chunk boundaries - [ ] Run-token scoping: an instance can only fetch its own repo's secrets **Out of scope:** - The four harness layers: broker output redaction (#105), git pre-push leak guard (#106), tracker-write sanitization (#107), exposure detection / chat masking (#108) - Any audit log of secret access (explicitly declined in design) - Cross-repo secret sharing or vault credential kinds - Any read-back path for a secret value, ambient env injection, or writing values to files
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #112PASS

Validated feat(secrets): per-repo secrets — vault to labctl broker exec (tracer bullet) (afk/104).

Signals relied on

  • CI ci / native: success (Forgejo Actions run — relied on, not re-run).
  • Merge-tree against origin/main: no conflict.

Checks

  • Title is Conventional Commits; body carries a working Closes #104 (AFK contract).
  • Sealed at rest: metadata reads (repoSecretColumns) never select encrypted_value; only RepoSecretValues does, keyed by name — never embedded in the RepoSecret struct.
  • Operator API write-only: repoSecretJSON emits id/name/description/timestamps only; rotate takes a fresh value wholesale, never reads the old one back; cross-repo {sid} → 404.
  • Agent surface run-scoped: repo derived strictly from the run rows info.RepoID, never client-supplied; partial miss → 404 naming the missing, zero values returned; decrypt failure → opaque 500 logging name+repo only.
  • labctl secret exec: values land only in cmd.Env — never argv, os.Setenv, or disk; fetched at exec time (live rotation); child exit code passes through.
  • Matcher: carry window maxLen−1 + strict end > threshold give exactly-once detection across arbitrary chunk boundaries; encodings (base64 ×4, hex, url) covered.
  • Migrations: sqlite/postgres 0008 consistent (BLOB↔BYTEA), UNIQUE(repo_id, name), ON DELETE CASCADE.

Independent review + a deep second-pass review each found no correctness bugs. Awaiting free-text merge go-ahead.

> *This was generated by AI while landing a PR.* ## Landing audit — PR #112 → **PASS** Validated `feat(secrets): per-repo secrets — vault to labctl broker exec (tracer bullet)` (`afk/104`). **Signals relied on** - **CI `ci / native`: `success`** (Forgejo Actions run — *relied on, not re-run*). - Merge-tree against `origin/main`: **no conflict**. **Checks** - Title is Conventional Commits; body carries a working `Closes #104` (AFK contract). - **Sealed at rest**: metadata reads (`repoSecretColumns`) never select `encrypted_value`; only `RepoSecretValues` does, keyed by name — never embedded in the `RepoSecret` struct. - **Operator API write-only**: `repoSecretJSON` emits id/name/description/timestamps only; rotate takes a fresh value wholesale, never reads the old one back; cross-repo `{sid}` → 404. - **Agent surface run-scoped**: repo derived strictly from the run rows `info.RepoID`, never client-supplied; partial miss → 404 naming the missing, **zero** values returned; decrypt failure → opaque 500 logging name+repo only. - **`labctl secret exec`**: values land only in `cmd.Env` — never argv, `os.Setenv`, or disk; fetched at exec time (live rotation); child exit code passes through. - **Matcher**: carry window `maxLen−1` + strict `end > threshold` give exactly-once detection across arbitrary chunk boundaries; encodings (base64 ×4, hex, url) covered. - **Migrations**: sqlite/postgres 0008 consistent (BLOB↔BYTEA), `UNIQUE(repo_id, name)`, `ON DELETE CASCADE`. Independent review + a deep second-pass review each found **no correctness bugs**. Awaiting free-text merge go-ahead.
Sign in to join this conversation.
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#104
No description provided.