v4.14.0
v4.14.0 — the command gate could be bypassed by choosing another shell tool
Full entry: CHANGELOG.md § [4.14.0]. This file records release evidence and the upgrade note.
Security fix. Reported by the paperclip+hermes-v1 consumer as escalation E6.
Existing installs are still exposed until you re-merge. Run
bash hooks/local/post-fusebase-update.sh --wire-hooks. Upgrading the tree alone does not
update your.claude/settings.jsonmatcher.
Read this first — what is and is not fixed
This release makes PowerShell reach the gate. It does not make the rules PowerShell-shaped.
| Gated now | git reset --hard, git push --force, --no-verify, npx prisma migrate deploy, fusebase deploy — anything a shell-agnostic pattern already matched |
| Still open | Remove-Item -Recurse -Force, ri -r -fo, … | Remove-Item -Recurse |
| Degraded, not denied | rm -Recurse -Force matches destructive_file_delete (approval) rather than the FR-06 hard deny |
Writing PowerShell cmdlet patterns is rule authoring, blocked on decisions D1/D2 in
docs/backlog/command-gate-shell-evasion/. That gap was hypothetical while the tool was unreachable
by the gate. It is reachable now, and the backlog entry records that change of status.
Measured coverage lives in docs/hook-coverage.md so "PowerShell is gated" cannot be over-read.
The defect
On Windows, Claude Code exposes a PowerShell tool beside Bash. Flow's command gate never saw it:
hooks/handlers/pre_tool_use.py—BASH_LIKE_TOOLS = {"Bash", "Shell", "Terminal", "ExecuteCommand"}, no PowerShell..claude/settings.json.example— matcherBash|Edit|Write|MultiEdit|NotebookEdit, so the event never reached the handler at all.
Every FR-06 hard deny, every FR-12 require_approval, and the K4 policy-error fail-closed was
bypassable by choosing the other shell tool. Protected-path and secret arms were unaffected — they
key on Edit/Write.
A second, narrower bypass was found during the fix. hooks/handlers/permission_request.py
carried its own inline set — {"bash", "shell", "terminal"} — missing PowerShell and
ExecuteCommand. Its fall-through is ask, so an FR-06 hard deny was silently degrading to an
operator prompt rather than to allow. Measured: ExecuteCommand + npx prisma migrate deploy →
ask before, deny/FR-12 after.
Concrete exposure, measured on the maintainer host across ~400 transcripts: 1,104 PowerShell
tool calls, carrying production-touching commands such as
fusebase isolated-store sql bundle --app apps/admin-area --env prod. Not hypothetical traffic.
The fix
- One shared set.
hooks/shared/command_policy.pygainsCOMMAND_TOOL_NAMES+is_command_tool(); both handlers import it. The two divergent copies are gone — divergence was the second bypass. - All matcher carriers named.
.claude/settings.json.example,.codex/hooks.json.example,DEFAULT_EVENT_MATCHERS, and the upstream-discovery path that reads the example file. - Re-merge widens an existing matcher. New
_widen_matchers()unions Flow's required tokens into an already-installed matcher instead of only writing on fresh installs. It touches only the block whose chain names ahooks/handlers/command, so consumer tools and foreign hook blocks survive. Idempotent. - Three RED-first fixtures freeze the tool set: two
pre_tool_use, onepermission_request. Driven through a worktree at the pre-fix commit — all threeallow/askbefore, denied after. Real discriminators, not outcome-only assertions.
Upgrade note
| Situation | What happens |
|---|---|
| Fresh install | Gets the widened matcher automatically |
Existing install, runs --wire-hooks |
Matcher widened in place; foreign blocks untouched |
| Existing install, does not re-wire | Stays exposed. Nothing forces the re-merge |
Hand-written regex matcher (^(Bash|Edit)$) |
Never rewritten — a naive split would corrupt it. The merge prints a warning naming the matcher; edit it by hand |
* or absent matcher |
Left alone; already matches every tool |
Coverage beyond this host
PowerShell is the only command-execution carrier besides Bash observable here. No Shell,
Terminal, ExecuteCommand, pwsh or Cmd appeared in live traffic — those names remain in the
set as defence in depth.
Tool names on hosts that cannot be observed from here — Cursor (run_terminal_cmd), Cline/Roo
(execute_command), Codex (local_shell) — were deliberately not added speculatively. Membership
is now case-insensitive and lives in one line of one file, with a tripwire recording that widening is
cheap and omission is the hole.
Release evidence
| Item | Value |
|---|---|
| Tag verification | the v4.14.0 run is the authoritative gate; a local run is never release evidence |
| Pre-tag verification | 8d5f99e green on both platforms |
| Suite | 166/166; handler fixtures 26/26 with subprocess parity |
Known residual — not fixed here
The EOL guard's printed remedy does not work. stamp-hook-manifest.sh correctly refuses on CRLF in
an eol=lf file and advises git add --renormalize -- <files>, but when the index is already LF that
is a no-op and the guard refuses again identically. A maintainer following the advice loops. The
working repair is rewriting the worktree from the index blob. Filed separately.