Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
9.7 KiB
REPORT — SPIKE: a secret broker and transcript guards for CC (2026-10-17)
For the operator
- What I did. I tested five ideas with fake secrets. I did not read your credentials file. I did not change your CC settings. Every test ran in a separate, throwaway CC session.
- What works. A helper can use a secret and hide it in the output. A guard stops the four known bad commands. A guard can replace a secret in a command's output, so it never reaches the session record. The encrypted store works. A nightly search finds a planted secret and nothing else.
- What I found broken, and fixed in the test. The replace-guard leaks when it crashes. It also leaks when the output is very large, because CC saves a copy on disk first. Both have a fix, and both fixes passed.
- What I could not do. I could not make the private test repo. My tokens do not have the right to make repos, and Gitea does not create a repo on push. So the store test ran only on this machine.
- What you decide. Where CC keeps its secrets. Pick B: an encrypted file in a private Gitea repo. The other choice is Vaultwarden: one more service to run and back up. If you pick nothing, the plain credentials file stays, and every known leak stays open.
- What you do, after you pick B. Make one empty private repo,
admin/cc-secrets-spike, or give CC a token that can make one. Put the design file in git (it was not on DooPlex). - Also for you. The Resend key is in every CC command's environment, from your
.bashrc. The file~/rotated-secrets-2026-10-09.txtstill exists. The homelab-manifests CC settings letkubectl get secretrun without a prompt. Each has a row. - One more fact. This session was started with permission prompts switched off (
--permission-mode bypassPermissions). The workspace rules say not to do that on DooPlex.
Baselines
| Repo | main at start |
Note |
|---|---|---|
| felhom.eu | e1dd1cadd31e9ba503ddf3fc952c03352887c4bb |
equal to the brief; one untracked folder from another session (audits/tester1-limits-filebrowser-2026-10-17/), not touched |
| homelab-manifests | origin/main e8e595a (equal to the brief) |
read only; the local clone ~/git/homelab-manifests is at eba52d7 (behind) with uncommitted edits to .claude/settings.json — not touched; the committed file was read with git show origin/main: |
main had not moved, so no new commits to read. CC version: 2.1.300 (claude --version). onFailure needs
2.1.295 — present.
Architecture document for the area: none (12-agent-tooling.md instruction files only; runbooks/secrets.md
cluster Secrets only) → R-1118. Inputs missing: DESIGN-cc-secrets-2026-10-17.md and
credentials-file-key-names-2026-10-16.md were not found (searched / and /mnt/5_hdd by file name). The
design copy is therefore not in git; Q6 used a name pattern.
Results
Full page: documentation/audits/SPIKE-cc-creds-2026-10-17.md. Probes and evidence:
documentation/audits/SPIKE-cc-creds-2026-10-17/{probes,evidence}/ — every canary replaced by its name;
checked with grep -rlF -f canary-<n>.txt over the folder, 0 files for all 14 canaries.
| Q | Verdict | Key number | Evidence |
|---|---|---|---|
| Q1 mask | PASS | 7/7 → 0/7, rc equal; +6 encodings 6/6 → 0/6; +0.7 s per secret per 100 MB | evidence/q1/q1-results.txt, q1b-results.txt, q1-timing.txt |
| Q2 PreToolUse | PASS | 4/4 refused, control ran, crash blocked | evidence/q2/q2-results.txt, q2-results-controls.txt, pre-hook.log |
| Q3 PostToolUse | PARTIAL | hook: answer 0, transcript 0, files 0 (control 1/3/1); 2 leaks + 2 measured fixes | evidence/q3/q3-results.txt, q3-edge-results.txt, q3-mitigation-results.txt |
| Q4 store | PARTIAL | 0 canary in file and git log -p; decrypt median 21 ms; revert restores; repo not created |
evidence/q4-binaries.txt, q4-anon-checks.txt |
| Q5 sweep | PASS | 1 098 MB in 35.7 s; planted 1, outside 0; this session's folder 0 for all canaries | evidence/q5-results.txt, q5-all-canaries.txt, q5-own-session.txt |
| Q6 | done | RESEND_API in CC's env; rotated file exists (632 B) |
evidence/q6-env-names.txt, q6-rotated-file.txt |
The updatedToolOutput shape used (Bash)
{"hookSpecificOutput": {"hookEventName": "PostToolUse",
"updatedToolOutput": {"stdout": "...«CANARY»...", "stderr": "", "interrupted": false,
"isImage": false, "noOutputExpected": false}}}
The whole tool_response is copied and only stdout/stderr rewritten (probes/post.py:17-28). Bash's
tool_response keys, measured: interrupted, isImage, noOutputExpected, stderr, stdout, plus
persistedOutputPath, persistedOutputSize on large output (evidence/q3/post-hook.log). The docs list four
keys (evidence/cc-docs-hooks-2026-10-17.md, „PostToolUse decision control").
Q3 table
| run | (a) answer | (b) transcript .jsonl |
(c) other files under ~/.claude, ~/.claude.json, ~/.cache, /tmp/claude-1000 |
|---|---|---|---|
| control, no hook | 1 | 3 | the transcript only |
| hook | 0 | 0 | none |
hook crashes, onFailure: "block" |
0 | 2 | the transcript |
| 197 KB output, hook | 0 | 0 | <session>/tool-results/b8qzklpyz.txt (raw, saved before the hook) |
fix: guard || printf withheld-json |
0 | 0 | none |
fix: hook also scrubs persistedOutputPath |
0 | 0 | none |
Transcripts: ~/.claude/projects/-home-kisfenyo-spike-cc-secrets-work/<session>.jsonl (ids in the evidence files).
Things that went wrong in the run, and what caught them
- Q1: the first mask leaked 2 of 3 base64 copies wrapped at 76 columns; my first checker read 0 there (a hollow zero) and a direct search found it. Fixed in the probe. A first fix attempt crashed the filter; the red-proof column then read 0 unfiltered as well, which flagged the broken run.
- Q2: the first control
lswas blocked by CC's own working-folder rule, not by the hook. Re-run with--add-dir. - Q4: a
git revert … -qmade no commit; seen ingit log, re-run without-q. The first anonymous raw-URL check used a file that does not exist in that repo (authed also 404), so it proved nothing; re-run on a file that exists (authed 200, anonymous 404).
„No access" — what was tried (workspace rule 2)
Creating admin/cc-secrets-spike: (1) POST /api/v1/user/repos with the kisfenyo credential → 403; (2) the
same with the admin entry of ~/.git-credentials → 403, required=[write:user], token scopes
write:misc,write:notification,write:package,write:issue,write:repository; (3) git push to the new URL →
Push to create is not enabled for users. Not tried, on purpose: the Gitea admin CLI or kubectl exec into the
Gitea pod (DooPlex infrastructure, outside the brief).
Register
Rows before 168, after 173; opened 5 (R-1118, R-1119, R-1120, R-1121 in Security & access; R-1122 in
Process & tooling); closed 0. STATUS.md: D10 (the store choice).
Recommendations not followed (workspace rule 4)
-
R-1122 is filed, not fixed, although the size rule says a small fix is done in the session: the brief named it as a register row and kept this spike docs-only.
-
No
CHANGELOG.mdentry: this repo's changelogs are per area (hub/,scripts/,website/,marketing/) and none of them changed; doc-only commits here carry none (e.g.e1dd1cad). -
REPORT-cc-secrets-2026-10-17.md, notREPORT.md: another session is active in this clone today, and the repo rule says the second session writesREPORT-<topic>.md. -
File names use
creds, notsecretsas the brief said:.gitignoreline 3 (*secret*) ignores the brief's names. I renamed rather than force past or edit a safety net.
The push collision (after this report was first written)
- What happened. Another session was editing
OPEN-ITEMS.mdat the same time and had not committed its edits yet. My script added my rows to the file as it stood, with that session's rows already in it. Mygit addnamed the path explicitly but did not diff its hunks. So my commitb9c4f73balso carried the other session's unfinished rows: R-1118..R-1122 used twice, and three rows removed without CLOSED-ITEMS entries. - Why the gate did not stop it. The pre-push hook checks the working tree. By the time I pushed, the other
session had already committed
fae80645on top, which fixed the numbers. So the hook passed while the pushed commit was broken. - Effect. CI job 1993 on
b9c4f73b: failure. The other session pushedfae80645itself; CI job 1994 on11d03dc0: success. My five rows and STATUS D10 are intact on main, and the section counts are correct. - My message to that session was not delivered. It was held and then expired, because the two sessions run in different permission modes. The repair was the other session's own work.
- Lesson saved to memory
felhom-eu-shared-worktree: diff a shared file before staging it, and stage only your own hunks.
End-of-session checks
python3 scripts/repo_gates.py --fast: all 19 gates OK.scripts/unproven.py --summary: walked 20, partial 16, built 15, missing 4, not walked 35 of 55 — no number moved.- System poster: no fact in
felhom-system-poster.facts.mdchanged — none of the three actions needed. - Capability map: no status changed.
Teardown
- Machine (DooPlex):
~/spike-cc-secrets/kept (0700, fake canaries, probes, pinnedsops/age, a throwaway age key) for the build phase; the Gitea netrc copy in it was deleted. Child transcripts holding fake canaries stay under~/.claude/projects/-home-kisfenyo-spike-cc-secrets-work/(the sweep's positive controls). The localpython3 srv303.pytest server is stopped at the end of the session. - Other hosts: none touched. Hub: not touched. Gitea: nothing created.