v4.14.1
v4.14.1 — --wire-hooks did not wire enforcement on an occupied event
Full entry: CHANGELOG.md § [4.14.1]. This file records release evidence and the upgrade note.
CORRECTION TO v4.14.0. That release told exposed consumers to run
bash hooks/local/post-fusebase-update.sh --wire-hooks. If your.claude/settings.jsonalready
had aPreToolUsearray of your own, that command did not wire Flow's enforcement handler — it
exited 0, reported "applied 7 changes", and recorded an intent marker saying you had opted in.Re-run it on this release, then confirm:
bash hooks/local/post-fusebase-update.sh --wire-hooks grep -c 'hooks/handlers/pre_tool_use.py' .claude/settings.json # must be >= 1Published tags are immutable, so v4.14.0's note cannot be rewritten. This correction lives here.
The defect
settings-json-merge.py's add branch was a wholesale replace, correctly guarded to fire only when
there was nothing to preserve:
if event not in hooks or not hooks[event]:
hooks[event] = [make_event_block(event)]When the array was non-empty it fell through to two functions that both skip any block not already
naming a Flow handler — so a Flow block that was absent was never added. Stop was the only
event with an add-beside path. Measured: all five non-Stop events had the gap.
| Event | Flow handler after --wire-hooks on a consumer-only array (pre-fix) |
|---|---|
| SessionStart · UserPromptSubmit · PreToolUse · PostToolUse · PreCompact | 0 — not wired |
| Stop | 1 — the only event with an add-beside path |
Why this was worse than a missing feature
post-fusebase-update.sh recorded the wiring-intent marker on that exit 0. So the tree recorded
intent while enforcement was absent — and the health arm added in v4.12.0 then correctly reported
Flow PreToolUse ENFORCEMENT STRIPPED and prescribed --wire-hooks, the command that had just
produced the state. A loop that could not converge.
The code already guarded against a near version of this: a comment at the recording site states
intent is written only on a merge that "actually succeeded… so a failed or aborted merge never leaves
an intent the health check would then report as stripped enforcement." That protection is real and it
did not cover this case, because the merge did succeed. It applied seven other changes. Success
was measured as exit 0, not as achieved the thing.
The fix
- Add beside, never replace. When an event array is non-empty but contains no Flow block, Flow's block is appended alongside the consumer's. No drop, no reorder, no overwrite — the guarantee
_widen_matchersalready documented survives. - Intent keys on the achieved state. The marker is written only when the canonical handler is verifiably present in the merged chain, not when the merge exited 0. The predicate is
ffhc_hwi_wired(0 wired / 1 absent / 2 no settings file).
Flow's block is appended as a separate block, not merged into the consumer's chain. Joining their
chain would inherit their matcher — measured as Bash|Edit|Write on a real consumer tree — which
would re-open the v4.14.0 PowerShell bypass on every such install. Multi-block-per-event is documented
host behaviour and is already the shape the "correctly wired" control case treats as valid, though
Flow's own example ships one block per event. A certain security regression avoided was preferred over
an inferred host behaviour relied upon.
Evidence
hooks/tests/test-wire-hooks-add-beside.sh — 35 rows, tag wire-hooks-beside.
| Pre-fix | Post-fix | |
|---|---|---|
| Phase | 20/35 PASS, 15 FAIL | 35/35 PASS |
Controls: key-absent, [], consumer+flow |
PASS ×5 events | PASS |
| Consumer-only array | FAIL ×5 | PASS ×5 |
Convergence — health arm after a successful --wire-hooks |
FAIL — ENFORCEMENT STRIPPED |
PASS |
| Unachieved wiring records no marker | FAIL — marker written with handler absent | PASS |
The three control rows passing before the fix is the point: only the consumer-only row
discriminates. Regression: 227/227 across every phase touching these files.
Also in this release
The E2–E5 consumer-proposal close-out: three documentation additions and five backlog filings.
policies/approval-policy.ymlnow states that a VALID artifact authorizes its action repeatedly until expiry — it is not single-use. A reader could previously infer single-use from the TTL prose. The one real narrowing is recorded too: a digest-boundprotected_path_editis single-changeset, not single-use.hooks/README.mdgains a consumer-authored-hooks section: hooks in a group run in parallel, deny takes precedence, and a hook that cannot start or exceeds its timeout is non-blocking — the host fails open. A consumer security hook must bound its runtime well below its timeout and convert every local failure to a deny. Provenance is labelled: read from the Claude Code hooks reference by the consumer who filed it, not measured by Flow.workflows/violation-recovery.mddocuments the FR-07 first-edit deadlock: the tool-time arm is defence-in-depth for already-staged content, the commit gate is the boundary, and the sanctioned path is stage → mint → commit →--consume.
Known residual — reported, not fixed
post-fusebase-update.sh Step 5b reports "(re)installed Flow git fallback hooks" from an exit code
plus the absence of a string, without asserting the hook file exists, is executable, or is Flow's —
the same exit-code-not-outcome shape. Lower blast radius: a transient report line, not a durable
marker a later check reads back as an accusation, and no non-convergent loop. Filed separately.