Skip to content

Harden the Claude Code hooks: redaction, free compaction, PowerShell installer (#327) - #333

Merged
rahilp merged 4 commits into
327-claude-code-hooksfrom
feat/327-hooks-hardening
Sep 3, 2026
Merged

Harden the Claude Code hooks: redaction, free compaction, PowerShell installer (#327)#333
rahilp merged 4 commits into
327-claude-code-hooksfrom
feat/327-hooks-hardening

Conversation

@rahilp

@rahilp rahilp commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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 to 327-claude-code-hooks automatically 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, Bearer values, provider shapes (sk-, ghp_/gho_, github_pat_, xoxb-/xoxp-, AWS AKIA…, Google AIza…), whole PEM private-key blocks, and TOKEN=/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_KEY was 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/clear is 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.ps1

A PowerShell mirror for Windows machines where Claude Code falls back to PowerShell instead of Git Bash — the case where install.sh simply cannot run. Same credentials file, same entries, same reconcile-don't-append behaviour, same refusal to overwrite a malformed settings.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 --noEmit clean, check:scope clean.

  • Redaction: one case per pattern, plus the negative cases above, plus a body that grows under redaction to prove the cap still holds.
  • Compaction: unit tests for the cache helpers including a path-traversal case (../../etc/passwd cannot 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-TOKEN rather 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.ps1 has 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

rahilp and others added 4 commits September 3, 2026 15:51
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>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 91.12% 4734 / 5195
🔵 Statements 89.12% 5620 / 6306
🔵 Functions 91.2% 830 / 910
🔵 Branches 81.38% 3624 / 4453
File CoverageNo changed files found.
Generated in workflow #445 for commit 724aab6 by the Vitest Coverage Report Action

Base automatically changed from fix/327-hooks-client to 327-claude-code-hooks September 3, 2026 20:38
@rahilp
rahilp merged commit 394733d into 327-claude-code-hooks Sep 3, 2026
3 checks passed
@rahilp
rahilp deleted the feat/327-hooks-hardening branch September 3, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant