Harden the Claude Code hooks: redaction, free compaction, PowerShell installer (#327) - #333
Merged
Merged
Conversation
What reaches the capture body is user and assistant prose — tool output is
already dropped — so the exposure that matters is a person pasting a credential
into a prompt, or the assistant echoing one back. redactSecrets() replaces those
with [redacted]: the caller's own configured token wherever it appears, Bearer
values, provider key shapes (sk-, ghp_/gho_, github_pat_, xoxb-/xoxp-, AKIA,
AIza), whole PEM private-key blocks, and TOKEN=/SECRET=/PASSWORD=/API_KEY=
assignments in either case and with either separator.
Nothing else. Over-redaction destroys the memory the hook exists to write, so
every pattern needs a provider's own prefix or an explicit label in front of it:
a UUID, a 40-char commit SHA, a path like /home/u/.config/second-brain/config.json
and ordinary prose ("we rotate the token: it expired on Tuesday") all survive
byte-for-byte, and there are tests that say so.
Order: format, then redact, then re-apply the cap.
- Redaction runs on the FORMATTED body, not on the turns, because the header is
stored too and carries a user-controlled branch name.
- It runs AFTER formatSession, not before, so formatSession's budget arithmetic
stays exactly as PR A verified it — redacting first would change what fits and
silently alter which turns are kept.
- That order can lengthen the text (TOKEN=abcd1234 is 15 chars, TOKEN=[redacted]
is 16), so buildCaptureBody re-applies the 2000-char cap afterwards. The trim
is the last thing that happens, so no secret can slip in behind it, and a test
builds a body that grows under redaction to prove the cap still holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd recall Compaction discards what the SessionStart hook injected, so the hook has to run again — but the context it would fetch is the context it already fetched. The session id is what makes reuse correct: it survives compaction and rotates on /clear, so a block cached under that id is always the current session's context and never a stale one from a cleared conversation. On startup and clear, the exact block that was printed is written to $XDG_CACHE_HOME/second-brain/session-<session_id>.txt. On compact, a cache file younger than 24 h is printed verbatim and no request is made at all — no recall, no embedding, no latency. With no cache, or an expired one, the hook falls back to a live recall with PR A's failure semantics unchanged (stderr + exit 1). The session id lands in a filename, so it is folded to name characters first; a test proves `../../etc/passwd` cannot escape the cache directory. The contract test spawns the hook twice against the recording stub and asserts the second run made zero requests and printed byte-identical output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code runs hooks under Git Bash when it is present and under PowerShell when it is not, and install.sh cannot run in the second case. install.ps1 gives those users the same installer: credentials to ~/.config/second-brain/config.json (never settings.json, never the hook command line), `node "<path>"` commands with no env prefix, timeout 30 on SessionEnd, matcher startup|clear|compact on SessionStart, reconciliation by command substring rather than appending, a refusal to write a settings.json that is not valid JSON, and -Check / -Uninstall. Node does the JSON editing in both installers, from the same script text, so the two cannot drift; PowerShell's own ConvertTo-Json would flatten a user's nested settings at depth 2. Two deviations from the bash script, both deliberate: Write-Error under -ErrorActionPreference Stop can only ever exit 1, so a small Stop-WithError helper carries the exit-2 case, and node is invoked with $PSNativeCommandUseErrorActionPreference disabled so a non-zero exit is a value to inspect rather than a thrown error on PowerShell 7.4. Not tested by CI: this machine has no pwsh, so there is no test file for it — writing one that cannot run would be worse than none. The embedded Node payloads were extracted from the script and exercised directly against a scratch HOME: install, re-run (idempotent), upgrade of a pre-PR-A entry with real Windows backslash paths, uninstall, and the malformed-settings refusal all behave as the bash installer's test asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`apiKey = process.env.OPENAI_API_KEY` was being rewritten to `apiKey =
[redacted]`. The value names a secret, it is not one, and a Claude Code session
is mostly talk about code — so the rule meant to protect a pasted credential was
blanking the lines these memories exist to keep.
Values that begin as an environment lookup or a placeholder (`process.env`,
`os.environ`, `import.meta.env`, `Deno.env`, `env.`, `$`, `<`, `{`) are left
alone. Literal assignments are still redacted, and every other pattern is
untouched: provider prefixes, PEM blocks, Bearer values and the caller's own
token never depended on this branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report
File CoverageNo changed files found. |
Base automatically changed from
fix/327-hooks-client
to
327-claude-code-hooks
September 3, 2026 20:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The third and last piece of #327.
Stacked on #331, because it edits the same two scripts. Base is
fix/327-hooks-client, so the diff here is only this work; GitHub retargets it to327-claude-code-hooksautomatically when #331 merges. Review order: #331, #332, then this.1. Credentials never reach the brain
Session capture sends conversation prose, and people paste credentials into prompts. The formatted body — header included, since a branch name is user-controlled — is scanned before it is sent and each credential replaced with
[redacted]: your own configured token wherever it appears,Bearervalues, provider shapes (sk-,ghp_/gho_,github_pat_,xoxb-/xoxp-, AWSAKIA…, GoogleAIza…), whole PEM private-key blocks, andTOKEN=/SECRET=/PASSWORD=/API_KEY=assignments.Restraint is the hard half. A memory redacted into uselessness is worse than no memory, so every pattern needs either a provider's own prefix or an explicit label in front of it. Verified against negative cases: a UUID, a 40-character commit SHA,
/home/u/.config/second-brain/config.json, base64 in prose, and the sentence "we rotate the token: it expired on Tuesday" all survive untouched.One over-redaction was found during review and fixed in its own commit:
apiKey = process.env.OPENAI_API_KEYwas being blanked. The value names a secret rather than being one, and these sessions are mostly talk about code, so environment lookups and placeholders (process.env,os.environ,import.meta.env,Deno.env,$…,<…>) are now left alone while literal assignments are still redacted.Ordering is deliberate and commented: format, then redact, then re-apply the 2000-character cap. Redacting first would change what fits and silently alter which turns are kept; the trim runs last so nothing can slip in behind it.
[redacted]can be longer than what it replaced, which is exactly why the cap is re-applied rather than assumed.2. Compaction costs nothing
The block printed at
startup/clearis cached under$XDG_CACHE_HOME/second-brain/session-<id>.txt. On compaction the hook prints that file verbatim and makes no request at all. The session id survives compaction and rotates on/clear, which is what makes a cached block always the current session's context; with no cache or one older than 24 h it falls back to a live recall with the same failure semantics as #331.Measured end to end against a stub: three runs (startup, compact, compact-with-cold-cache) produce two requests instead of three, and the cached run's output is byte-identical to the live one.
3.
install.ps1A PowerShell mirror for Windows machines where Claude Code falls back to PowerShell instead of Git Bash — the case where
install.shsimply cannot run. Same credentials file, same entries, same reconcile-don't-append behaviour, same refusal to overwrite a malformedsettings.json, and-Check/-Uninstall. Node does the JSON editing in both installers, from the same script text, so the two cannot drift.Testing
15 new tests (3258 total in this branch),
tsc --noEmitclean,check:scopeclean.../../etc/passwdcannot escape the cache directory), and contract tests that spawn the real hook twice and assert zero requests on the cached run and a live request on a cold one.Note: the Slack fixture in the redaction test is written as
xoxb-EXAMPLE-NOT-A-REAL-SLACK-TOKENrather than the canonical shape. The realistic-looking placeholder tripped GitHub's push protection, and rewriting the fixture is the right answer — allowlisting a "secret" to get a push through is a habit worth not forming. It still exercises the same pattern.One honest gap: there is no PowerShell on this machine, so
install.ps1has not been executed. Rather than write a test that would silently skip, I verified what could be verified mechanically — both embedded Node payloads were extracted and run exactly as the script invokes them, covering config writing, settings writing, idempotency, upgrade from a Windows-style stale entry, uninstall, and the malformed-file refusal (exit 1, file byte-identical afterwards). The PowerShell shell code around them — the param block, prompts, and the node invocation helper — is reviewed but unexecuted. It should be smoke-tested on a Windows machine before anyone is pointed at it.🤖 Generated with Claude Code