Second half of #29. The index was hand-maintained and had already drifted once: dream and experiment-spec shipped with no row, their rows lost to a squash-merge after a directory reorg. bin/check-index caught that after the fact; generating removes the class. The curated summary and trigger move into each SKILL.md as `summary:` and `use_when:`, carried over verbatim from the existing table rather than derived from `description:` — description is a paragraph written for model selection, and truncating it would have produced a worse table than the one it replaced. Knowledge now lives once, next to the skill it describes, which is the argument that made a hand-maintained index the wrong shape to begin with. The table is written between markers, so the wiring docs, the relationships tree and the trigger table below it are untouched — those are prose, not data. Rows are alphabetical now; the conceptual grouping the old order carried lives in the relationships tree, which is the better place for it. A fourth column shows each skill's surfaces, which is what makes #29's field visible rather than merely enforced. bin/check-index and its test are deleted: a generated table cannot drift in either direction, so `generate-index --check` subsumes both of its assertions. Verified by mutation — deleting the tdd row turns `task check` red with "SKILLS_INDEX.md is out of date". Closes #29 Bump-Type: minor
4.4 KiB
4.4 KiB
name, surfaces, summary, use_when, description
| name | surfaces | summary | use_when | description | ||
|---|---|---|---|---|---|---|
| secret-hygiene |
|
Stop credentials rendering into the session transcript; safe rotation chain when one leaks | Before any kubectl/SOPS/token command; after a confirmed leak | Prevent agent sessions from rendering credentials into the transcript, and recover safely when one leaks. Load BEFORE any command that touches credentials - kubectl exec/env/describe, kubectl get secret, whole-file SOPS decryption, token generation or rotation, envFrom inspection, or edits to encrypted config. Also load when a credential has already been exposed and a rotation chain is needed. Triggers - "rotate this token", "decrypt the config", "check the env var", "leaked credential", "did that print a secret", "why is this secret wrong". |
Secret hygiene for agent sessions
Goal: stop the recurring leak pattern where a diagnostic command renders a SOPS-encrypted value or a Kubernetes Secret value into the session transcript, forcing a rotation.
Confirmed past leaks (see mathias/infra#39 rotation umbrella):
POSTGRES_PASSWORD—kubectl exec ... envrendered envFrom valueINFRA_MCP_TOKEN—sops -d ingress.enc.yamlrendered the bearer in the configuration-snippetbrain_apppostgres DSN — pre-fix ingestion pod log (hyperguild#20)
Three are pattern, not bug.
Rules — never do these
| Forbidden | Why | Use instead |
|---|---|---|
kubectl exec <pod> -- env (or printenv) |
Renders every envFrom + valueFrom.secretKeyRef plaintext | kubectl describe pod <pod> — k8s redacts secretKeyRef in describe |
sops -d <file> to inspect |
Whole-file decrypt dumps everything | sops --extract '["key"]' <file> for pinpoint reads |
kubectl get secret ... -o jsonpath='{.data}' (whole map) |
Returns base64 of every key; trivial to b64-decode the lot | ... -o jsonpath='{.data.SPECIFIC_KEY}' for the one key needed |
| Echoing a generated token to verify length / content | Renders the new token into transcript, defeating the rotation | Verify functionally: curl ... -H "Authorization: Bearer $(cat /tmp/tokfile)" — never echo $NEW_TOK |
Storing a token in a shell variable then running echo / set / env |
Same as above | Pipe through file (mode 600) or stdin to the next consumer |
Rules — do these instead
- Verify by side-effect, not by value. If a token rotation should make
the old token return 401 and the new one return 200, test those two
HTTP responses. Never check
$OLD == $LEAKEDby printing it. - Pinpoint SOPS reads. When you need ONE key from a SOPS file, always
sops --extract '["..."]'. The shape of the key path is safe to log; the value isn't. - Stash tokens in
/dev/shm/<unique>mode 600 and pass via filename, not env.shred -uafter use. Avoid/tmpif the system has/dev/shm. - If you must dump a secret to confirm wiring, immediately rotate it in the same commit train. Treat the dump as "this is now compromised".
Rotation chain pattern when a token is already exposed
If a token was leaked in a prior step of the same session, chain two rotations:
- T1: replace the leaked value with a new one. The verification step may also leak T1 (acceptable risk).
- T2: replace T1 with a fresh value. Run this without any decrypt
step that emits to stdout. The new value lives only in the encrypted
file + the consumer's secret store (
~/.llmkeys, the k8s Secret).
Example T2 path (Python, never decrypts to stdout):
new_t2 = subprocess.check_output(["openssl","rand","-hex","32"]).decode().strip()
plaintext = subprocess.check_output(["sops","-d", path]).decode() # in-memory only
patched = re.sub(r'Bearer [a-f0-9]+(?=")', f"Bearer {new_t2}", plaintext)
# write patched via /dev/shm tmp + sops -e → write back
# DO NOT print(patched), DO NOT print(new_t2)
After T2 lands via GitOps + Flux, update consumers from the file stash without echoing.
Detection ideas (not blocking — telemetry)
- Add a
bashpre-hook in Claude harness that refuses obvious patterns (kubectl exec * env*,sops -d *.enc.yaml). Filed as a follow-up. - Run a periodic grep over
~/.claude/projects/*/sessions/*.jsonlfor high-entropy strings matching[a-f0-9]{32,}to catch new leak surface.
Reference
gitea.d-ma.be/mathias/infra#39— umbrella credential rotation issue with confirmed-leak roster.gitea.d-ma.be/mathias/infra#44— POSTGRES_PASSWORD rotation procedure (template for future incidents).