Repository navigation
v3.64.0 - Guards That Notice Their Own Blind Spots
Release v3.64.0 - Guards That Notice Their Own Blind Spots
Release Date: 2026-09-14
Type: Minor Release
Summary
This release is a dogfooding-driven hardening pass. Four separate
false-positive/false-negative classes in the command-judging guards are
closed — a heredoc or -m/-F message VALUE read as a command, a
multi-line command convicted past its own end, a backslash-newline
word-split that evaded every guard, and a malformed or unreadable config
silently taking an event down. Alongside that, the daemon gains several new
"notices its own blind spot" checks: a second session upgrading its own
installed daemon, a git pull that left config or handler code unloaded, a
reference clone read without pulling first, and a QA/acceptance run graded
by a daemon that predates the working tree it is judging. Three niggles
ledgers (Plans 00377, 00405, 00407) and one release-review follow-up ledger
(Plan 00364) are worked to zero. It ships as MINOR rather than PATCH because
it adds eleven new handlers, a new reference_repos config block, and
several new CLI verbs (status-line-explained, check-source-fresh,
reference-repos, issue-report, deploy-core-docs) — see Changes below.
One breaking change: plan-qa --json renames level to severity,
carried by a mandatory post-upgrade task — see Breaking Changes below.
Highlights
Everyone
- A wait loop that watches a wrapper's
$!is now caught. When a Bash
command backgrounds a job behindsetsid,nohup sh -c,timeoutor
envand then asks about$!, that pid can belong to the wrapper, not
the job —setsidin particular forks and its parent exits at once, so
kill -0 $!reports the job finished while it is still running, which is
exactly how a real incident lost its first waiter. The message names three
remedies: a pidfile the job writes itself,pgrep -P <wrapper-pid>, or
waiting on a log marker. The two verdicts differ, so it is worth knowing
which you will get:setsidis denied, because its parent exits
immediately and the wait is guaranteed wrong;nohup sh -c,timeoutand
envare advisory, because those may or may not fork and the pid may
well be the job's. repairno longer blames a missinguvfor a lock failure.
hooks-daemon repairanswered everyFileNotFoundErrorwith "install
uv", including one raised by the venv build lock contending with another
daemon or repair — that gap now reads as "the lock just became free"
instead, and the "install uv" message is scoped to the spawn that can
actually mean it.- A failing git listing no longer reads as a clean slate.
release-slate-checkused to treat a brokengit worktree listor
git for-each-refas "no branches ahead, no live worktrees" — a failed
read is now carried into the report asSlate: UNDETERMINED, exiting1
rather than0;--acceptdoes not rescue it. - Private lint output directories, and a hook that no longer stalls on a
wide character range. The Kotlin and Rust lint strategies used to write
compiler output to a fixed, guessable shared-temp path — a symlink-redirect
risk on a multi-user host — and now write to a private, unguessable
per-process directory.secret_file_guard's expansion cap on a bracket
character range now applies before the range is built rather than after,
cutting a 13-second cost for a wide literal range on a PreToolUse hot path. - The plan QA commit gate stops paying per plan folder. A
git commit
used to ask git what was staged once per plan folder — 363 subprocesses
for one commit in this repository — and now asks once. Alongside it: a
non-UTF8PLAN.mdno longer raises out of the commit gate,settings.json
is written through a temp file and renamed into place, andtransport verifynames a down daemon instead of reporting the failure as a broken
exit-code translation. - A worktree's hooks now reach the worktree's own daemon. A deployed
relay forwarder bakes absolute paths, so a linked worktree — which
inherits.claude/hooks/*byte for byte — used to dial the MAIN
checkout's socket and be judged by the main checkout's config and
handlers, with no error anywhere saying so. - The QA audits no longer pass by scanning nothing. Two audit scripts
matched their exclusion patterns against each file's ABSOLUTE path, so a
checkout merely living under a matching directory name — a git worktree
underuntracked/worktrees/<branch>/— excluded itself entirely and
reported a clean scan having looked at nothing. Both now fail loudly when
they collect zero candidate files, instead of reporting silence as safety. - A declared project wins at any depth, and editing an existing
.mdis
never a location violation.markdown_organizationdenied an edit of an
existing.mdfile inside a first-party clone installed twovendor/
levels deep, with no escape hatch that rescued it. Aprojects:
declaration is now consulted before the built-in inference, and a.md
file that already exists is no longer treated as a NEW-file location
violation. setup_worktree.shnow runs to the end, and its venv can run QA. Its
editable-install check used to crash the script silently right after
"Venv created" (a uv-managed venv ships nopip), and the venv it built
had nodevextra, soimport pytestfailed in every fresh worktree.
Both are fixed.- A daemon restart in a git worktree no longer regenerates and commits
CLAUDE.mdon the worktree's own branch, closing a source of conflicts
between a worktree's branch and main on every merge back. - The upgrade route now works from a fresh clone of a client repository.
A teammate cloning an existing client repo gets its committed config but
no gitignored.claude/hooks-daemon/checkout or venv — a state the
documented upgrade route used to refuse outright.scripts/upgrade.shnow
clones the daemon fresh, reads the previous version from the committed
.claude/HOOKS-DAEMON.md, and skips the daemon-stop step when there is
nothing to stop. - Hooks now reach a daemon whose socket fell back to the runtime
directory, and an upgrade always serves the project it was given. When a
project's path is too long for the AF_UNIX socket limit, the daemon
relocates its socket and PID file — but the hook launcher only followed
the socket there, so every hook reporteddaemon_startup_failedwhile
statusshowed the daemon RUNNING. Install and upgrade also now anchor
to the project root they were given instead of the shell's current
directory. - The post-upgrade reports are bounded, offloaded to files, and chunked
for subagents. A canary upgrade across the full v3 span delivered 143 KB
of combined stdout fromcheck-truth-changesand
check-config-migrations, of which roughly sixty of seventy-three
truth-change entries never reached the agent past Claude Code's exit-1
truncation — silently. Both commands now write their full report to a
file and print a bounded summary;check-truth-changesalso writes one
self-contained chunk file per topic, and the upgrade skill dispatches one
subagent per chunk in parallel. lsp_noise_checker, a new SessionStart advisory. Claude Code injects
a language server's diagnostics after every edit, and a stream carrying
noise from trees that are not project code trains the reader to skim it.
One check per supported language present (Python, TypeScript/JavaScript,
Go, Rust, PHP) now reports a server that isn't told to exclude the
daemon's runtime directory, the plan directory, vendored/build trees and
the remote-docs tree — with that language's exact fix — plus a companion
check for a language server that started before its config was last
written.pyrightis also now a QA gate in this repository, driving the
count of real, unsuppressed diagnostics to zero.hooks-daemon status-line-explainedexplains every status-line icon.
Answers "what does this icon mean, and what does its current value mean?"
for every status-line segment in a project, closing a gap
explain-rule/explain-handlercould not reach, since a status-line
segment is advisory-only and declares no blocking rule.- A stale daemon can no longer pass or fail QA silently. The acceptance
harness dispatches through the live daemon socket, so a daemon that
predates the working tree it is grading used to produce both a false
failure (denying correct code because the daemon still ran the old,
broken version) and the more dangerous false pass (a genuinely broken
tree sailing through because a stale daemon still answered with the old,
correct code). The daemon now reports a content fingerprint of the code
it loaded at startup, and every socket-dispatched acceptance test compares
it before running any probe-specific assertion —hooks-daemon check-source-freshruns the same check on demand. QA stays read-only;
nothing restarts the daemon automatically. - The bracket-trick rewrite in
self_matching_process_probenow names the
actual offending text. A probe followed by an unescaped fallback message
(|| echo "no <name>") could still self-match on the fallback text even
after the probe itself was bracket-tricked; the deny message now names the
exact text still responsible instead of silently repeating advice already
applied. - A fully completed plan is closed, and both human-approval gates are now
real, opt-in keys. The deployed workflow document used to tell every
agent to "ask user for approval before marking plan complete" with
nothing in the daemon enforcing it — either work stalled on a human who
never asked for the gate, or the instruction was ignored. It now says what
the daemon does: a fully completed plan is closed by the agent that
completed it, unlessplan_workflow.close_requires_human_approvalis turned
on, in which caseplan_close_approvaldenies the status FLIP and names
hooks-daemon approve-plan-close NNNNN. Its sibling gate is a different
thing on a different trigger, and the two are easy to conflate:
worktree.merge_to_main_requires_human_approvalturns on
merge_to_main_approval, which denies agit merge/gh pr mergeinto
the default branch run from the MAIN checkout and names
hooks-daemon approve-merge <branch>. It never fires on aPLAN.mdedit,
so turning it on does not gate plan closes. A new docs-QA check blocks any
future daemon-owned document from prescribing an unenforced approval gate. plan-qa --sweepanddocs-qa --sweepare now registered QA tools, and
a merge no longer lands drift unremarked. Both sweeps always exited
non-zero on findings but were never wired into the QA gate, CI or the
release-slate check — a merge once resurrected an archived plan folder
and its findings survived a fully green run of all three. Any finding at
either severity now fails QA. A newmerge_qa_reportadvisory also runs
aftergit merge/pull/rebaseand reports only what that operation
actually introduced, since none of the commit-time gates ever see those
three commands.- A plan-folder-scoped glob on the next line is no longer read as a
discovery scan, and a\<newline>line continuation is now recognised.
Listing the files of ONE named plan folder — whose number is already in
the path — used to be refused because anechoon one line could reach
across the newline and borrow a glob character from an unrelated next
line. - A word split across two lines no longer evades every blocking guard
(security fix). The shell REMOVES a backslash-newline line continuation
and joins the two halves into one word —git pu\+newline+sh --force
really does rungit push --force— but the daemon substituted a space
instead, splitting the word into two tokens that matched nothing. Every
guard reading a Bash command through the shared command reader, including
destructive_git, was evadable this way. - An unreadable or malformed config no longer takes the whole event
down. Secret redaction's word-list resolver was documented as never
raising, but missed pydantic'sValidationErrorandyaml.YAMLError;
a config with a syntax error, one unrecognised key, or a legacy/newer
spelling could fail the tool call being routed. Malformed config is now a
reported configuration error, and an unusable config makes redaction
inert (logged, not silent) rather than crashing the event. - A newline now ends the command being judged, across four blocking
handlers. Their "stays inside this sub-command" character class listed
;,&and|but not the newline, so a pattern could run past the end
of its own line and convict the next one — a force-push deny on a later
line's unrelatedgrep -f, a force-branch-delete deny on a safe
git branch -d, a squash-merge deny on the word--squashinside an
echo, and acd-into-daemon-directory deny on the very command its own
message recommends running instead. Joining the two lines with&&
always reversed the verdict, which was the tell. - Four guards corrected by the release review. A
git mergenamed only
in a commit message body is no longer read bymerge_to_main_approvalas
a real merge; acdnamed in prose inside a quoted heredoc is no longer
read bydaemon_location_guardas a real directory change;
deployed_artefact_driftnow reads the project's configured plan
directory instead of a hardcoded default; and (security)
issue_filing_gatenow coversgh's default repository resolution, not
only an explicit--repo/GH_REPO.
Operators
- BREAKING:
plan-qa --jsonrenamesleveltoseverity. See
"⚠️ BREAKING CHANGES" below — this release carries a mandatory
post-upgrade task for anyone whose tooling parses that verb's JSON output. - An archival that forgets the age-out is now blocked at commit. The
main plan index keeps only the 30 highest-numbered completed rows, ageing
older ones intoCompleted/README.mdin the same commit as the archival
that displaces them — until now nothing enforced that, and three
consecutive archival commits in this repository skipped it silently. A new
plan-QA check,index-retention-window, now runs at the commit gate and
in the sweep. - The ccy supervisor follows up a line it typed but did not submit. When
its own Enter fails to submit an injected/goal,continueor
/effortline — which left one/goalsitting unsubmitted for eight
hours in a real session — it now remembers the line as its own and presses
Enter for it (at most twice, 15 seconds apart) at the next lull. The
goal-injection cap is also now a rolling one-hour budget rather than five
per process lifetime. hooks-daemon worktree-reapno longer risks reaping a worktree that
just started, and its branch delete now actually works. A worktree
created moments ago for an actively-working agent looked identical to a
finished, stale one and was listed as safe to reap; it is now also
checked for a live process and an under-15-minutes age. Separately, the
reap-success branch delete passed git a ref shape it rejects outright, so
every reaped worktree kept its branch behind — now fixed.bin/hooks-daemon's project anchor now actually reaches the command.
Running the wrapper by absolute path from another directory — a worktree's
wrapper invoked from the main checkout, say — used to silently act on the
WRONG project because argparse's own subparser default overwrote the
anchor; now the wrapper's own project always wins unless you supply
--project-rootyourself.- A drifted daemon-owned file can now actually be repaired, and a new
advisory tells you when one has.agents install <name> --forceis the
explicit escape when a deployed agent no longer matches any shipped
revision (previously the daemon refused and recommended the very command
that had just refused), anddeploy-core-docsrefreshes
CLAUDE/core/*.core.mdoutside a full upgrade. A new SessionStart
advisory compares every deployed daemon-owned file against its template
and names the repair command, staying silent when nothing has drifted. - A commit message describing
--forceis no longer read as a force
push (security fix).destructive_gitjudged raw command text, so a
heredoc BODY or an-m/-Fmessage VALUE that merely documented a flag —
including the commongit commit -F - <<'EOF' && git push origin main
idiom — could be denied as the command it described. Inert spans are now
blanked before the command is judged, across every rule the handler
enforces. - Agent deployments wrongly frozen as "customised" upgrade again. Four
shipped revisions across the built-in agents were never recorded in the
ledger that tells a genuine local edit apart from a merely-outdated file,
so a pristine file installed during one of those windows was permanently
refused every upgrade with a message blaming an edit that was never made.
Nothing to do — those files start upgrading again. worktree-reapstops refusing worktrees over files its own gitignore
update cannot see.git statusruns inside each worktree against that
worktree's PINNED.gitignore, so a generated path ignored only after
the worktree was created read as unsaved work forever. Only untracked
paths are ever dropped this way — a modified tracked file still refuses,
regardless of any ignore rule. The refusal message was rewritten too, and
that is the half an operator with stale worktrees wants today: it used to
say "remove it by hand" and "N need a human", which was simply untrue —
removal was never blocked — and it caused five stale worktrees in this
repository to be handed back to a human again and again. It now says
removal is not blocked and names the command.- A branch name containing
-f-is no longer read as a force push, and
clustered short flags now are (security fix, reported as issue #37).
git push origin feature/lane-f-adoptionused to be denied with no force
flag anywhere in it; force markers must now START a whitespace-delimited
argument. The more serious half of the same investigation:git push -uf origin main(and-fu,-nf) were never blocked in any prior release,
because the literal-fnever appears in a short-option cluster — that is
now caught too. transport on/offnow establish the deployed state they report,
instead of trusting the config alone. A config match used to answer
"already<state>— nothing to do" without ever checking the deployed
forwarders, so a drifted deployment could report success while every hook
kept routing through the wrong transport. A drifted state is now
regenerated, the daemon restarted, and the result verified — a converged
project still pays nothing. If you script the toggle, note the exit
code can now be non-zero where it was always 0.- Declare crons your project always wants, and have them re-asserted
every session. Claude Code crons cannot persist across sessions or past
seven days, full stop — a newpersistent_cronsconfig block lets a
project declare the jobs it wants, and a new SessionStart handler
re-states the declaration every session so they can be re-created. Off by
default; states what was declared, never what is missing. - A pull that leaves the daemon stale now says so. The daemon imports
its config and handler code once, at startup, and never hot-reloads — a
git pullbringing in a new blocking handler or a retuned priority left
the project believing it was protected while nothing new had actually
loaded, and nothing said so. A new advisory reports exactly which paths a
merge/pull/rebasechanged and names the restart command; it also
catches the tracked.claude/HOOKS-DAEMON.mdversion marker moving out
of step with the gitignored installed clone. - A stale
.claude/hooks-daemon/clone now says which version it is. A
leftover old clone meeting newer tracked config used to fail every hook
for the whole session withdaemon_startup_failed, pointing atlogsand
restart— neither of which fixes a version mismatch. Startup now names
both versions and the exact upgrade or regenerate command, andupgrade
computes an honest version range from the tracked assets rather than the
stale clone. - A gate you switched off is no longer announced as your
CLAUDE.md's
active policy. A handler that is loaded and enabled but inert because
its own config key is at its default used to be listed in the generated
instructions as enforced fact — indistinguishable to an agent from a
project that really did require it, and two finished plans in this
repository sat open for days waiting on an approval that was never
needed. Handlers can now report themselves dormant and are left out of
the generated document entirely.
Handler Authors
- The
optimisereport is scored by the registry's own gates, and a
broken handler no longer aborts the whole verb./hooks-daemon optimise
used to score enabled/disabled with its own incomplete copy of
registration's rules; both now share one predicate. A handler whose
constructor raises now appears as "could not be assessed" instead of
taking down the report for every other handler. - An acceptance test's contradiction error now names the field you
declared, instead of a field the derivation step had already populated
behind the scenes. - The config package has one validator. A second, never-run,
hand-maintained JSON-schema validator that only understood 3 of the
daemon's 31 wired event types is gone;ConfigValidator,HandlersConfig
andPluginConfig.event_typeall derive their coverage from the same
wired-event catalogue now, pinned by a test.
Client Projects
daemon_restart_verifierbecomes a project handler — no config change
needed. It only ever fired inside the hooks-daemon repository itself, so
it never did anything for a client project; it is now dogfooded as a
project-level handler in this repository's own
.claude/project-handlers/. A leftover config key validates cleanly and
is simply ignored.- A running daemon now notices its own upgrade. When a different
session or process upgrades this project's installed daemon, the one
already running kept serving the old code with nothing saying so. A new
advisory compares the in-memory version against what is actually
installed on every turn and names both versions plus the restart command
when they differ — silent otherwise, and dormant in a self-install
checkout. - A reference clone is fresh before you read it. Tracking read-only
clones of upstream repos underuntracked/repos/is a convention this
project shares with its siblings; a newreference_reposconfig block
and paired handlers fetch and fast-forward what is provably safe at
session start, then deny a READ of a clone that is stale or off its
default branch at PreToolUse time — with no network I/O on that hot path.
A project with no such directory sees nothing at all. - One unsecurable per-event socket no longer costs them all. Only
reaches a project running the per-event transport. A single failed
chmodwhile securing one of the daemon's 31 per-event sockets used to
propagate out ofstart()and fail daemon startup entirely; it is now
covered by the same best-effort guard binding already had. - An upstream issue body now comes from a generator, not a hand-written
report (security governance). Agh issue createagainst the
hooks-daemon repository's own PUBLIC tracker is now denied unless
--body-filenames a documenthooks-daemon issue-reportproduced — the
generator collects a controlled field set and never gathers a hostname,
git remote,.env, config dump or logs at all.BUG_REPORTING.mdis now
the whole procedure end to end.
⚠️ BREAKING CHANGES
Configuration Changes
plan-qa --jsonfinding key — Renamedlevel→severityin
v3.64.0- Why:
docs-qa --jsonandplan-qa --jsondescribed the same
finding shape under two different field names. That is not cosmetic —
it already produced a live defect: a reader keyed on one name silently
drops every finding from the other verb while still counting it toward
the total, which is how a QA wrapper once printed3 findings (0 block, 0 advise), a summary contradicting itself.plan-qa --jsonnow emits
severityand nothing else, matchingdocs-qa --json. Shipped with no
deprecation window, deliberately: emitting both spellings for a release
would make the two-names defect itself correct by policy for that
release, and would not avoid the break either — it would only defer it. - Migration: Any tooling that parses
plan-qa --jsonand reads
finding["level"](or.get("level"),.level,jq '.findings[].level')
should readseverityinstead. The values are unchanged — this is a
key rename and nothing more. See the mandatory post-upgrade task below
for the full detection and migration procedure. - Guide:
v3.63.0-to-v3.64.0 Upgrade Guide
- Why:
No handler was removed from behaviour available to a client project.
daemon_restart_verifier is removed as a built-in handler (see Removed in
the Changes below), but it never fired outside this repository's own
checkout, so no client config loses any enforcement — a leftover config key
validates cleanly and is ignored.
Changes
See CHANGELOG.md for the full,
categorised list of changes in this release.
Post-Upgrade Tasks
CLAUDE/UPGRADES/v3/v3.63.0-to-v3.64.0/post-upgrade-tasks/ has one
critical task: rewriting any call site that parses plan-qa --json and
reads the level key to read severity instead (see Breaking Changes
above). It applies to any project whose tooling parses that verb's JSON
output; a project that never parses it is unaffected. Read the task file
for the detection command and the full migration procedure — an agent
upgrading this project should treat it as blocking the next step until
acknowledged.
Installation
New Installations
mkdir -p .claude
git clone -b v3.64.0 https://github.com/Edmonds-Commerce-Limited/claude-code-hooks-daemon.git .claude/hooks-daemon
# The installer builds the fingerprint-keyed venv, installs the package and
# starts the daemon.
bash .claude/hooks-daemon/scripts/install_version.sh "$PWD" "$PWD/.claude/hooks-daemon"Upgrading Existing Installations
cp .claude/hooks-daemon.yaml .claude/hooks-daemon.yaml.backup
git -C .claude/hooks-daemon fetch --tags
bash .claude/hooks-daemon/scripts/upgrade.sh --project-root "$PWD" "v3.64.0"
.claude/hooks-daemon/bin/hooks-daemon restartUse upgrade.sh (Layer 1), not upgrade_version.sh (Layer 2) directly —
Layer 1 is the documented entry point; invoking Layer 2 on its own skips
steps Layer 1 is responsible for sequencing.
After upgrading, read the Post-Upgrade Tasks section above — this
release ships one critical task.
Testing
- Tests: 23,370 passed, 0 failed, 8 skipped — measured by the full
./scripts/qa/llm_qa.py allgate (Step 8 of
CLAUDE/development/RELEASING.md), which reported 30/30 checks passed
against a freshly restarted daemon. Coverage 95.4%. - Known-behaviour gaps: three tests are pinned as strict
xfail, each
naming the plan that will close it — a quoted destructive operand and
pushdevadingR-DAEMON-DIR-CD(both Plan 00408), and a markdown heredoc
mentioningsed(Plan 00260). None is a regression: each was verified
against the v3.63.0 tree and behaves identically there. Two of the three
were found by this release's own review rather than by a user. - Type Safety: MyPy strict mode compliant
- Security: Bandit scan clean
- QA gate:
./scripts/qa/llm_qa.py all— to be run at the QA gate after
this document was drafted, with the daemon restarted on the bumped source - Acceptance: MINOR bump — full acceptance suite per
CLAUDE/development/RELEASING.mdStep 12.
Release Review
This release closes three niggles ledgers (Plans 00377, 00405, 00407) and
one release-review follow-up ledger from v3.63.0 (Plan 00364), alongside the
substantive new work detailed in the Highlights above and the full CHANGELOG
entry.
Contributors
- joseph
- Claude (Co-Authored-By across this release's commits)
Full Changelog
Compare: v3.63.0...v3.64.0