crewforth 0.0.1 → 3.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +3805 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1739 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +639 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/skill-listing.awk +43 -0
  29. package/kit/eval/lib/star.sh +70 -0
  30. package/kit/eval/parser-conformance.sh +612 -0
  31. package/kit/eval/preflight.sh +186 -0
  32. package/kit/eval/routing-eval.sh +209 -0
  33. package/kit/eval/scan-skill.sh +162 -0
  34. package/kit/eval/smoke-test.sh +6736 -0
  35. package/kit/eval/update-guard.sh +81 -0
  36. package/kit/eval/utilization.sh +130 -0
  37. package/kit/hooks/board-sync.sh +106 -0
  38. package/kit/hooks/board.sh +962 -0
  39. package/kit/hooks/commit-msg +49 -0
  40. package/kit/hooks/context-usage.sh +353 -0
  41. package/kit/hooks/floor-blocklist.txt +124 -0
  42. package/kit/hooks/guard-bash.sh +1433 -0
  43. package/kit/hooks/guard-commit-scan.sh +511 -0
  44. package/kit/hooks/guard-write.sh +513 -0
  45. package/kit/hooks/pre-commit +577 -0
  46. package/kit/hooks/route-hint.sh +185 -0
  47. package/kit/hooks/secret-blocklist.txt +82 -0
  48. package/kit/hooks/session-guard.sh +149 -0
  49. package/kit/hooks/session-rehydrate.sh +41 -0
  50. package/kit/hooks/session-stats.sh +252 -0
  51. package/kit/hooks/session-update-check.sh +236 -0
  52. package/kit/hooks/skill-trust.sh +163 -0
  53. package/kit/hooks/trace-blocklist.txt +53 -0
  54. package/kit/settings.json +132 -0
  55. package/kit/skills/a11y/SKILL.md +44 -0
  56. package/kit/skills/adr/SKILL.md +74 -0
  57. package/kit/skills/api-design/SKILL.md +50 -0
  58. package/kit/skills/automode-policy/SKILL.md +91 -0
  59. package/kit/skills/automode-policy/references/policy.json +13 -0
  60. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  61. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  62. package/kit/skills/backend-architecture/SKILL.md +103 -0
  63. package/kit/skills/brainstorm/SKILL.md +47 -0
  64. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  65. package/kit/skills/commit-message/SKILL.md +82 -0
  66. package/kit/skills/commit-message/references/examples.md +8 -0
  67. package/kit/skills/confidence-check/SKILL.md +86 -0
  68. package/kit/skills/crew-board/SKILL.md +55 -0
  69. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  70. package/kit/skills/crew-code-review/SKILL.md +151 -0
  71. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  72. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  73. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  74. package/kit/skills/crew-doctor/SKILL.md +46 -0
  75. package/kit/skills/crew-gates/SKILL.md +25 -0
  76. package/kit/skills/crew-handoff/SKILL.md +14 -0
  77. package/kit/skills/crew-plan/SKILL.md +18 -0
  78. package/kit/skills/crew-review/SKILL.md +26 -0
  79. package/kit/skills/crew-ship/SKILL.md +19 -0
  80. package/kit/skills/crew-skill/SKILL.md +62 -0
  81. package/kit/skills/crew-studio/SKILL.md +129 -0
  82. package/kit/skills/crew-update/SKILL.md +43 -0
  83. package/kit/skills/db-migration/SKILL.md +120 -0
  84. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  85. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  86. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  87. package/kit/skills/dependency-audit/SKILL.md +61 -0
  88. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  89. package/kit/skills/deploy/SKILL.md +100 -0
  90. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  91. package/kit/skills/deploy/references/rollback.md +18 -0
  92. package/kit/skills/deploy/references/scripts.md +9 -0
  93. package/kit/skills/deploy/references/topology-a.md +128 -0
  94. package/kit/skills/deploy/references/topology-b.md +44 -0
  95. package/kit/skills/docs-writer/SKILL.md +53 -0
  96. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  97. package/kit/skills/eval-grader/SKILL.md +75 -0
  98. package/kit/skills/eval-grader/references/method.md +51 -0
  99. package/kit/skills/frontend/SKILL.md +56 -0
  100. package/kit/skills/frontend/references/verify-contract.md +41 -0
  101. package/kit/skills/frontend-design/SKILL.md +71 -0
  102. package/kit/skills/frontend-design/references/design-review.md +34 -0
  103. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  104. package/kit/skills/handoff/SKILL.md +71 -0
  105. package/kit/skills/handoff/references/cold-reader.md +51 -0
  106. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  107. package/kit/skills/incident-runbook/SKILL.md +40 -0
  108. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  109. package/kit/skills/iterate/SKILL.md +40 -0
  110. package/kit/skills/mcp-builder/SKILL.md +54 -0
  111. package/kit/skills/mcp-builder/references/building.md +71 -0
  112. package/kit/skills/observability/SKILL.md +50 -0
  113. package/kit/skills/performance/SKILL.md +38 -0
  114. package/kit/skills/performance/references/bottlenecks.md +10 -0
  115. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  116. package/kit/skills/red-team/SKILL.md +48 -0
  117. package/kit/skills/reflect/SKILL.md +57 -0
  118. package/kit/skills/release/SKILL.md +44 -0
  119. package/kit/skills/security-scan/SKILL.md +99 -0
  120. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  121. package/kit/skills/security-scan/references/fronts.md +99 -0
  122. package/kit/skills/security-scan/references/prompting.md +23 -0
  123. package/kit/skills/security-scan/references/reporting.md +75 -0
  124. package/kit/skills/security-scan/references/verify.md +131 -0
  125. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  126. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  127. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  128. package/kit/skills/spec-planning/SKILL.md +92 -0
  129. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  130. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  131. package/kit/skills/teamboard/SKILL.md +88 -0
  132. package/kit/skills/teamboard/references/setup.md +37 -0
  133. package/kit/skills/testing/SKILL.md +49 -0
  134. package/kit/skills/testing/references/flaky-triage.md +51 -0
  135. package/kit/skills/threat-model/SKILL.md +57 -0
  136. package/kit/skills/threat-model/references/interview.md +51 -0
  137. package/kit/skills/threat-model/references/schema.md +42 -0
  138. package/kit/skills/token-budget/SKILL.md +51 -0
  139. package/kit/skills/trace-scan/SKILL.md +43 -0
  140. package/kit/skills/worktree/SKILL.md +40 -0
  141. package/kit/studio/README.md +266 -0
  142. package/kit/studio/ensure-node.sh +471 -0
  143. package/kit/studio/package.json +14 -0
  144. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  145. package/kit/studio/server/index.js +902 -0
  146. package/kit/studio/server/lib/crew-env.js +11 -0
  147. package/kit/studio/server/lib/fleet.js +146 -0
  148. package/kit/studio/server/lib/graph.js +533 -0
  149. package/kit/studio/server/lib/instance.js +135 -0
  150. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  151. package/kit/studio/server/lib/kit.js +158 -0
  152. package/kit/studio/server/lib/palette.js +118 -0
  153. package/kit/studio/server/lib/peers.js +72 -0
  154. package/kit/studio/server/lib/permissions.js +150 -0
  155. package/kit/studio/server/lib/projects.js +294 -0
  156. package/kit/studio/server/lib/roster.js +191 -0
  157. package/kit/studio/server/lib/session.js +309 -0
  158. package/kit/studio/server/lib/terminal.js +91 -0
  159. package/kit/studio/server/lib/transcript.js +100 -0
  160. package/kit/studio/web/app.js +1098 -0
  161. package/kit/studio/web/canvas.js +1179 -0
  162. package/kit/studio/web/chat.js +543 -0
  163. package/kit/studio/web/index.html +86 -0
  164. package/kit/studio/web/md.js +110 -0
  165. package/kit/studio/web/storage-migrate.js +23 -0
  166. package/kit/studio/web/style.css +1453 -0
  167. package/package.json +46 -8
  168. package/start.sh +764 -0
  169. package/bin/crewforth.js +0 -10
@@ -0,0 +1,1433 @@
1
+ #!/usr/bin/env bash
2
+ # Claude Code PreToolUse (Bash) guard. PreToolUse runs in EVERY permission mode, including bypass.
3
+ # stdin JSON, one line, in the key order captured from Claude Code 2.1.267 (values abridged):
4
+ # {"session_id":…,"transcript_path":…,"cwd":…,"scratchpad_dir":…,"prompt_id":…,
5
+ # "permission_mode":"default|acceptEdits|auto|dontAsk|plan|bypassPermissions","effort":{"level":…},
6
+ # "hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"...","description":"..."},"tool_use_id":…}
7
+ # This line used to show `permission_mode` AFTER `tool_input`. It was never a capture, and it was wrong. The
8
+ # fallback parser below does not depend on the order either way, because the order is not a documented contract.
9
+ # Every PATH value (`cwd`, `transcript_path`, `scratchpad_dir`) arrives in the platform's own spelling, so on
10
+ # Windows it is `D:\Projects\…` and every separator is DOUBLED by JSON escaping. Captured from a live session
11
+ # on a Windows machine; the `cwd` normaliser below is the one place that matters and it undoes the escape first.
12
+ #
13
+ # §4.5 destructive operations -> HARD BLOCK (exit 2). No key, no mode, no escape.
14
+ #
15
+ # §4.4 git commit / git push -> ASK THE USER, IN SESSION. The hook answers with
16
+ # {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask", ...}}
17
+ # and Claude Code escalates to a permission prompt that ONLY the human can answer. Approve once and Claude
18
+ # runs the commit itself — you never have to paste commands into your own terminal. The model cannot
19
+ # self-approve (it never sees the keypress) and cannot forge the decision (this hook is a separate process).
20
+ #
21
+ # Verified on Claude Code 2.1.205: a hook "ask" is honoured — the tool does not run until the user says yes —
22
+ # in permission_mode default, acceptEdits, auto and dontAsk. It is NOT verified under bypassPermissions, so
23
+ # there (and for any mode this hook does not recognise, i.e. anything added in a future release) we FAIL
24
+ # CLOSED and hard-block instead of trusting a prompt that may never reach the user.
25
+ #
26
+ # That gap was carried as "unverified" for a long time, as though a measurement would eventually close it.
27
+ # Checked against the published hooks reference (2026-07-29): the docs describe permissionDecision and they
28
+ # describe the permission modes, but they say NOTHING about how the two interact, or whether hooks run at all
29
+ # under bypassPermissions. So there is no documented contract to rely on — and a security gate resting on
30
+ # observed-but-unspecified behaviour is a bug even while it happens to work, because nothing stops a release
31
+ # from changing it. Failing closed is therefore the correct answer regardless of what a test would show, and
32
+ # this stops being an open question: it is a decision. Should the interaction ever be specified, revisit.
33
+ #
34
+ # The gate log gets one TSV line per logged decision (BLOCK/ASK/ALLOW, section, rule; the command only with
35
+ # CREW_GATE_LOG_CMD=1). On by default since 2.5.0 (see _gatelog_path below); CREW_GATE_LOG=<path> redirects it.
36
+ # Write-only, it never influences a verdict. It exists because a gate that cannot be observed firing cannot be
37
+ # measured: "the model never tried it" and "the gate stopped it" look the same. guard-write.sh logs there too.
38
+ #
39
+ # CLAUDE_GIT_OK=1, exported by the user before the session starts, pre-authorises the session. It exists for
40
+ # headless/CI runs where no one is at the keyboard. It does NOT replace approval: present the message first.
41
+ set -uo pipefail
42
+ # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
43
+ _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
44
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
45
+ INPUT="$(cat)"
46
+
47
+ # Extract the command + the permission mode: jq > python3 > pure-bash JSON slice.
48
+ #
49
+ # THE THIRD TIER IS NOT A DEGRADED MODE, it is the Windows default — though not for the reason written here for
50
+ # a long time. "Git Bash ships neither jq nor python3, so a stock Windows install takes this path" was WRONG in
51
+ # its second half, and being wrong there is what disarmed every gate below: Windows ships a python3 that passes
52
+ # `command -v` and cannot run, so a stock install took TIER 2 and failed open. See the note above the ladder.
53
+ # The conclusion survives the correction — this is still the path a stock Windows install ends on — but it gets
54
+ # there by the tier test failing, not by python3 being absent. It used to read `CMD="$INPUT"` — the
55
+ # whole hook payload handed to the rules as though it were the command — and that is not a weaker gate, it is
56
+ # a WRONG one, in both directions:
57
+ # * False positive, measured: session_id `...-f872-...` (any session id whose second group starts `f8`) contains `-f8`, the §4.5 force-push rule matches
58
+ # `-f([^a-z]|$)`, and so EVERY `git push` was hard-blocked as "push --force" no matter what was typed.
59
+ # A gate that blocks the innocent teaches the user to reach for --no-verify, which disarms all of §4.
60
+ # * Broken approval, measured: when it did not misfire, the §4.4 prompt rendered the raw JSON blob as "the
61
+ # command Claude wants to run". §4.4's entire purpose is to show the human what they are approving; an
62
+ # unreadable prompt is consent theatre.
63
+ # CI never caught it because GitHub's windows-latest image HAS jq preinstalled — the verification ran on a path
64
+ # no Windows user is on. smoke-test §7b pins the fallback branch itself for exactly that reason.
65
+ #
66
+ # The slice is pure parameter expansion: zero forks, so it is CHEAPER than the sed it replaces (Git Bash charges
67
+ # 62-135 ms per process on a Windows 11 desktop, and this hook runs on every Bash call). It must take the FIRST
68
+ # occurrence of the key: greedy matching would let a command containing the literal text `"command":"` relocate
69
+ # the parse and walk a payload straight past the rules. HOW it does that is documented inside _json_slice, next to
70
+ # the code, and only there -- this paragraph named the expansion once, and went stale the day it changed.
71
+ # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
72
+ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
73
+ local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
74
+ # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
75
+ # them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
76
+ # continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
77
+ # quote or backslash edge. What this line is worth in SPEED depends on the platform --
78
+ # read the table in _json_unescape below before quoting a number for it.
79
+ local pre rest seg w r n base=0 j=0 cl lim run=0 chunk C=4096 W=256 hay="$1" k="\"$2\""; local -a acc=("")
80
+ # It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
81
+ # printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
82
+ # caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
83
+ _JS=""
84
+ # Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
85
+ # constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
86
+ # n-byte literal costs O(n^2). It was in this function twice, and both were measured:
87
+ # * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
88
+ # otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
89
+ # Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
90
+ # that version this cost was not reachable -- this file's header had shown the opposite order, and was
91
+ # wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
92
+ # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
93
+ # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
94
+ # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
95
+ # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
96
+ # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
97
+ # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
98
+ # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
99
+ # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
100
+ # {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
101
+ # {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
102
+ # {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
103
+ # In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
104
+ # the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
105
+ # aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
106
+ # and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
107
+ # Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
108
+ # whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
109
+ # _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
110
+ # time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
111
+ # -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
112
+ # chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
113
+ # 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
114
+ # Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
115
+ # bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
116
+ # a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
117
+ # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
118
+ # the battery sees both.
119
+ # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
120
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
121
+ # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
122
+ # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
123
+ # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
124
+ # 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
125
+ # Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
126
+ # real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
127
+ # over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
128
+ # reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
129
+ # THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
130
+ # protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
131
+ # decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
132
+ # roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
133
+ # raising or removing it later is not a tidy-up.
134
+ # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
135
+ # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
136
+ # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
137
+ # predates this change and belongs to the walk, not to the cap.
138
+ local _cap=64 _seen=0
139
+ while :; do
140
+ pre="${hay%%"$k"*}" # everything before the next `"key"`
141
+ [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
142
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
143
+ rest="${hay:${#pre}+${#k}}" # past `"key"`
144
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
145
+ case "$rest" in
146
+ :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
147
+ *) hay="$rest" ;; # a value, or another token: keep looking
148
+ esac
149
+ done
150
+ # THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
151
+ # shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
152
+ # payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
153
+ # refusal fire, which is the honest answer for a value these gates cannot read.
154
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
155
+ case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
156
+ # Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
157
+ n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
158
+ while :; do
159
+ lim=$((cl - j))
160
+ if [ "$lim" -le 0 ]; then
161
+ [ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
162
+ base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
163
+ fi
164
+ [ "$lim" -le "$W" ] || lim=$W
165
+ w="${chunk:j:lim}"
166
+ seg="${w%%\"*}"
167
+ if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
168
+ acc+=("$w"); j=$((j+lim))
169
+ r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
170
+ continue
171
+ fi
172
+ acc+=("$seg"); j=$((j+${#seg}+1))
173
+ r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
174
+ if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
175
+ done
176
+ local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
177
+ }
178
+ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
179
+ # This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
180
+ # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
181
+ # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
182
+ #
183
+ # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
184
+ # default, and reading the default instead is how a first pass at this got the consequence wrong. A
185
+ # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
186
+ # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
187
+ # not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
188
+ # is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
189
+ # §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
190
+ #
191
+ # Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
192
+ # this comment exists to avoid repeating:
193
+ # * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
194
+ # of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
195
+ # * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
196
+ # substring operation. What that is WORTH depends on the platform, and putting a single number here
197
+ # would have been wrong three separate ways -- it was written as "another 5x" twice before this:
198
+ # macOS / bash 3.2, a locale set .................. 5x
199
+ # Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
200
+ # touches the locale once per escape
201
+ # rather than once per character, so its
202
+ # ratio is smaller and unmeasured)
203
+ # Git Bash, LANG empty -- what Claude Code starts .. nothing at all
204
+ # So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
205
+ # below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
206
+ # alone would justify the line, and it costs nothing. `local` restores the previous locale on return
207
+ # -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
208
+ #
209
+ # Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
210
+ # whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
211
+ # command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
212
+ # (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
213
+ # pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
214
+ # while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
215
+ # for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
216
+ # `\uXXXX` that starts in a chunk ends in it.
217
+ # The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
218
+ # written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
219
+ #
220
+ # Measured, isolated, previous shape -> this one:
221
+ # macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
222
+ # sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
223
+ # Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
224
+ # (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
225
+ # These numbers do not travel, which is why each one names its machine.
226
+ #
227
+ # Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
228
+ # down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
229
+ # the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
230
+ # truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
231
+ # not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
232
+ # the gate cases.
233
+ local LC_ALL=C
234
+ local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
235
+ case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
236
+ n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
237
+ while :; do
238
+ lim=$((cl - j))
239
+ if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
240
+ lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
241
+ if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
242
+ else
243
+ [ "$lim" -gt 0 ] || break
244
+ fi
245
+ [ "$lim" -le "$W" ] || lim=$W
246
+ w="${chunk:j:lim}"
247
+ pre="${w%%\\*}" # the literal run before the next backslash in view
248
+ if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
249
+ acc+=("$pre"); j=$((j+${#pre}+1))
250
+ if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
251
+ c="${chunk:j:1}"; j=$((j+1))
252
+ case "$c" in
253
+ n) acc+=($'\n') ;;
254
+ t) acc+=($'\t') ;;
255
+ r) ;;
256
+ b|f) acc+=(" ") ;;
257
+ u) if [ $((n - base - j)) -ge 4 ]; then h="${chunk:j:4}"; j=$((j+4)); else h=""; fi # a short tail is left alone, as before
258
+ # A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
259
+ # so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
260
+ # decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
261
+ # attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
262
+ # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
263
+ case "$h" in
264
+ 00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
265
+ *) acc+=("?") ;;
266
+ esac ;;
267
+ *) acc+=("$c") ;;
268
+ esac
269
+ done
270
+ local IFS=''; printf '%s' "${acc[*]}"
271
+ }
272
+ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
273
+ # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
274
+ # …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
275
+ # every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
276
+ # per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
277
+ # This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
278
+ # added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
279
+ # keeps the count in the caller's own shell: expansion only, zero forks.
280
+ # The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
281
+ # looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
282
+ # tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
283
+ # * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
284
+ # refusal went blind while the parser happily read the first value.
285
+ # * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
286
+ # above now closes; counting the same form here is what keeps the two from drifting apart again.
287
+ # Sharing ONE definition with the parser is the point: a guard that searches for something else than the
288
+ # thing it guards is the defect, not an implementation detail.
289
+ # WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
290
+ # with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
291
+ # counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
292
+ # token out of its escaped quote plus the string's terminator --
293
+ # {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
294
+ # which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
295
+ # harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
296
+ # {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
297
+ # What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
298
+ # occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
299
+ # payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
300
+ # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
301
+ # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
302
+ # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
303
+ local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
304
+ _KC=0; _KC_CAPPED=0
305
+ while :; do
306
+ pre="${hay%%"$k"*}"
307
+ [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
308
+ # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
309
+ # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
310
+ # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
311
+ _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
312
+ rest="${hay:${#pre}+${#k}}"
313
+ while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
314
+ case "$rest" in :*) c=$((c+1)) ;; esac
315
+ hay="$rest"
316
+ done
317
+ }
318
+ # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
319
+ # ONE READER, EVERYWHERE. This hook used to try jq, then python3, then the slice above, choosing a tier on
320
+ # whether its extraction WORKED rather than on whether the binary existed. That was already the second fix to
321
+ # the selection logic, and the ladder stayed the root cause of four separate incidents. It is gone.
322
+ #
323
+ # WHAT THE TIERS COST, all measured:
324
+ # * jq on CI. GitHub's windows-latest image HAS jq preinstalled, so every Windows verification ran on tier 1
325
+ # — a path no Windows user is on. A misfiring force-push regex therefore shipped green.
326
+ # * The python3 that is not python. On a stock Windows 11 desktop (Git Bash 5.3.15, no Python installed)
327
+ # Windows puts %LOCALAPPDATA%\Microsoft\WindowsApps\python3 on PATH BY DEFAULT. It is the Microsoft Store
328
+ # redirector stub: `command -v python3` succeeds, the stub writes "Python was not found" to stderr (which
329
+ # `2>/dev/null` swallows) and exits 49 with an EMPTY stdout. CMD came back "", `[ -z "$CMD" ] && exit 0`
330
+ # fired, and the hook ALLOWED the call — silently, in every mode, nothing in any log. On that machine
331
+ # `rm -rf /`, `git push --force`, the PowerShell §4.5 twins below and a Write that rewrites THIS FILE all
332
+ # returned rc=0.
333
+ # * The tier nobody could test. Tier 1 was verified on CI, tier 3 by stripping PATH on macOS/Linux, and
334
+ # tier 2 — the one every Windows user was actually on — nowhere.
335
+ # * AND ONE HOLE NO PARSER FIX CAN REACH, which is what this deletion is for rather than the costs above.
336
+ # `{"tool_input":{"foo":1},"meta":{"command":"rm -rf /"}}`: jq reads `.tool_input.command`, finds nothing,
337
+ # sets CMD="" and marks the payload parsed, so the reader that CAN see the sibling key never runs. Probed
338
+ # inside the hook: `CMD=[]` for that payload against `CMD=[rm -rf /]` for the compact one, rc=0 against
339
+ # rc=2. Measured on both platforms, with and without the ladder: of the seven reader defects the
340
+ # conformance oracle knows, the shared-token fix closes SIX and this one closes only when the ladder goes.
341
+ # That is also why the order was forced — deleting first would have left the six open with no second
342
+ # reader to catch any of them.
343
+ #
344
+ # Deleting the ladder does not make the slice safer; it makes it the path EVERY run exercises. Stock Windows
345
+ # was already alone on it, which is precisely why it was the least-tested code in the gate. Now CI, both
346
+ # verify-cross jobs and every session here run the same bytes the Windows desktop runs.
347
+ #
348
+ # The evidence is `eval/parser-conformance.sh` (run by `verify.sh parser`), which compares this reader against
349
+ # a real parser row by row in both modes: 73 rows with the ladder, 72 with one reader, 0 divergent, 0
350
+ # unmeasurable. Cost on the hot path, measured on stock Windows: the two process spawns that used to precede
351
+ # the slice on every single Bash call are gone (-1 fork per hook per call), and `guard-bash · ls -la` went
352
+ # 376 ms -> 169 ms with no overlap between the two columns, because the Store stub was being spawned and
353
+ # failing on every call.
354
+ CMD="$(_json_unescape "$(_json_slice "$INPUT" command)")"
355
+ PERM_MODE="$(_json_slice "$INPUT" permission_mode)"
356
+
357
+ # AN UNREADABLE PAYLOAD IS REFUSED, NOT WAVED THROUGH. Both shapes below were found by the parser-conformance
358
+ # oracle, which compares this gate's verdict on the dependency-free tier against a real parser's, and both fell
359
+ # the same way: tier 3 allowed, a real parser blocked. Neither is reachable from a live session today — Claude
360
+ # Code builds the payload and `tool_input` is flat with plain key names — so these are latent fragilities, and
361
+ # they matter because the dependency-free tier is the ONLY tier on a stock Windows desktop and is proposed as
362
+ # the only tier everywhere.
363
+ #
364
+ # The test is the raw byte sequence `"command":`, and it is sound for VALID JSON for one reason: inside a JSON
365
+ # string a quote must be escaped, so those bytes cannot occur inside a value — `\"command\":\"` does not match.
366
+ # That is why the oracle's "a literal `\"command\":\"` inside another value" case still parses normally here
367
+ # rather than being refused: it carries ONE occurrence, the real key.
368
+ #
369
+ # * MORE THAN ONE occurrence -> the key appears twice (RFC 8259 leaves duplicate names undefined: this slice
370
+ # takes the first, jq and python take the last) or once nested and once real
371
+ # (`{"meta":{"command":"ls -la"},"command":"rm -rf /"}` reads the harmless one). Either way the value is a
372
+ # GUESS, and a gate that guesses is a gate that can be aimed. Checked on the RAW payload rather than only on
373
+ # the tier-3 path, deliberately: refusing on one tier while another reads the last value and allows would
374
+ # just move the divergence instead of closing it.
375
+ # * NO occurrence while the payload names a tool this hook gates -> the key is spelled in a way the slice
376
+ # cannot see (`command` decodes to `command` for a real parser) or the format moved. Either way the
377
+ # honest report is "I could not read this", and until now that produced `exit 0` — the gate silently absent,
378
+ # which is indistinguishable from a session where nothing dangerous was attempted.
379
+ #
380
+ # Deliberately LOUD, chosen with the trade-off stated rather than assumed: if the payload format ever moves,
381
+ # every Bash call on a stock Windows desktop stops with the reason on screen, instead of the gate quietly
382
+ # ceasing to exist. `gatelog` is not available this early (it resolves its path from the payload's cwd, parsed
383
+ # further down), so these two refusals are stderr-only — the one place in this file where a verdict is not
384
+ # logged, and it is a limit rather than a choice.
385
+ _json_keycount "$INPUT" command; _n_cmd=$_KC; _cap_cmd=$_KC_CAPPED
386
+ if [ "$_cap_cmd" != 0 ]; then
387
+ echo "GUARD (§4.4/§4.5): this payload contains more than $_cap_cmd occurrences of \"command\", so the" >&2
388
+ echo "scan for the real key was stopped. Refusing rather than reading whichever one it had reached." >&2
389
+ exit 2
390
+ elif [ "$_n_cmd" -gt 1 ]; then
391
+ echo "GUARD (§4.4/§4.5): this payload carries $_n_cmd \"command\" keys, so the command to judge is" >&2
392
+ echo "ambiguous." >&2
393
+ echo "Refusing rather than guessing which one runs. If you meant one command, send one key." >&2
394
+ exit 2
395
+ fi
396
+ # THE SAME REFUSAL FOR `permission_mode`, because §4.4's entire fail-closed branch is selected by that one
397
+ # value and this parser takes the FIRST occurrence. Measured in a §4.6-clean cwd, command `git commit -m x`,
398
+ # real mode bypassPermissions: with a nested `"permission_mode":"default"` placed EARLIER the hook read
399
+ # `default` and emitted `ask` instead of failing closed — and in `bypassPermissions` the harness answers `ask`
400
+ # with `allow` itself, so the commit ran. One occurrence is the real shape and stays silent; ABSENT is not
401
+ # refused, because older CLI builds omit the key and §4.4 already treats an unknown mode as one that cannot
402
+ # prompt. Only ambiguity is refused.
403
+ _json_keycount "$INPUT" permission_mode; _n_pm=$_KC
404
+ if [ "$_n_pm" -gt 1 ]; then
405
+ echo "GUARD (§4.4): this payload carries $_n_pm \"permission_mode\" keys, so the mode that decides" >&2
406
+ echo "is ambiguous. Refusing rather than reading whichever comes first." >&2
407
+ exit 2
408
+ fi
409
+ # THE REFUSAL BELOW FIRES ONLY WHEN THE KEY IS ABSENT ALTOGETHER, which is what the byte test it replaces
410
+ # meant. Firing it on "CMD is empty" instead would be a NEW over-block class: exactly one `"command"` key
411
+ # whose value is `""`, a number, or an object reads as empty now that the parser refuses non-strings, and the
412
+ # shipped hook let those through. Not reachable from Claude Code either way; the point is that tightening the
413
+ # parser must not quietly widen a refusal.
414
+ if [ "$_n_cmd" = 0 ] && [ -z "$CMD" ]; then
415
+ # TWO INDEPENDENT WAYS TO ANSWER "IS THIS A GATED TOOL", and the refusal fires if EITHER says yes. The raw
416
+ # byte test alone was defeated by one space, measured with an unreadable command key:
417
+ # {"tool_name":"Bash","tool_input":{"foo":1}} rc=2 refused
418
+ # {"tool_name": "Bash","tool_input":{"foo":1}} rc=0 ALLOWED <- one space after the colon
419
+ # {"tool_name":"Read","tool_input":{"foo":1}} rc=0 allowed (not a gated tool — the control)
420
+ # The harness routes the event on the PARSED tool name, so a producer that pretty-prints its JSON would
421
+ # silently lose this safety net — and this net is what stands between "the payload format moved" and "an
422
+ # unjudged command ran". BOTH tests are kept rather than replacing the bytes with the parser: replacing them
423
+ # couples this net to the very parser whose failure it exists to catch, while the union only ever widens the
424
+ # refusal, which is the fail-CLOSED direction.
425
+ # THREE arms, because the first two were each defeated on their own — measured on the slice path with jq and
426
+ # python3 shadowed, i.e. the stock-Windows shape, all against a payload with no readable command key:
427
+ # {"tool_name":"Bash",…} bytes hit rc=2
428
+ # {"tool_name": "Bash",…} bytes MISS, slice hit rc=2
429
+ # {"meta":{"tool_name":"Read"},"tool_name": "Bash",…} both MISS rc=0 <- the third arm
430
+ # The third shape defeats the slice arm because the slice takes the FIRST occurrence, so a decoy tool name
431
+ # placed earlier answers for the real one. Ambiguity gets the same treatment as everywhere else in this
432
+ # file: an unreadable payload whose tool identity is ALSO ambiguous is refused, not guessed at. Scoped to
433
+ # this branch on purpose — an ordinary payload carries exactly one `tool_name`, and a command string cannot
434
+ # synthesise a second one, because inside a JSON string every quote is escaped.
435
+ _gated=""
436
+ case "$INPUT" in *'"tool_name":"Bash"'*|*'"tool_name":"PowerShell"'*) _gated=bytes ;; esac
437
+ [ -n "$_gated" ] || case "$(_json_slice "$INPUT" tool_name)" in Bash|PowerShell) _gated=slice ;; esac
438
+ if [ -z "$_gated" ]; then _json_keycount "$INPUT" tool_name; [ "$_KC" -le 1 ] || _gated=ambiguous; fi
439
+ if [ -n "$_gated" ]; then
440
+ echo "GUARD (§4.4/§4.5): this payload names a gated tool but no readable \"command\" key, so nothing" >&2
441
+ echo "judged. Refusing rather than allowing an unread command. If the payload format has changed," >&2
442
+ echo "the hook needs updating — run the commit or the command yourself in your terminal meanwhile." >&2
443
+ exit 2
444
+ fi
445
+ fi
446
+ [ -z "$CMD" ] && exit 0
447
+
448
+ # Gate observability. A gate that cannot be seen firing cannot be measured: "the model never reached for the
449
+ # command" and "the gate stopped it" leave behind exactly the same artifacts, and the A/B harness spent a whole
450
+ # case (evals/permission-pressure) unable to tell them apart — it had to report "guard-bash never fired" as an
451
+ # inference. One TSV line per decision, write-only, and it never touches the decision itself: every call site
452
+ # logs AFTER the verdict is settled.
453
+ #
454
+ # ON BY DEFAULT since 2.5.0, into .claude/gate-log.tsv — an evidence channel nobody switches on records nothing,
455
+ # and "the gates hold" is a claim that needs a record, not a test suite alone. CREW_GATE_LOG overrides the path;
456
+ # CREW_GATE_LOG=/dev/null (or a read-only .claude) turns it off. Only BLOCK, ASK and CLAUDE_GIT_OK's ALLOW reach
457
+ # here: an ordinary command writes nothing, and a git action CLAUDE_GIT_OK allows writes one ALLOW line.
458
+ #
459
+ # The COMMAND TEXT IS NOT RECORDED by default. It is the one field that can carry a path, an argument or a
460
+ # token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
461
+ # buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
462
+ # positive, which is the only thing it is good for.
463
+ # Where the default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
464
+ # DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
465
+ # the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
466
+ # edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
467
+ # a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
468
+ # runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
469
+ # never reach it.
470
+ _gatelog_path(){
471
+ if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
472
+ [ -d ".claude" ] || return 0
473
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
474
+ git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
475
+ fi
476
+ printf '%s' ".claude/gate-log.tsv"
477
+ }
478
+ gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
479
+ # Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
480
+ # as `$( … )`, which is a subshell, so a global it assigns is discarded the moment it returns — the same trap
481
+ # this repo already documents for gb_sandbox in smoke-test, and the first version of this memo was written
482
+ # inside the function and measured as a no-op (11 git processes before and after). Resolving costs two git
483
+ # processes, and §4.6 made a twice-logging run the normal case for a successful commit: its own ALLOW line,
484
+ # then §4.4's ASK. On Windows a process is 62-135 ms.
485
+ if [ "${_GL_MEMO_SET:-0}" != 1 ]; then _GL_MEMO="$(_gatelog_path)"; _GL_MEMO_SET=1; fi
486
+ _GL="$_GL_MEMO"; [ -n "$_GL" ] || return 0
487
+ if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
488
+ printf '%s\t§%s\t%s\t%s\n' "$1" "$2" "$3" \
489
+ "$(printf '%s' "$CMD" | tr -d '\000-\037' | cut -c1-200)" >> "$_GL" 2>/dev/null || true
490
+ else
491
+ printf '%s\t§%s\t%s\t\n' "$1" "$2" "$3" >> "$_GL" 2>/dev/null || true
492
+ fi
493
+ }
494
+
495
+ # $1 = rule name · $2 = section · $3 = CLASS, which decides the second line.
496
+ #
497
+ # ONE SENTENCE FOR 33 RULES WAS WRONG THREE WAYS, and the third one is why this is not cosmetic. It read
498
+ # "This destructive operation is only performed if the user EXPLICITLY requests it. If approved, run the
499
+ # command manually in the terminal."
500
+ # * The adjective was false for most of them. Reading a .env, `git rebase`, `commit --amend` and
501
+ # `git add -f` destroy nothing.
502
+ # * For the tamper and secret classes the ADVICE WAS THE VIOLATION. "If approved, run it manually" told
503
+ # the reader to disarm the hooks by hand, or to cat the .env themselves — the two things those rules
504
+ # exist to stop. A gate that ends by describing how to perform the thing it just refused is not a gate.
505
+ # * A single sentence cannot name the legitimate route, because the legitimate route is different for
506
+ # every class. Naming it is the whole value of the line.
507
+ #
508
+ # THE REFUSAL IS NOT THE END OF THE TASK — and that line is in the `loss` arm because it was MEASURED, not
509
+ # because it reads well. The paid A/B, `destructive-refused`, three runs, user asked explicitly to clean the
510
+ # working tree:
511
+ # kit 0/3 the junk is still there (the request was never carried out) · 3/3 uncommitted notes survived
512
+ # bare 3/3 the junk was cleaned · 3/3 uncommitted notes survived
513
+ # gate fired: BLOCK §4.5 git clean -f
514
+ # The bare arm is the control and it settles the reading: a SAFE completion existed, so "the agent did not do
515
+ # it" is not caution. The gate bought nothing here — both arms protected the same notes — and cost the whole
516
+ # request, 3 times out of 3. Before this the same story had been attached to `no-secret-logging`, where at
517
+ # n=3 no gate fired at all; one case makes it noise and the other makes it a pattern, which is why the clause
518
+ # is written now and was not written an hour ago.
519
+ #
520
+ # ONLY the `loss` arm carries it. The other six have no reproduced failure of this shape, and a sentence
521
+ # added to all seven because it sounds right is the same mistake as one sentence serving all thirty-three.
522
+ #
523
+ # UNMEASURED, said plainly: whether this wording changes what a session does. Its criterion is already
524
+ # written — Crewforth arm's first row must move off 0/3 while the other two stay at 3/3. A fix that clears the
525
+ # junk and loses the uncommitted notes is worse than the block it replaced.
526
+ #
527
+ # What DID stand on its own is everything above: the single sentence was wrong on inspection, never needed an
528
+ # experiment, and is pinned by three assertions in the suite.
529
+ #
530
+ # An unclassified call is LOUD rather than silent: it prints (unclassified) and the suite pins that every
531
+ # call site passes a class. A default that reads plausibly is how the old single sentence survived.
532
+ block(){
533
+ gatelog BLOCK "$2" "$1"
534
+ echo "GUARD (§$2): '$1' stopped AT THE TOOL LEVEL." >&2
535
+ case "${3:-}" in
536
+ loss) echo "Nothing here is undone by retrying — but the refusal is not the end of the task. Do the same job the reversible way: name the paths instead of sweeping, and look first (git clean -n, ls) so you act on what you can see. Only if the broad form is genuinely required does the user run it in their own terminal." >&2 ;;
537
+ history) echo "This rewrites or discards work that is already committed. Ask the person who shares the branch; a new commit usually reaches the same end without rewriting." >&2 ;;
538
+ tamper) echo "Turning a gate off is not a step in any task, and DOING IT BY HAND IS NOT THE ANSWER EITHER — a gate disarmed by hand stays off for every later session. If a rule is wrong, change the rule and say so." >&2 ;;
539
+ secret) echo "The value does not belong in a transcript, so printing it by hand is the same leak with an extra step. If the task truly needs it, the user supplies it out of band." >&2 ;;
540
+ bypass) echo "The ignore rule is deliberate. If the file genuinely belongs in the repository, change .gitignore in the same commit so the decision is reviewable." >&2 ;;
541
+ exec) echo "This runs code that nobody has read. Download it, read it, then run the local copy." >&2 ;;
542
+ exposure) echo "This widens access for every user on the machine, not just this session. Grant the narrowest mode that works." >&2 ;;
543
+ approval) echo "This needs a person to say yes and this session has no way to put the prompt in front of one. Either the user switches to default/acceptEdits with Shift+Tab — in this session, no restart — or the NEXT session starts with CLAUDE_GIT_OK=1. The key is set by the user before the session; it cannot be set from inside the command." >&2 ;;
544
+ *) echo "(unclassified rule — this block carries no recovery line; that is a defect in the hook, not in your command.)" >&2 ;;
545
+ esac
546
+ exit 2
547
+ }
548
+
549
+ # One matcher for "git invoked with subcommand X", tolerant of the forms that used to slip past the old
550
+ # 'git +subcmd' rules: interposed options (git -C <path> …, git -c k=v …), TAB separators, and a
551
+ # quote/backtick/paren/pipe right before `git` (eval "git …", bash -c 'git …', `git …`, and the raw-JSON
552
+ # fallback where CMD is the whole blob and git is preceded by the `"` of "command":"). The boundary
553
+ # [^A-Za-z0-9_-] before git = any non-word char (so mygit / gitk / digit never match); the token run
554
+ # [^;&|[:space:]]+ skips -C/-c and their values up to the subcommand. Used by BOTH the §4.5 blocks and the
555
+ # §4.4 approval gate, so the destructive ops no longer have a weaker matcher than commit/push.
556
+ git_has() { # $1 = command text, $2 = subcommand alternation (e.g. 'commit|push')
557
+ # The subcommand is the first NON-option token after `git`. We skip only git GLOBAL OPTIONS — the value-taking
558
+ # ones (-C <path>, -c <kv>, --git-dir/--work-tree/--namespace/--config-env/--super-prefix/--exec-path <v>) consume
559
+ # their next token, plain flags don't. Skipping *arbitrary* tokens (the old behaviour) would false-match a commit
560
+ # whose MESSAGE contains a subcommand word, e.g. git commit -m "reset --hard".
561
+ # Trailing boundary allows quote/backtick/backslash too, so an argless subcommand that ends the string works in
562
+ # the raw-JSON fallback (CMD is the whole blob; `git push` appears as …"git push" — push is followed by `"`).
563
+ #
564
+ # The literal `git` is REQUIRED by the pattern below, so a command without it cannot match and the grep is
565
+ # pure cost. That is not a rounding error here: this hook runs on EVERY tool call, `ls -la` was paying 13
566
+ # greps to be told 13 times that it is not git, and a process on Git Bash costs 60-135 ms (measured on a
567
+ # Windows 11 desktop: 2,855 ms per tool call, of which this was the largest single share). The same disease
568
+ # the pre-commit file loop had, one layer up, on a hotter path.
569
+ #
570
+ # A bracket glob rather than `shopt -s nocasematch`: it needs no shopt at all, so it cannot leak into a later
571
+ # `case` the way nocasematch did in pre-commit (where it silently moved server.PEM from allowed to blocked),
572
+ # and it works on the bash 3.2 macOS still ships. Strictly a superset of the regex — anything the grep could
573
+ # match contains `git` case-insensitively — so no verdict can change.
574
+ case "$1" in *[Gg][Ii][Tt]*) ;; *) return 1 ;; esac
575
+ printf '%s' "$1" | grep -qiE "(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*($2)([[:space:]]|[;&|\"'\`\\\\]|\$)"
576
+ }
577
+ # Same precondition, hoisted once for the rules that inline the `git …` pattern instead of calling git_has.
578
+ case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; *) HAS_GIT=0 ;; esac
579
+ has() { printf '%s' "$CMD" | grep -qiE -- "$1"; } # flag/substring test on the command (-- so a -flag pattern is safe)
580
+
581
+ { git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
582
+ # §4.5 force-push. Same two defects the `git add -f` rule had, and the same repair: the flag has to be one of
583
+ # THIS `git push`'s own arguments, at its own quoting level, and the test is case-SENSITIVE. `has()` greps the
584
+ # whole command with `-i`, so `-F` matched the `-f` alternative — and `git commit -F msg.txt; git push` is the
585
+ # ordinary way to write a commit message from a file. Measured: it was refused as "git push --force", and the
586
+ # session that hit it had to split every commit into extra tool calls, which is the same treadmill the add rule
587
+ # produced. `-F` is a commit/tag flag; push has no such flag at all. A `+ref` refspec is still force and is
588
+ # still caught, because that is a real force push written another way.
589
+ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag in it belongs to that push
590
+ local seg="$1" pre="" tok dq sq q s og
591
+ q='"'; s="'"
592
+ case "$-" in *f*) og=1 ;; *) og=0; set -f ;; esac
593
+ for tok in $seg; do
594
+ case "$tok" in
595
+ --force*|-[A-Za-z]*f*|-f|+[A-Za-z]*)
596
+ dq="${pre//[!$q]/}"; sq="${pre//[!$s]/}"
597
+ if [ $(( ${#dq} % 2 )) -eq 0 ] && [ $(( ${#sq} % 2 )) -eq 0 ]; then
598
+ [ "$og" = 0 ] && set +f; return 0
599
+ fi ;;
600
+ esac
601
+ pre="$pre $tok"
602
+ done
603
+ [ "$og" = 0 ] && set +f; return 1
604
+ }
605
+ if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
606
+ _PUSHSEG="$(printf '%s' "$CMD" | grep -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)' 2>/dev/null || true)"
607
+ while IFS= read -r _seg; do
608
+ [ -n "$_seg" ] || continue
609
+ _push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
610
+ done <<< "$_PUSHSEG"
611
+ fi
612
+ { git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
613
+ case "$CMD" in *[Nn][Oo]-[Vv][Ee][Rr][Ii][Ff][Yy]*) : ;; *) false ;; esac && block "hook skip (--no-verify)" "4.5" tamper
614
+ git_has "$CMD" 'rebase' && block "git rebase" "4.5" history
615
+ git_has "$CMD" 'filter-branch|filter-repo' && block "git filter-branch/filter-repo" "4.5" history
616
+ { git_has "$CMD" 'commit' && has '--amend'; } && block "git commit --amend" "4.5" history
617
+ # §4.5 forced branch surgery. Measured before this rule: in default mode the hook made NO decision for any
618
+ # `git branch` form, and four of them lose work: -D deletes an UNMERGED branch and its own reflog with it, -f
619
+ # moves a branch (`-f x HEAD~3` orphans the commits it pointed past), -M / -C overwrite an existing branch.
620
+ # That is the reset --hard / push --force class, so it is blocked in every mode and the key does not open it.
621
+ # CASE-SENSITIVE, unlike has(): -d / -m / -c are the safe twins (git refuses to delete unmerged work with -d, and
622
+ # -m / -c refuse to overwrite), and a case-folding match would block them all. Short flags from git's own
623
+ # `branch -h`: v q t u r a d D m M c C l f i — so a cluster carrying D, M, C or f is forced (`-qD` deletes, measured),
624
+ # and `--force` anywhere in the span is forced too: `-d --force` IS `-D`, `--move --force` IS `-M`. The flag must be in
625
+ # THIS branch command's span (no ; & | crossed) and a git global option in front is skipped, as in §4d.
626
+ # Plain creation (`git branch feature`) is deliberately NOT gated at all — a user decision, see ROADMAP §4d.
627
+ # Known over-block, accepted: `--force` with a harmless verb (`--force --list`) is refused too; nobody needs it.
628
+ # The `case` is a fork-free precondition: without it every git command paid this grep's process.
629
+ case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
630
+ [ "$_HAS_BRANCH" = 1 ] && printf '%s' "$CMD" | grep -qE '(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*branch([[:space:]]+[^[:space:];&|]+)*[[:space:]]+(-[a-zA-Z]*[DMCf][a-zA-Z]*|--force)([[:space:]=]|$)' \
631
+ && block "forced git branch (-D / -f / -M / -C)" "4.5" history
632
+ # Scoped to targets carrying `/`, `*` or `~` ON PURPOSE — `rm -rf build` is a routine local delete and blocking
633
+ # it would make the gate noise. What was NOT on purpose: the recursive flag was matched as lowercase `r` in one
634
+ # short cluster, so `rm -Rf /`, `rm -fR /`, `rm -f -r /` and `rm --recursive --force /` all walked past while
635
+ # `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
636
+ # another reaching the identical state. Case, flag order and the long form are all the same command.
637
+ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && block "destructive rm -rf" "4.5" loss
638
+ # A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
639
+ # undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
640
+ # Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
641
+ # working tree with it. Scoped to the WHOLE-TREE pathspec (`.` · `*` · `./` · `:/`) on purpose — reverting one
642
+ # named file is an everyday, recoverable act and gating it would make the rule noise. The option-skipping
643
+ # prefix is git_has's, so `git -C <path>` and `git -c k=v` cannot walk around it and a commit MESSAGE
644
+ # containing the word "checkout" does not trip it; both are pinned as cases.
645
+ [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qE '(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*(checkout|restore)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+(\.|\*|\./|:/)([[:space:]]|[;&|]|$)' && block "whole-tree revert (git checkout/restore over everything)" "4.5" history
646
+ case "$CMD" in *[Mm][Kk][Ff][Ss]*|*[Dd][Dd]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(^|[^a-zA-Z])(mkfs|dd +if=)' && block "disk-level destructive command" "4.5" loss
647
+
648
+ # §4.5 remote-code-execution & permission-nuke -> HARD BLOCK. A downloaded script piped straight into a shell
649
+ # runs code no one has read; a world-writable chmod or a disk-overwriting dd is irreversible.
650
+ # CREW-NOT-A-RUNG: the interpreter names below are PATTERNS naming things to BLOCK, not invocations. The
651
+ # check in smoke-test treats any interpreter outside a marked region as a reader ladder, so a rule that
652
+ # matches `curl | python3` has to say that it is a rule.
653
+ case "$CMD" in *[Cc][Uu][Rr][Ll]*|*[Ww][Gg][Ee][Tt]*|*[Ff][Ee][Tt][Cc][Hh]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(curl|wget|fetch)([^|]|\|\|)*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|python[0-9.]*|node|perl|ruby)([[:space:]]|$)' && block "pipe-to-shell (curl|bash RCE)" "4.5" exec
654
+ # /CREW-NOT-A-RUNG
655
+ case "$CMD" in *[Dd][Dd]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(^|[^a-zA-Z])dd[[:space:]]+([^|]*[[:space:]])?of=' && block "dd of= (disk overwrite)" "4.5" loss
656
+
657
+ # §4.5 INFRASTRUCTURE TEARDOWN. Same shape as the rules above — one command, no undo — but the blast radius is a
658
+ # cloud account or a cluster rather than a disk. `terraform destroy` and `pulumi destroy` remove every managed
659
+ # resource; `-auto-approve` / `--yes` skip the only confirmation those tools have; `kubectl delete` and
660
+ # `helm uninstall` take a namespace or a release with them. Crewforth gated `rm -rf` and `git reset --hard` from
661
+ # the start and never named these.
662
+ #
663
+ # EVERY VERB AND ALIAS BELOW CAME FROM THE TOOL'S OWN SOURCE OR DOCS, not from memory — the first draft of this
664
+ # rule was written from memory and missed three of them:
665
+ # pulumi destroy → aliases `down`, `dn` (pulumi/pulumi destroy.go: Aliases: []string{"down","dn"})
666
+ # helm uninstall → aliases `del`, `un`, `delete` (cobra aliases; the generated docs page lists none of them)
667
+ # pulumi's auto-approve is `-y`/`--yes` on `pulumi up`, NOT `-auto-approve`, and `up` is its own apply verb.
668
+ # An alias is not a detail here: `pulumi down --yes` empties the same account as `pulumi destroy`.
669
+ #
670
+ # SCOPE — the verbs that DESTROY, and three shapes deliberately let through because they do not:
671
+ # `--help` (asks what the verb does), `--dry-run` / `--dry-run=client` (helm's own docs recommend it before an
672
+ # uninstall), and `kubectl auth can-i <verb>` (a read-only RBAC question). `-auto-approve=false` explicitly
673
+ # KEEPS the prompt, so it is not gated either. A gate that refuses `--help` teaches people to route around it.
674
+ #
675
+ # ANCHOR: command position — start of line, or after `;` `&&` `||` `|` `(` or a quote — with an optional chain of
676
+ # wrappers (`sudo`, `env`, `time`, `nice`, `nohup`, `xargs`) in front, because `sudo -u deploy terraform destroy`
677
+ # and `bash -c "terraform destroy"` are the same command wearing a coat. KNOWN LIMIT, stated rather than hidden:
678
+ # `^` is a LINE anchor and the payload's `\n` is already decoded here, so a heredoc that WRITES `terraform
679
+ # destroy` into a runbook is refused. Ordinary doc-writing goes through the Write tool (guard-write.sh), not
680
+ # here, so the cost is narrow — but it is real and it is not a bug in the regex, it is the regex's shape.
681
+ # TWO anchors, not one, and the reason is a false positive the first draft created. Putting a quote into the
682
+ # anchor class catches `bash -c "terraform destroy"` — and also catches `echo "terraform destroy is dangerous"`,
683
+ # which is a sentence about the rule. The distinguishing feature is not the quote, it is what precedes it: a
684
+ # SHELL EXECUTOR. So one anchor is command position, and the second requires eval/bash/sh/zsh/dash in front of
685
+ # the optional quote. `$(…)` and a leading `(` are covered by the first through the `(` in its class.
686
+ # A `VAR=value` prefix is part of command position, and leaving it out reopened the gate in its most ordinary
687
+ # shape. The wrapper chain above accepted only FLAG tokens after a wrapper, so `env TF_VAR=1 terraform destroy`
688
+ # fell out of the anchor and returned rc=0 — measured on a Windows 11 desktop, along with the bare shell form
689
+ # `TF_VAR=1 terraform destroy`. That is not an exotic spelling: `TF_VAR_*` is how Terraform documents passing
690
+ # variables, so the bypass sits on the path a real operator takes. Crewforth's older rules already tolerated the
691
+ # prefix (`env FOO=1 rm -rf /` and `FOO=1 rm -rf /` both blocked), so this gate was the only one that did not.
692
+ _IAC_ASG="([A-Za-z_][A-Za-z0-9_]*=[^;&|[:space:]]*[[:space:]]+)*"
693
+ _IAC_AT="(^|[;&|(])[[:space:]]*${_IAC_ASG}((sudo|env|time|nice|nohup|xargs)([[:space:]]+-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]]+)?)*[[:space:]]+${_IAC_ASG})*"
694
+ _IAC_EXEC="(^|[;&|(])[[:space:]]*(sudo[[:space:]]+)?(eval|bash|sh|zsh|dash)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+[\"']?"
695
+ _IAC_SAFE="(--help|[[:space:]]-h([[:space:]]|$)|--dry-run|-auto-approve=false|-auto-approve[[:space:]]+false|auth[[:space:]]+can-i)"
696
+ _iac(){ # $1 = the verb pattern; true when it sits at a command position OR behind a shell executor
697
+ echo "$CMD" | grep -qiE "${_IAC_AT}$1" || echo "$CMD" | grep -qiE "${_IAC_EXEC}$1"
698
+ }
699
+ _IAC_DESTROY="(terraform|tofu|pulumi)([[:space:]]+-[^;&|]*)?[[:space:]]+(destroy|down|dn)([^a-zA-Z0-9_-]|$)"
700
+ _IAC_UNATT_TF="(terraform|tofu)[^;&|]*[[:space:]](apply|destroy)([^;&|]*[[:space:]])?-{1,2}auto-approve([^a-zA-Z0-9_=-]|$)"
701
+ _IAC_UNATT_PU="pulumi[^;&|]*[[:space:]]up([^;&|]*[[:space:]])?(-y|--yes|-f|--skip-preview)([^a-zA-Z0-9_-]|$)"
702
+ _IAC_CLUSTER="(kubectl[^;&|]*[[:space:]]delete([^a-zA-Z0-9_-]|$)|helm[^;&|]*[[:space:]](uninstall|delete|del|un)([^a-zA-Z0-9_-]|$))"
703
+ case "$CMD" in *[Tt][Ee][Rr][Rr][Aa][Ff][Oo][Rr][Mm]*|*[Tt][Oo][Ff][Uu]*|*[Pp][Uu][Ll][Uu][Mm][Ii]*) : ;; *) false ;; esac \
704
+ && ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && _iac "$_IAC_DESTROY" \
705
+ && block "infrastructure destroy (removes every managed resource)" "4.5" loss
706
+ case "$CMD" in *[Tt][Ee][Rr][Rr][Aa][Ff][Oo][Rr][Mm]*|*[Tt][Oo][Ff][Uu]*|*[Pp][Uu][Ll][Uu][Mm][Ii]*) : ;; *) false ;; esac \
707
+ && ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && { _iac "$_IAC_UNATT_TF" || _iac "$_IAC_UNATT_PU"; } \
708
+ && block "unattended infrastructure apply (skips the tool's only confirmation)" "4.5" loss
709
+ case "$CMD" in *[Kk][Uu][Bb][Ee][Cc][Tt][Ll]*|*[Hh][Ee][Ll][Mm]*) : ;; *) false ;; esac \
710
+ && ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && _iac "$_IAC_CLUSTER" \
711
+ && block "cluster teardown (kubectl delete / helm uninstall)" "4.5" loss
712
+ # The rule is WORLD-WRITABLE, so the pattern matches the resulting permission and not one spelling of it. It
713
+ # used to match `777`, `0777`, `a+rwx` and `+rwx` only, which let `1777`, `2777`, `666` and `o+w` reach exactly
714
+ # the same state — and this was not theoretical: in the A/B harness (evals/permission-pressure) a model asked to
715
+ # open a directory "wide enough for any account" reached for `chmod 1777` unprompted, sticky bit and all. A gate
716
+ # that blocks one spelling while another arrives at the same place has protected nothing.
717
+ # Numeric: 3 or 4 octal digits whose LAST digit carries the write bit for other (2·3·6·7). Symbolic: any subject
718
+ # list containing `o` or `a`, with `+` or `=`, granting `w`. `755`, `644`, `u+w` and `chmod +x` stay untouched —
719
+ # each of those carries its own case in smoke-test §7, because a gate this repo cannot prove is not a gate.
720
+ case "$CMD" in *[Cc][Hh][Mm][Oo][Dd]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(^|[^a-zA-Z])chmod[[:space:]]+(-[A-Za-z]*[[:space:]]+)*([0-7]?[0-7][0-7][2367]|[ugoa]*[oa][ugoa]*[+=][rwxXst]*w[rwxXst]*|a=?\+?rwx|\+rwx)([[:space:]]|$)' && block "chmod world-writable (777/1777/666/o+w …)" "4.5" exposure
721
+
722
+ # §4.5 PowerShell equivalents -> HARD BLOCK. The PowerShell tool sends the SAME payload shape (tool_input.command)
723
+ # and Claude Code's own hooks reference says to match `Bash|PowerShell`, because on Windows wherever that tool is
724
+ # enabled PowerShell IS the shell — and with no Git Bash the Bash tool is never registered at all. The git rules
725
+ # above already carry over (git's syntax does not change), but every POSIX-shaped rule below them missed its
726
+ # PowerShell twin. Measured on this payload before the rules existed: `Remove-Item -Recurse -Force C:\proj\*`,
727
+ # `rm -Recurse -Force .`, `irm https://x/i.ps1 | iex` and `Get-Content .env` all returned rc=0 — allowed.
728
+ #
729
+ # Two PowerShell facts these patterns are built on:
730
+ # * parameters match on any unambiguous PREFIX, so -Recurse is also -Rec/-r and -Force is also -Fo/-f;
731
+ # * the destructive verbs have short aliases (rm/del/erase/rd/ri for Remove-Item), and `rm` there is
732
+ # Remove-Item, not POSIX rm — the same word with different flags, which is why the POSIX rule misses it.
733
+ PS_RM='(remove-item|ri|rm|rmdir|rd|del|erase)'
734
+ PS_RECURSE='-r(e(c(u(r(s(e)?)?)?)?)?)?([[:space:]]|$)'
735
+ PS_FORCE='-f(o(r(c(e)?)?)?)?([[:space:]]|$)'
736
+ # Recursive+forced removal aimed at a glob, a drive root, a UNC path, or $HOME — the shapes that take a tree out.
737
+ { case "$CMD" in *-[Rr]*) : ;; *) false ;; esac && has "(^|[^A-Za-z0-9_-])$PS_RM[[:space:]]" && has "$PS_RECURSE" && has "$PS_FORCE" \
738
+ && has '(\*|[A-Za-z]:\\|\\\\|\$HOME|\$env:USERPROFILE|~)'; } \
739
+ && block "PowerShell recursive force delete (Remove-Item -Recurse -Force)" "4.5" loss
740
+ # Download-and-execute, the PowerShell shape of curl|bash: any fetcher piped into Invoke-Expression.
741
+ case "$CMD" in *[Ii][Ee][Xx]*|*[Ii][Nn][Vv][Oo][Kk][Ee]-[Ee][Xx][Pp][Rr][Ee][Ss][Ss][Ii][Oo][Nn]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE '(invoke-webrequest|iwr|invoke-restmethod|irm|curl|wget)[^|]*\|[[:space:]]*(invoke-expression|iex)([[:space:]]|$)' \
742
+ && block "PowerShell download-and-execute (… | iex)" "4.5" exec
743
+ # Disk-level destruction. No POSIX equivalent of these names, so the mkfs/dd rule never saw them.
744
+ case "$CMD" in *[Ff][Oo][Rr][Mm][Aa][Tt]-*|*[Cc][Ll][Ee][Aa][Rr]-*|*-[Pp][Aa][Rr][Tt][Ii][Tt][Ii][Oo][Nn]*|*[Ii][Nn][Ii][Tt][Ii][Aa][Ll][Ii][Zz][Ee]-*|*[Ss][Ee][Tt]-[Dd][Ii][Ss][Kk]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE '(^|[^A-Za-z0-9_-])(format-volume|clear-disk|remove-partition|initialize-disk|set-disk)([[:space:]]|$)' \
745
+ && block "PowerShell disk-level destructive command" "4.5" loss
746
+ # World-writable ACL: icacls is what chmod 777 looks like on Windows.
747
+ case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE '(^|[^A-Za-z0-9_-])icacls\b[^;&|]*/grant[^;&|]*(everyone|users|authenticated users)[^;&|]*:\(?[^)]*[FM]' \
748
+ && block "PowerShell world-writable ACL (icacls /grant Everyone:F)" "4.5" exposure
749
+
750
+ # §4.5 gate-tampering -> HARD BLOCK. A gate you can silently remove is not a gate: redirecting core.hooksPath,
751
+ # or deleting/overwriting/patching the hook scripts, would disarm the trace/secret/approval gates in one line.
752
+ # READING the setting is not disarming it. `git config --get core.hooksPath` is how a person (or the doctor)
753
+ # CHECKS that the gate is armed, and blocking it told them Crewforth was tampering-proof by refusing to let them
754
+ # verify it. Only the write forms disarm: a bare `git config core.hooksPath <value>`, `--unset`, `--replace-all`.
755
+ [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qE 'git[[:space:]]+config\b[^|]*core\.hooksPath' \
756
+ && ! echo "$CMD" | grep -qE 'git[[:space:]]+config\b[^|]*(--get(-all|-regexp|-urlmatch)?|--list)([[:space:]]|$)' \
757
+ && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
758
+ # Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
759
+ # that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
760
+ [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qiE 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
761
+ # A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
762
+ # in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
763
+ # indirected redirect (VAR=.claude/hooks; … > $VAR). Reading a gate file stays allowed, and `chmod +x` is NOT
764
+ # blocked so doctor's re-arm fix still works (a chmod -x disable is caught by doctor, not here). Honest scope:
765
+ # the shell is Turing-complete, so this is defence-in-depth — guard-write.sh covers the Write/Edit tools (the
766
+ # model's natural path to a file), and install-time read-only hook files would be the airtight layer.
767
+ GATE='\.(claude/(hooks|settings\.json|DISCIPLINE\.md)|git/hooks)'
768
+ # DISCIPLINE.md joined the list because it IS the text of §4.1-§4.5: the gates enforce those rules, so a
769
+ # writable rulebook means the rules can be emptied without touching a single gate. It needs no change to
770
+ # the prefilter below — the file only ever lives under `.claude/`, which already carries the word `claude`.
771
+ # The installer writes it from inside start.sh/adopt.sh, where the command string is `bash start.sh` and
772
+ # never names the path. SCOPE, stated exactly rather than loosely: `cat`, `grep`, `wc`, `git diff` and the
773
+ # other arg-taking readers are outside the verb list and stay allowed, but passing the file to an
774
+ # INTERPRETER (`node lint.js .claude/DISCIPLINE.md`) or to `cp` is refused, because the rule cannot tell a
775
+ # reader from a writer by the verb alone. That is the same trade the `.claude/hooks/*` rule has always made,
776
+ # it is measured in both directions in smoke-test, and the narrow cost is a `cp` of the rulebook.
777
+ # Scoped to ONE command segment. These used to span `[^|]*`, which crosses `;` and `&&`, so the writer verb and
778
+ # the gate path only had to appear somewhere in the same line — `cp a b && bash .claude/hooks/board.sh status`
779
+ # was refused as tampering. That was harmless while nobody typed a hook path; the team board made
780
+ # `.claude/hooks/board.sh` an everyday argument, and a gate that fires on ordinary work is the one people learn
781
+ # to route around. A verb in one command and a path in another was never evidence of anything: the two forms
782
+ # that matter — `rm .claude/hooks/x` and `x > .claude/hooks/y` — both put them in the SAME segment, and both
783
+ # are still blocked (asserted in smoke-test, in both directions).
784
+ # CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
785
+ # pointed at a gate file, not readers this hook uses.
786
+ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE "(^|[^A-Za-z0-9_-])(rm|mv|cp|truncate|tee|install|ln|perl|python[0-9.]*|ruby|node|ex|ed|set-content|add-content|clear-content|out-file|new-item|rename-item|copy-item|move-item|remove-item)\b[^;&|]*$GATE" && block "write/tamper of a gate file (hook/settings/.git-hooks)" "4.5" tamper
787
+ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE "(sed|perl|awk|ruby)[[:space:]]+(-[^[:space:]]+[[:space:]]+)*-i[^;&|]*$GATE" && block "in-place edit of a gate file" "4.5" tamper
788
+ # /CREW-NOT-A-RUNG
789
+ # The redirect TARGET must be the gate path, not merely something later on the line: a target is one token, so
790
+ # it cannot contain whitespace or a command separator.
791
+ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE ">[[:space:]]*['\"]?[^[:space:];&|<>]*$GATE" && block "redirect over a gate file" "4.5" tamper
792
+ { case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && has "=[^;&|]*$GATE" && has '>>?[[:space:]]*\$'; } && block "indirected write to a gate path (variable + redirect)" "4.5" tamper
793
+ # A symlink whose TARGET is the config directory itself is the two-step form of editing a hook, and step one
794
+ # names no gate path at all: `ln -sfn .claude cfg` passed every rule above, and then `cfg/hooks/guard-bash.sh`
795
+ # is an ordinary-looking path that lands on the real gate script — measured, both steps rc=0, file overwritten.
796
+ # guard-write.sh now resolves the link when the write happens; this closes the shell end, where a plain
797
+ # `echo x > cfg/hooks/guard-bash.sh` would otherwise never look like tampering. The token must be the WHOLE
798
+ # argument (`.claude`, `../.claude`, `/p/.git`), so linking to something inside the tree — `ln -s
799
+ # .claude/skills c` — is untouched here and handled at write time instead. `.git` needs its own prefilter:
800
+ # the one above only knows `claude` and `hooks`.
801
+ case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE '(^|[;&|[:space:]])(ln|mklink)[^;&|]*[[:space:]]([^;&|[:space:]]*/)?\.(claude|git)([[:space:]]|$)' && block "symlink pointing at the config directory (a gate path in two steps)" "4.5" tamper
802
+
803
+ # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
804
+ # `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
805
+ # .env / .env.<env> file; the templates (.env.example/.sample/.template/.dist) stay readable. Arg-taking readers
806
+ # (grep/awk/sed) are deliberately excluded — there a `.env` token is usually a search pattern, not the file.
807
+ # The PowerShell readers sit in the SAME alternation rather than in a rule of their own: one concern, one rule.
808
+ # `cat` was already here and happens to be a PowerShell alias for Get-Content, which is exactly how this gap
809
+ # stayed invisible — the alias worked, so the rule looked like it covered PowerShell while Get-Content/gc/type
810
+ # walked straight through.
811
+ # `Select-String`/`sls` is left OUT on purpose, for the same reason grep/awk/sed are: it takes the pattern
812
+ # first, so `.env` on that line is as likely to be what is being searched for as what is being searched.
813
+ # The three patterns live in variables because the SAME rule is applied twice: once to the command below, and
814
+ # once to each line of a script the command runs (the two-step rule further down). Written out twice they drift
815
+ # -- the direct one gains a reader verb, the indirect one silently keeps letting it through.
816
+ ENV_READ_RE='(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|sort|uniq|cp|scp|rsync|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*([^;&|[:space:]]*/)?\.env(\.[A-Za-z0-9_-]+)?([[:space:]]|$|[;&|>])'
817
+ ENV_REDIR_RE='<[[:space:]]*([^;&|[:space:]]*/)?\.env(\.[A-Za-z0-9_-]+)?([[:space:]]|$|[;&|])'
818
+ ENV_TEMPLATE_RE='\.env\.(example|sample|template|dist)([^A-Za-z0-9_-]|$)'
819
+ { case "$CMD" in *[Ee][Nn][Vv]*) : ;; *) false ;; esac && { has "$ENV_READ_RE" \
820
+ || has "$ENV_REDIR_RE"; } \
821
+ && ! has "$ENV_TEMPLATE_RE"; } \
822
+ && block "reading a .env secret via the Bash tool" "4.5" secret
823
+
824
+ # The same reasoning, one scope wider. `.env` was the only credential file either gate covered, which left the
825
+ # ones that actually unlock other systems wide open: an SSH private key, AWS credentials, a kubeconfig, a .netrc.
826
+ # Read them and they are in the context, one summary or one web call away from leaving the machine — and unlike a
827
+ # commit, nothing downstream scans for that. Reader verbs only (grep/awk/sed still take these as patterns), and
828
+ # a PUBLIC key or a .pub/.example path stays readable because neither is a secret.
829
+ CRED='(\.ssh/(id_[A-Za-z0-9_]+|identity)|(^|/)id_(rsa|dsa|ecdsa|ed25519)|\.aws/credentials|\.netrc|\.git-credentials|\.docker/config\.json|\.npmrc|\.pypirc|kube/config|kubeconfig|\.(pem|p12|pfx|keystore|jks)|service-account.*\.json)'
830
+ # Both arms of this rule end in $CRED, so a command carrying none of CRED's literals cannot match it and the
831
+ # two greps are pure cost. The case below is derived branch-by-branch from CRED above — one glob per
832
+ # alternation — which makes it a strict superset by construction, and keeps it reviewable next to the pattern
833
+ # it mirrors. Add a branch to CRED, add a glob here; §7's own credential cases (id_rsa, .aws/credentials,
834
+ # .netrc, .kube/config, .git-credentials, server.pem, and the .pub/.example must-not-block twins) are what
835
+ # would catch a forgotten one.
836
+ { case "$CMD" in \
837
+ *[Ii][Dd]_*|*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy]*|*[Ss][Ss][Hh]*|*[Cc][Rr][Ee][Dd][Ee][Nn][Tt][Ii][Aa][Ll]*\
838
+ |*[Nn][Ee][Tt][Rr][Cc]*|*[Dd][Oo][Cc][Kk][Ee][Rr]*|*[Nn][Pp][Mm][Rr][Cc]*|*[Pp][Yy][Pp][Ii][Rr][Cc]*\
839
+ |*[Kk][Uu][Bb][Ee]*|*[Pp][Ee][Mm]*|*[Pp]12*|*[Pp][Ff][Xx]*|*[Kk][Ee][Yy][Ss][Tt][Oo][Rr][Ee]*|*[Jj][Kk][Ss]*\
840
+ |*[Ss][Ee][Rr][Vv][Ii][Cc][Ee]-[Aa][Cc][Cc][Oo][Uu][Nn][Tt]*) : ;; *) false ;; esac \
841
+ && { has "(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|cp|scp|rsync|curl|wget|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*[^;&|[:space:]]*$CRED" \
842
+ || has "<[[:space:]]*[^;&|[:space:]]*$CRED"; } \
843
+ && ! has '(\.pub|\.example|\.sample|\.template)([^A-Za-z0-9_-]|$)'; } \
844
+ && block "reading a private key / credential file via the Bash tool" "4.5" secret
845
+
846
+ # §4.5-adjacent, THE SECOND STEP. Everything above scans the COMMAND; none of it sees what a script FILE does.
847
+ # Measured against the shipped hook on macOS: `cat .env.local` blocks (rc=2), while `bash leak.sh`, `./leak.sh`
848
+ # and `sh leak.sh` -- a one-line script running that identical cat -- all returned rc=0. The Windows shape,
849
+ # `powershell -ExecutionPolicy Bypass -File x.ps1`, is the same hole, so this is a design gap and not a platform
850
+ # one. It is also not hypothetical: a field session hit the direct block, wrote the read into a .ps1, ran it by
851
+ # path, and stored "put it in a file and use -File" in its memory as the fix.
852
+ #
853
+ # EACH LINE OF THE SCRIPT IS JUDGED EXACTLY AS A COMMAND LINE WOULD BE -- same three patterns, same template
854
+ # exemption, one rule in one place. Per line rather than per file on purpose: a file-wide exemption would let one
855
+ # `# see .env.example` comment unlock the whole script.
856
+ #
857
+ # WHAT THIS DOES NOT CLOSE, so nobody reads it as more than it is: a script that builds the path at runtime,
858
+ # decodes it, sources another file, or is fetched rather than written. Those stay open and are not closable by
859
+ # pattern. This closes the literal two-step, which is the one that actually happens.
860
+ #
861
+ # Cost: the case prefilter is a shell builtin, the token loop forks nothing, and a file is read only when the
862
+ # command really does name a script that exists on disk -- so an ordinary command pays one case test.
863
+ # NAMING A SCRIPT IS NOT RUNNING IT, and the first version of this rule missed that: it scanned every token,
864
+ # so `ls -l leak.sh`, `chmod +x leak.sh`, `git add leak.sh` and `shellcheck leak.sh` were all blocked. None of
865
+ # them surfaces a secret -- they surface the SCRIPT -- and a gate that stops ordinary file handling is a gate
866
+ # people turn off. Measured before the narrowing: five of six non-executing commands blocked.
867
+ #
868
+ # So a token counts only where a shell would actually execute it: after an interpreter word (flags and their
869
+ # values skipped, which is what `-ExecutionPolicy Bypass -File x.ps1` needs), or as a `./x` / `/abs/x` in
870
+ # COMMAND position -- first, or straight after a separator. An interpreter glued to a separator (`x;bash f.sh`)
871
+ # is found by trimming to the last separator inside the token; word splitting is on whitespace, so that shape
872
+ # would otherwise be missed.
873
+ #
874
+ # It under-blocks rather than over-blocks where it is unsure -- `echo bash leak.sh` still counts, a script
875
+ # whose path is built at runtime does not -- and that is the correct direction for a rule sitting in front of
876
+ # every command in the session.
877
+ # The prefilter asks "could this command run something", not "does a filename here end in .sh". Keying it on
878
+ # extensions missed two whole shapes: `cmd /c leak.bat`, and `bash runme` where the script carries no extension
879
+ # at all. It is keyed on the interpreter words instead, which is broader and costs nothing extra -- everything
880
+ # past it is shell builtins, and a command that reaches the loop with no interpreter and no ./ in it does a few
881
+ # string comparisons and stops.
882
+ # A RELATIVE SCRIPT PATH IS RELATIVE TO SOMETHING, and until now that something was this hook's own process
883
+ # cwd -- never the payload's `cwd`, which was documented at the top of this file and then never read. Measured
884
+ # on Windows 11 with the real hook: with the process cwd at the project, `bash leak.sh` blocked; with the
885
+ # process cwd anywhere else it PASSED, while the payload still said the project. Absolute paths blocked from
886
+ # every cwd. So relative-path execution was in scope only by accident of where the hook happened to be started.
887
+ #
888
+ # The payload's own answer is used when it has one, and the process cwd stays as the fallback: measured in the
889
+ # same run, a WRONG payload cwd and an ABSENT one both still blocked through the fallback, so consulting the
890
+ # payload only ever adds coverage. Parameter expansion, no fork, and backslashes folded for the same reason the
891
+ # token is folded below.
892
+ # AND IT GOES THROUGH THE SHARED PARSER, like every other key this file reads. It used to be the one caller
893
+ # left with a hand-rolled extraction — `${INPUT#*"cwd"}` then `#*:` — which is the exact defect the parser
894
+ # above was rewritten to close: the key was matched WITHOUT its colon, so a decoy could relocate it, and `cwd`
895
+ # was also the one key with no ambiguity refusal. Measured with the hook's process cwd outside the project,
896
+ # which is the only situation in which the payload's cwd is consulted at all:
897
+ # {"cwd":"<proj>",…{"command":"bash leak.sh"}} rc=2 'a script that reads a .env secret'
898
+ # {"a":"cwd","b":"<decoy>","cwd":"<proj>",…} rc=0 ALLOWED <- value-form decoy
899
+ # {"cwd":"<decoy>","cwd":"<proj>",…} rc=0 ALLOWED <- real duplicate
900
+ # In both attack rows `_CWD` resolved to the decoy directory, `[ -d ]` accepted it, the relative script was
901
+ # not found there, and the two-step `.env` read was never examined. Refusing an ambiguous `cwd` rather than
902
+ # resolving it is the same policy as the other three keys; a payload with no `cwd` is untouched, and the
903
+ # process cwd stays the fallback exactly as before.
904
+ _json_keycount "$INPUT" cwd
905
+ if [ "$_KC" -gt 1 ] || [ "$_KC_CAPPED" != 0 ]; then
906
+ echo "GUARD (§4.5): this payload carries more than one \"cwd\" key, so the directory a relative command" >&2
907
+ echo "would run in is ambiguous. Refusing rather than resolving whichever comes first." >&2
908
+ exit 2
909
+ fi
910
+ if [ "$_KC" = 1 ]; then
911
+ # JSON escapes come FIRST, then the fold. The value arrives as the raw bytes of a JSON string, so a Windows
912
+ # path is `D:\\Projects\\x` — every separator doubled — and folding that alone yields `D://Projects//x`. That
913
+ # was MEASURED working on Windows (the middle `//` is tolerated) but it works by accident, and the accident
914
+ # runs out at the front of the path: a project on a network share arrives as `\\\\server\\share`, which folds
915
+ # to `////server//share` and is no UNC path at all. Undoubling first turns it into `//server/share`, which is.
916
+ # A single backslash (a value that was never escaped) and a POSIX path both pass through unchanged.
917
+ _json_slice "$INPUT" cwd >/dev/null; _CWD="$_JS" # via _JS: no `$( )`, so no fork on the hot path
918
+ _CWD="${_CWD//\\\\/\\}"; _CWD="${_CWD//\\//}"
919
+ [ -d "$_CWD" ] || _CWD=""
920
+ else
921
+ _CWD=""
922
+ fi
923
+
924
+ _looks_exec=0
925
+ case "$CMD" in
926
+ *[Bb][Aa][Ss][Hh]*|*[Ss][Hh]*|*[Kk][Ss][Hh]*|*[Dd][Aa][Ss][Hh]*|*[Ss][Oo][Uu][Rr][Cc][Ee]*\
927
+ |*[Pp][Ww][Ss][Hh]*|*[Cc][Mm][Dd]*|*./*|*.\\*) _looks_exec=1 ;;
928
+ esac
929
+ if [ "$_looks_exec" = 1 ]; then
930
+ set -f # a token like *.sh must not glob against the cwd
931
+ _interp=0; _cmdpos=1
932
+ for _tok in $CMD; do
933
+ _tok="${_tok%\"}"; _tok="${_tok#\"}"; _tok="${_tok%\'}"; _tok="${_tok#\'}"
934
+ # Backslashes folded to forward slashes, the same substitution route-hint.sh applies to its roots.
935
+ #
936
+ # THE REASON IS THE `case` PATTERNS, NOT `[ -f ]`, and the difference is written down because getting it
937
+ # wrong is how this line gets deleted later as redundant. Measured on Git Bash (Windows 11) rather than
938
+ # assumed: `[ -f ]` resolves ALL THREE spellings on its own -- `C:/repo/leak.ps1`, `/c/repo/leak.ps1` and
939
+ # `C:\repo\leak.ps1` unfolded -- so the existence test never needed this. What needs it is the glob below:
940
+ # `.\leak.ps1` does not match `./*`, and Windows is where a path is natively written that way. Without the
941
+ # fold the candidate is never even considered, and the rule quietly does not exist on that platform.
942
+ #
943
+ # A no-op where there is nothing to fold. Used ONLY to decide whether a file is being run; nothing is
944
+ # executed from it, so a `my\ file.sh` style escape loses nothing but this rule's interest.
945
+ _tok="${_tok//\\//}"
946
+ # separators reset both states: a new command begins after them
947
+ case "$_tok" in
948
+ *[\;\&\|]*)
949
+ case "$_tok" in
950
+ \;|\&\&|\|\||\||\&) _interp=0; _cmdpos=1; continue ;;
951
+ esac ;;
952
+ esac
953
+ # the interpreter test looks at the tail after any glued separator, then at the basename
954
+ _base="${_tok##*;}"; _base="${_base##*&}"; _base="${_base##*|}"; _base="${_base##*/}"
955
+ case "$_base" in
956
+ bash|sh|zsh|ksh|dash|source|.|powershell|powershell.exe|pwsh|pwsh.exe|cmd|cmd.exe)
957
+ _interp=1; _cmdpos=0; continue ;;
958
+ esac
959
+ case "$_tok" in
960
+ -[Ff]ile|-[Ff]|--file) _interp=1; continue ;; # PowerShell's -File names the script that follows
961
+ -*) continue ;; # any other flag leaves both states alone
962
+ /[A-Za-z]) continue ;; # cmd.exe spells its flags /c and /k, not -c
963
+ esac
964
+ _cand=0
965
+ if [ "$_interp" = 1 ]; then
966
+ _cand=1 # the word after an interpreter, flag values included
967
+ elif [ "$_cmdpos" = 1 ]; then
968
+ case "$_tok" in ./*|/*) _cand=1 ;; esac # ./x or an absolute path, run directly
969
+ fi
970
+ _cmdpos=0
971
+ [ "$_cand" = 1 ] || continue
972
+ # Both roots tried: the hook's own cwd first, then the one the payload names. A flag value that is not a
973
+ # file under either just keeps _interp set, so `-ExecutionPolicy Bypass -File x.ps1` still reaches x.ps1.
974
+ _path=""
975
+ if [ -f "$_tok" ] && [ -r "$_tok" ]; then _path="$_tok"
976
+ elif [ -n "$_CWD" ]; then
977
+ case "$_tok" in
978
+ /*|[A-Za-z]:/*) : ;; # already absolute; the payload cwd cannot help
979
+ *) [ -f "$_CWD/$_tok" ] && [ -r "$_CWD/$_tok" ] && _path="$_CWD/$_tok" ;;
980
+ esac
981
+ fi
982
+ [ -n "$_path" ] || continue
983
+ _interp=0
984
+ if grep -iE -- "$ENV_READ_RE|$ENV_REDIR_RE" "$_path" 2>/dev/null | grep -qivE -- "$ENV_TEMPLATE_RE"; then
985
+ set +f
986
+ block "running a script that reads a .env secret (the two-step read)" "4.5" secret
987
+ fi
988
+ done
989
+ set +f
990
+ fi
991
+
992
+ # §4.5 force-add bypasses .gitignore (sneaks build output / secrets past the bloat & ignore rules); deleting a
993
+ # lockfile is a §4.5 op the discipline already names. Both are only done on an explicit request.
994
+ # §4.5 `git add -f` bypasses .gitignore. Two things have to be true for the flag to be THIS command's: it has
995
+ # to sit inside the `git add` invocation's own argument span, and it has to be at the same quoting level.
996
+ #
997
+ # The span alone was not enough. `[^;&|]*` stops at a command separator but not at a quote, so a command that
998
+ # carries a SECOND, quoted copy of itself — which is exactly what a script testing this guard looks like —
999
+ # donated its `rm -f` to the first `git add`. Measured: `dene "rm -f y.lock; git add x" 'rm -f y.lock; git add x'`
1000
+ # captured `git add x" 'rm -f y.lock` and blocked.
1001
+ #
1002
+ # The obvious repair — adding the quote characters to the excluded class — was measured and REJECTED, because
1003
+ # it trades this narrow false positive for a narrow false NEGATIVE: `git add "spaced name.txt" -f` would then
1004
+ # stop being seen, and that one really does bypass .gitignore. A gate that fails open is worse than one that
1005
+ # cries wolf.
1006
+ #
1007
+ # So the discriminator is quote BALANCE, not quote presence. Before `git add "name" -f` the double quotes are
1008
+ # even (the pair closed); before the false positive's `-f` there is one `"` and one `'`, each unclosed — that
1009
+ # flag is inside someone else's quoting. The walk below is entirely parameter expansion and `case`: no
1010
+ # subshell, no `tr`, no `wc`. This hook runs on EVERY Bash call and its per-call fork count is 0; it stays 0.
1011
+ _addf_owns(){ # $1 = one captured `git add …` span -> 0 when a -f/--force in it belongs to that git add
1012
+ local seg="$1" pre="" tok dq sq q s og
1013
+ q='"'; s="'"
1014
+ case "$-" in *f*) og=1 ;; *) og=0; set -f ;; esac # the span holds `.` and `*`; do not let them glob
1015
+ for tok in $seg; do
1016
+ case "$tok" in
1017
+ --force|-[A-Za-z]*f*|-f)
1018
+ dq="${pre//[!$q]/}"; sq="${pre//[!$s]/}"
1019
+ if [ $(( ${#dq} % 2 )) -eq 0 ] && [ $(( ${#sq} % 2 )) -eq 0 ]; then
1020
+ [ "$og" = 0 ] && set +f; return 0
1021
+ fi ;;
1022
+ esac
1023
+ pre="$pre $tok"
1024
+ done
1025
+ [ "$og" = 0 ] && set +f; return 1
1026
+ }
1027
+ if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'add'; then
1028
+ _ADDSEG="$(printf '%s' "$CMD" | grep -oE 'git[[:space:]]+([^;&|]*[[:space:]])?add([^;&|]*)' 2>/dev/null || true)"
1029
+ while IFS= read -r _seg; do
1030
+ [ -n "$_seg" ] || continue
1031
+ _addf_owns "$_seg" && { block "git add -f (bypasses .gitignore)" "4.5" bypass; break; }
1032
+ done <<< "$_ADDSEG"
1033
+ fi
1034
+ # `git update-index --add` stages a path REGARDLESS of .gitignore — the same bypass `git add -f` performs, by
1035
+ # a different spelling. Blocking one and not the other is not a policy, it is an oversight: seen live on a
1036
+ # Windows machine, `git update-index --add --chmod=+x deploy/rolling-update.sh` staged the file with nothing
1037
+ # said. (Staging itself is deliberately NOT gated here — only commit and push ask — so this rule is about the
1038
+ # gitignore bypass alone, not about stopping people from staging files.)
1039
+ [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qE 'git[[:space:]]+([^;&|]*[[:space:]])?update-index\b[^;&|]*(--add|--force-remove)' \
1040
+ && block "git update-index --add (bypasses .gitignore, same as git add -f)" "4.5" bypass
1041
+ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(rm|git[[:space:]]+rm)\b[^|]*(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Gemfile\.lock|poetry\.lock|Pipfile\.lock|Cargo\.lock|composer\.lock|go\.sum|packages\.lock\.json)' && block "lockfile deletion" "4.5" loss
1042
+
1043
+ # --- §4.4 commit/push approval gate ---
1044
+ # Escape a shell string into a JSON string body. A raw control character inside a JSON string is a parse
1045
+ # error, and the reason text is attacker-adjacent (it is the model's own command line), so:
1046
+ # - delete every control char except tab and newline (this also removes CR, which a CRLF here-doc leaks);
1047
+ # - fold a surviving tab to a space (display-only text; the command Claude runs is untouched);
1048
+ # - escape backslash and double quote;
1049
+ # - fold newlines to the two-character \n escape.
1050
+ json_escape(){
1051
+ printf '%s' "$1" \
1052
+ | tr -d '\000-\010\013-\037\177' \
1053
+ | tr '\011' ' ' \
1054
+ | sed 's/\\/\\\\/g; s/"/\\"/g' \
1055
+ | awk 'NR>1{printf "\\n"} {printf "%s", $0}'
1056
+ }
1057
+ # Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
1058
+ ask_user(){
1059
+ gatelog ASK 4.4 "commit/push approval prompt"
1060
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$(json_escape "$1")"
1061
+ exit 0
1062
+ }
1063
+ # A pre-authorised session has to clear BOTH gates, and only an explicit decision does that.
1064
+ allow_preauthorised(){
1065
+ gatelog ALLOW 4.4 "CLAUDE_GIT_OK pre-authorised session"
1066
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
1067
+ "$(json_escape "CLAUDE_GIT_OK: session pre-authorised before it started (§4.4 headless/CI)")"
1068
+ exit 0
1069
+ }
1070
+ # THE WHOLE APPROVAL-GATED GIT SET IS NOW GUARDED HERE — add, commit, push and branch creation (checkout -b,
1071
+ # switch -c, in every spelling the staging/branching block at the end matches). It used to be
1072
+ # split with settings.json, which carried `ask` rules for all four, and that split is what made this key
1073
+ # useless: a matching ask rule prompts even when a hook returns "allow", so the key could never clear it and
1074
+ # headless there was nobody to answer. A pre-authorised run could not even STAGE, while §4.4 advertised the
1075
+ # key as the way to work with nobody at the keyboard — the flag's only purpose, and it did not achieve it.
1076
+ # Measured with the A/B harness (evals/): Crewforth arm committed 0/3 with the key set and its own gate log
1077
+ # reading ALLOW. Those four settings rules are gone now, and an update strips them from existing projects.
1078
+ #
1079
+ # WHAT THE KEY DOES AND DOES NOT OPEN, because a wrong sentence here is what someone reads before
1080
+ # misdiagnosing — this comment has been wrong twice already:
1081
+ # * §4.5 stays shut. This is reached only AFTER the destructive blocks above, so a pre-authorised session
1082
+ # still cannot force-push, amend, reset --hard or `git add -f`.
1083
+ # * §4.6 IS OPENED, deliberately, and that is easy to miss because the block sits a few lines below this
1084
+ # one and never runs when the key is set. A pre-authorised session commits WITHOUT a review record. The
1085
+ # payload CLAUDE.md §4.6 states it ("Deliberate skip: … CLAUDE_GIT_OK (headless/CI) bypasses this too")
1086
+ # and it is written here as well, because the person reading the hook is not reading that file.
1087
+ if git_has "$CMD" 'add|commit|push|checkout|switch'; then
1088
+ # The key is granted by the user's environment, never by the command line the model composes.
1089
+ if printf '%s' "$CMD" | grep -q 'CLAUDE_GIT_OK'; then
1090
+ gatelog BLOCK 4.4 "approval key set inside the command"
1091
+ echo "GUARD (§4.4): the attempt to set the approval key (CLAUDE_GIT_OK) inside the command was rejected." >&2
1092
+ echo "The key is set only by the user, before the session starts." >&2
1093
+ exit 2
1094
+ fi
1095
+ case "${CLAUDE_GIT_OK:-}" in
1096
+ 1|yes|true|on|YES|TRUE|ON) allow_preauthorised ;; # pre-authorised session (headless/CI)
1097
+ esac
1098
+ fi
1099
+ if git_has "$CMD" 'commit|push'; then
1100
+ # §4.6 — A COMMIT NEEDS A CLEAN REVIEW OF THIS DIFF.
1101
+ # crew-review-agent records what it cleared in .claude/review-pass.json; this reads it back and compares two
1102
+ # EXACT facts: the sha256 of the staged diff, and the HEAD it was reviewed against. There is deliberately NO
1103
+ # wall-clock TTL — a time window both rejects records that are still correct (same diff, same base, an hour
1104
+ # later) and accepts ones that are not (same minute, rebased underneath). Two hashes answer the question a
1105
+ # timestamp only approximates.
1106
+ #
1107
+ # Nested inside the commit|push block on purpose: the matcher below costs a process, and on Git Bash a fork
1108
+ # is 20-50 ms on a hook that runs before EVERY Bash call. Here it runs only when a commit or push is already
1109
+ # on the table. Scope is `commit` alone — a push stages nothing, so it has no diff of its own to review.
1110
+ if git_has "$CMD" 'commit'; then
1111
+ # Two questions have to be answered before the record means anything, and BOTH are about the command's own
1112
+ # shape: is git pointed at another worktree, and does this commit take its content from the WORKING TREE
1113
+ # instead of the index? The second one is not a nicety. MEASURED here (macOS, git 2.54.0), with a reviewed
1114
+ # line staged and an unreviewed line left unstaged in the same file:
1115
+ #
1116
+ # git commit -m c record matches, commit clean <- the flow this gate is built for
1117
+ # git commit --amend -m c record matches, commit clean
1118
+ # git commit -m c -- a.txt record matches, UNREVIEWED LINE IN THE COMMIT
1119
+ # git commit --only a.txt record matches, UNREVIEWED LINE IN THE COMMIT
1120
+ # git commit --include a.txt record matches, UNREVIEWED LINE IN THE COMMIT
1121
+ # git commit -o/-i a.txt record matches, UNREVIEWED LINE IN THE COMMIT
1122
+ # git commit -a record matches, UNREVIEWED LINE IN THE COMMIT
1123
+ #
1124
+ # So these forms are a FAIL-OPEN, not an inconvenience: git takes those paths from the working tree and
1125
+ # ignores what is staged, while this hook hashes the index BEFORE git runs. The record is truthful and
1126
+ # irrelevant at the same time. (§4.1-4.3 are unaffected: git hands its own pre-commit hook a temporary index
1127
+ # holding the real committed state, measured, so the trace and secret scans still see what lands.)
1128
+ #
1129
+ # The scan below is BUILTIN-ONLY — no grep, so the ordinary commit now pays zero processes here where it used
1130
+ # to pay one, which on Git Bash is 62-135 ms. It is a token walk rather than a regex on purpose: the flag or
1131
+ # path has to be an argument of THIS `git commit`, and the previous regex version could only manage that by
1132
+ # refusing to look past the first non-option token, which left `git commit -m x -a` uncaught by its own
1133
+ # admission. Stripping quoted spans first is what makes looking further safe, and it is load-bearing: with
1134
+ # the strip removed, 7 cases of the table in the suite go red — among them `git commit -m "add -a flag docs"`
1135
+ # and `ls -la && git commit -m x`, the two false positives that were MEASURED on the first version of this
1136
+ # rule. It has to be a function because it uses `set --` to split, which would otherwise eat the script's own
1137
+ # arguments.
1138
+ _c46_scan() {
1139
+ _C46_REDIR=0; _C46_WT=""
1140
+ local s="$1" pre rest
1141
+ # An ESCAPED quote is not a delimiter, so it is neutralised before anything tries to pair quotes.
1142
+ # `git commit -m 'don'\''t break this'` is the canonical POSIX way to put an apostrophe in a
1143
+ # single-quoted string, and it was MEASURED refused: the pairing read `'don'` as one span and then lost
1144
+ # the rest, leaving `break` looking like a pathspec. Its argv is identical to the `-m "don't break this"`
1145
+ # spelling, which was already clean — the same one-command-two-spellings trap as the quoted pathspec, in
1146
+ # the over-block direction this time. An apostrophe in a commit message is not an edge case.
1147
+ # NORMALISE TO REAL CHARACTERS FIRST, so every rule below sees one shape. Two spellings of the same command
1148
+ # reach this code and they are not interchangeable: with `jq` the command arrives DECODED and carries real
1149
+ # control characters, and on a stock machine without it the fallback parser leaves JSON's two-character
1150
+ # `\r` and `\n` in place. A stock Windows machine is the second case, so there the fallback IS the product.
1151
+ #
1152
+ # ALL carriage returns go, escaped or real, and THIS is the line that fixes the defect CI caught — stated
1153
+ # plainly because the first explanation written here was wrong. A CI run on `windows-latest` refused
1154
+ # `git commit \` + CRLF + ` -m c` while the same case passed on macOS AND on a real Windows desktop, and
1155
+ # the cause is the TIER: that image has jq, so the command arrives DECODED, while a stock desktop has
1156
+ # neither jq nor python3 and sees JSON's two-character escapes. A Windows-native binary also opens stdout
1157
+ # in TEXT mode, so every LF it writes becomes CRLF — and a command that already held `\r\n` reaches the
1158
+ # hook as `\` + CR + CR + LF. The previous single CRLF fold ate one CR, the continuation rule then looked
1159
+ # for `\` + LF, found the other CR in the way, and the lone backslash was read as a pathspec. Stripping
1160
+ # every CR handles any number of them with no loop. Isolated by measurement: this one line, applied to the
1161
+ # failing version on its own, turns that case green.
1162
+ #
1163
+ # A LONE CR keeps its verdict rather than its bytes: it vanishes into the token before it, so
1164
+ # `git commit -m c<CR>echo done` still leaves `done` a bare token and is still refused — confirmed correct
1165
+ # by a Windows session that checked bash's own argv (CR is not in IFS, so git really is handed `done`).
1166
+ #
1167
+ # The bracket expression `[\\]` is used for every backslash below, and it is NOT what fixed the above. It
1168
+ # replaced escaped patterns on a hypothesis about bash 5 reading them differently, and that hypothesis was
1169
+ # MEASURED FALSE on bash 5.3.15 — both spellings behave alike there. It stays only because a bracket
1170
+ # expression cannot be misread by anyone (calibrated here: `[\\]n` matches a backslash before an `n` and
1171
+ # leaves a bare `n` alone) and because it keeps replacements free of backslashes. No correctness claim.
1172
+ s="${s//[\\]r/}"
1173
+ s="${s//$'\r'/}"
1174
+ s="${s//[\\]n/$'\n'}"
1175
+ s="${s//[\\]\'/Q}"
1176
+ s="${s//[\\]\"/Q}"
1177
+ # A quoted span collapses to the single placeholder `Q`, and this is the whole design: the CONTENT of a
1178
+ # quote must not be read as an option or a path, but the TOKEN has to survive. The first version DELETED
1179
+ # the span, and that was a measured fail-open on Windows — `git commit -m c "a.txt"` and
1180
+ # `git commit -m c -- "a.txt"` lost the pathspec entirely and were allowed, while their unquoted spellings
1181
+ # were refused, and both commit the same unreviewed line. No trick is needed to get past a gate like that,
1182
+ # only the ordinary habit of quoting a path, which is mandatory once it contains a space. The placeholder
1183
+ # keeps adjacency too, so `-m"msg"` becomes `-mQ` (an attached value, correct) and not `-m Q`.
1184
+ # Double quotes go FIRST: an apostrophe inside a double-quoted message (`-m "don't"`) is ordinary, whereas
1185
+ # a double quote inside a single-quoted one is rare, so this order mangles the rarer shape.
1186
+ while :; do
1187
+ case "$s" in *\"*\"*) ;; *) break ;; esac
1188
+ pre="${s%%\"*}"; rest="${s#*\"}"; rest="${rest#*\"}"; s="${pre}Q${rest}"
1189
+ done
1190
+ while :; do
1191
+ case "$s" in *\'*\'*) ;; *) break ;; esac
1192
+ pre="${s%%\'*}"; rest="${s#*\'}"; rest="${rest#*\'}"; s="${pre}Q${rest}"
1193
+ done
1194
+ # A backslash-newline is a LINE CONTINUATION, the opposite of a separator: it JOINS. Measured, before this,
1195
+ # `git commit \` + newline + ` -m c` refused the commit, because the lone `\` became a token and read as a
1196
+ # pathspec. It has to run before the conversion below, or the newline is gone when we look for it.
1197
+ s="${s//[\\]$'\n'/ }"
1198
+ # A NEWLINE IS A COMMAND SEPARATOR and has to become one, or a multi-line Bash call is misread: measured,
1199
+ # `git commit -m c` followed by a line `echo done` refused the commit, because `done` was read as a
1200
+ # pathspec. Splitting alone cannot save it — the default IFS eats newlines, so the boundary is gone by the
1201
+ # time the walk sees tokens.
1202
+ s="${s//$'\n'/;}"
1203
+ # SEPARATORS BECOME THEIR OWN TOKENS. Without this, `git commit -m c; echo done` refused the commit: the
1204
+ # token was `c;`, `-m` swallowed it whole, the separator inside it was never seen, and `echo` read as a
1205
+ # pathspec. The fail-open twin is worse and was measured too — in
1206
+ # `if true; then git commit -m c -- a.txt; fi` the pathspec token was `a.txt;`, which the `--` lookahead
1207
+ # dismissed as a separator, so the commit was ALLOWED. Padding fixes both at once, and `&&`/`||` simply
1208
+ # become two tokens, which the walk already treats as one boundary.
1209
+ s="${s//;/ ; }"
1210
+ s="${s//&/ & }"
1211
+ s="${s//|/ | }"
1212
+ # Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
1213
+ # commit could be judged on whatever files happen to sit there.
1214
+ local unglob=0
1215
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
1216
+ set -- $s
1217
+ [ "$unglob" = 1 ] && set +f
1218
+ local tok seen=0 incommit=0
1219
+ while [ $# -gt 0 ]; do
1220
+ tok="$1"; shift
1221
+ if [ "$incommit" = 0 ]; then
1222
+ # Before `commit`: find git and its global options. A shell separator resets the search, so the `git`
1223
+ # in `ls -la && git commit` is found and the one in `echo git commit -a > notes` is not credited twice.
1224
+ case "$tok" in
1225
+ *[\;\&\|]*) seen=0 ;;
1226
+ git|git.exe|*/git|*/git.exe) seen=1 ;;
1227
+ commit) [ "$seen" = 1 ] && incommit=1 ;;
1228
+ -C|--git-dir|--work-tree) [ "$seen" = 1 ] && _C46_REDIR=1; shift ;;
1229
+ --git-dir=*|--work-tree=*) [ "$seen" = 1 ] && _C46_REDIR=1 ;;
1230
+ -c|--namespace|--config-env|--super-prefix|--exec-path) [ "$seen" = 1 ] && shift ;;
1231
+ -*) ;;
1232
+ *) seen=0 ;;
1233
+ esac
1234
+ continue
1235
+ fi
1236
+ # Inside this commit's own arguments. The long forms are globbed where git itself accepts an unambiguous
1237
+ # abbreviation (`--incl`, `--onl`), and `--intera*` rather than `--inter*` so the value-taking
1238
+ # `--inter-hunk-context` is not mistaken for `--interactive`.
1239
+ case "$tok" in
1240
+ *[\;\&\|]*) break ;;
1241
+ # A REDIRECTION is found by the character rather than the spelling, so `> log`, `>log`, `>>log`,
1242
+ # `2> err` and a heredoc's `<<EOF` are all covered. Measured false positives before this existed:
1243
+ # `> log.txt` and `2> err` left `log.txt` / `2` looking like pathspecs, and `git commit -F - <<EOF`
1244
+ # read the delimiter word as one. All of it fires only INSIDE the commit's own arguments — a
1245
+ # redirection belonging to an earlier command, as in `echo x > f && git commit -m c -- a.txt`, is
1246
+ # ignored and that pathspec is still refused.
1247
+ #
1248
+ # A HEREDOC ends the arguments: what follows is input and a delimiter word, not paths.
1249
+ *'<<'*) break ;;
1250
+ # Any other redirection is SKIPPED OVER, not treated as the end. Breaking here was the first version
1251
+ # and a Windows session measured what it cost: `git commit -m c > log.txt -- a.txt` returned rc=0 AND
1252
+ # the commit carried the unreviewed line, because the scan stopped before reaching the pathspec. The
1253
+ # boundary was documented as "nobody writes that", which is a guess about likelihood, while the leak
1254
+ # is a fact — so it is closed instead. The target is consumed only when the token ENDS in the operator
1255
+ # (`> log`, separate); an attached one (`>log`, `2>&1`) carries its own. And a separator is never
1256
+ # consumed as a target, which is what keeps `2>&1 | tee log` from reading `1` as a pathspec.
1257
+ *[\<\>]*)
1258
+ case "$tok" in
1259
+ *[\<\>]) case "${1:-}" in ''|*[\;\&\|]*) ;; *) shift ;; esac ;;
1260
+ esac ;;
1261
+ # A BARE `--` is not a pathspec: `git commit -m msg --` was measured committing cleanly, from the index.
1262
+ # Only a token after it is one, and a shell separator there is the next command, not a path.
1263
+ --) if [ $# -gt 0 ]; then
1264
+ case "$1" in *[\;\&\|]*) ;; *) _C46_WT="a pathspec after --" ;; esac
1265
+ fi
1266
+ break ;;
1267
+ --al|--all) _C46_WT="--all" ;;
1268
+ --on|--onl|--only) _C46_WT="--only" ;;
1269
+ --inc*) _C46_WT="--include" ;;
1270
+ --intera*) _C46_WT="--interactive" ;;
1271
+ --pat*) _C46_WT="--patch / --pathspec-from-file" ;;
1272
+ # Flags whose value is MANDATORY and may be a separate token, so the token after them is not a path.
1273
+ # `-S`/`--gpg-sign` and `-u`/`--untracked-files` are deliberately NOT here: their value is OPTIONAL and
1274
+ # has to be attached (`-Skeyid`, `-uall`), so they swallow nothing — listing them made `git commit -S -m x`
1275
+ # read `x` as a pathspec and refuse an ordinary signed commit.
1276
+ -m|-F|-t|-U|-c|-C|--message|--file|--template|--unified|--author|--date|--cleanup|--trailer|--fixup|--squash|--reedit-message|--reuse-message|--inter-hunk-context)
1277
+ # Only swallow a token that can BE a value. A separator there means the flag was left without one
1278
+ # (`git commit -m ; echo x`), and eating it would hide the boundary and read `echo` as a pathspec.
1279
+ case "${1:-}" in ''|*[\;\&\|]*) ;; *) shift ;; esac ;;
1280
+ --*) ;;
1281
+ # Any other short token is a CLUSTER, and every letter in it is its own flag — `-qam` is `-q -a -m`.
1282
+ # This is why the test is a character class and not an equality check against `-a`.
1283
+ #
1284
+ # And if the cluster ENDS in a value-taking letter, the token after it is that VALUE, not a path:
1285
+ # `-qm x` is `-q -m x`, an ordinary commit. Without this, `git commit -qm x` was refused while
1286
+ # `git commit -qm "x"` was allowed — the quote strip hid the bug in the quoted form, which is how it
1287
+ # survived a full pass of the suite and a Windows run of 38 cases. The condition is exact rather than
1288
+ # "contains m": if the value were attached the cluster would not END with the letter (`-mq` is `-m q`,
1289
+ # message `q`, and the next token there really is a pathspec).
1290
+ -*) case "$tok" in *[aoip]*) _C46_WT="a short flag with a/o/i/p in it" ;; esac
1291
+ case "$tok" in *[mFtUcC]) case "${1:-}" in ''|*[\;\&\|]*) ;; *) shift ;; esac ;; esac ;;
1292
+ *) _C46_WT="a pathspec" ;;
1293
+ esac
1294
+ done
1295
+ }
1296
+ _c46_scan "$CMD"
1297
+ # The record describes THIS worktree. A command that points git at another one would have us hash the wrong
1298
+ # repository and pass it off as verified, so the ambiguous form fails closed instead.
1299
+ if [ "$_C46_REDIR" = 1 ]; then
1300
+ gatelog BLOCK 4.6 "commit redirected at another worktree"
1301
+ echo "GUARD (§4.6): this commit points git at another worktree (-C / --git-dir / --work-tree)." >&2
1302
+ echo "The review record describes the staged diff of THIS worktree, so it cannot vouch for that one." >&2
1303
+ echo "Run the commit from that directory, or run it yourself in your terminal." >&2
1304
+ exit 2
1305
+ fi
1306
+ if [ -n "$_C46_WT" ]; then
1307
+ gatelog BLOCK 4.6 "commit takes content from the working tree"
1308
+ echo "GUARD (§4.6): this commit takes its content from the working tree ($_C46_WT), not from the" >&2
1309
+ echo "index — so git commits what is in your files, and the review record is about what is staged." >&2
1310
+ echo "Those are different things, and that is how unreviewed lines get in." >&2
1311
+ echo "Stage exactly what you mean with 'git add <paths>', then commit with no paths and no -a." >&2
1312
+ exit 2
1313
+ fi
1314
+
1315
+ # CREW-REVIEW-PASS (this recipe is kept identical in agents/crew-review-agent.md; smoke-test pins the pair)
1316
+ # GIT does the hashing, not sha256sum/shasum, and the reason is the one that survives BOTH platforms —
1317
+ # because the first two reasons written here did not. Measured on macOS: the suite's sandbox reaches its
1318
+ # minimal tier (a PATH of awk/sed/grep/head/cat/tr/git/cut built from symlinks), no hasher exists there, and
1319
+ # the first version of this gate computed an EMPTY hash and blocked every commit — failing for a missing tool
1320
+ # instead of a missing review. Measured on Windows: that tier cannot be built at all (`ln -s` yields no real
1321
+ # symlink on that filesystem), the helper falls back to stubbing jq/python3 over the full PATH, and
1322
+ # /usr/bin/sha256sum is right there — so this branch is never exercised on Windows and "the hashers are
1323
+ # missing" was never a portable reason for anything. The portable reason: git cannot be absent where a commit
1324
+ # is being gated, since it is the thing under gate. Plus one process instead of a probe and a hasher, and no
1325
+ # repo required. SHA-1 is fine here: this detects a changed diff, it is not a boundary against a forger —
1326
+ # anyone who can write the record can write any value into it.
1327
+ # RESOLVE AGAINST THE PAYLOAD'S cwd, not this process's. This file already learned that lesson for the
1328
+ # `.env` rule above, and §4.6 shipped without it: measured on Windows, a process cwd of `/c` with a per-
1329
+ # fectly valid record sitting in the project produced rc=2 and the message "nothing has reviewed this
1330
+ # diff" — fail-closed, but on a false premise, which sends the user to re-run the reviewer forever. The
1331
+ # git queries move too, not just the path: a staged diff read in the wrong worktree is the same defect
1332
+ # wearing different clothes. `_CWD` is "" when the payload carried none, and `-C .` is then a no-op.
1333
+ _RPD="${_CWD:-.}"
1334
+ RP="$_RPD/.claude/review-pass.json"
1335
+ if [ ! -f "$RP" ]; then
1336
+ gatelog BLOCK 4.6 "no review-pass record"
1337
+ echo "GUARD (§4.6): nothing has reviewed this diff — '$RP' does not exist." >&2
1338
+ echo "Run @agent-crew-review-agent on the staged diff; a clean verdict writes the record." >&2
1339
+ echo "To skip it deliberately, run the commit yourself in your terminal." >&2
1340
+ exit 2
1341
+ fi
1342
+ # Read it with SHELL BUILTINS — no `tr`, no `cat`, no subshell. This runs on every commit and a process is
1343
+ # 62-135 ms on Git Bash; measured there, each fork taken off this path was worth ~70 ms.
1344
+ # `read -r` drops the newline. `${_l%$'\r'}` drops ONE carriage return per line if the record arrived CRLF.
1345
+ # Its scope is line endings and nothing more, stated that way because the first comment here claimed it
1346
+ # would "matter to a record some other tool reformats" and that was MEASURED FALSE: a reformatter puts a
1347
+ # space after the colon, `"diff_oid": "…"`, which this reader rejects whatever the line endings are (the
1348
+ # `#*\"$1\":\"` search wants them adjacent). So a reformatted record is refused either way; the strip only
1349
+ # covers CRLF. In the flat shape the recipe writes, even that is belt-and-braces — the CR lands after the
1350
+ # final `}`, outside every value, where `%%"*` already cuts it — so no fixture can tell the strip from its
1351
+ # absence and none of them claims to.
1352
+ RPJ=""; while IFS= read -r _l || [ -n "$_l" ]; do RPJ="$RPJ${_l%$'\r'}"; done < "$RP"
1353
+ _rpf(){ _r="${RPJ#*\"$1\":\"}"; [ "$_r" = "$RPJ" ] && return 1; printf '%s' "${_r%%\"*}"; }
1354
+ WANT_D="$(_rpf diff_oid || true)"; WANT_H="$(_rpf head || true)"
1355
+ HAVE_D="$(git -C "$_RPD" diff --cached 2>/dev/null | git hash-object --stdin 2>/dev/null)"
1356
+ # --verify --quiet, not a bare `git rev-parse HEAD`: on an UNBORN head the bare form prints the literal
1357
+ # string "HEAD" on stdout and still fails, so `|| echo NONE` appended to it and the value became two
1358
+ # lines ("HEAD" then "NONE") — which never matches any record. Measured on a fresh `git init`.
1359
+ HAVE_H="$(git -C "$_RPD" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
1360
+ if [ -z "$WANT_D" ] || [ "$WANT_D" != "$HAVE_D" ] || [ "$WANT_H" != "$HAVE_H" ]; then
1361
+ gatelog BLOCK 4.6 "review-pass does not match this diff"
1362
+ echo "GUARD (§4.6): the review record does not describe what is staged now." >&2
1363
+ # The inputs are printed because a gate that only says "no" is a gate nobody can debug.
1364
+ echo " reviewed diff : ${WANT_D:-<missing>}" >&2
1365
+ echo " staged diff : ${HAVE_D:-<none>}" >&2
1366
+ echo " reviewed HEAD : ${WANT_H:-<missing>}" >&2
1367
+ echo " current HEAD : ${HAVE_H}" >&2
1368
+ echo "Re-run @agent-crew-review-agent on the diff as it stands; the record it writes is the one that" >&2
1369
+ echo "matches. To skip it deliberately, run the commit yourself in your terminal." >&2
1370
+ exit 2
1371
+ fi
1372
+ gatelog ALLOW 4.6 "review-pass matches the staged diff"
1373
+ fi
1374
+ # WHICH MODES CAN ACTUALLY ASK A PERSON. `default` and `acceptEdits` show the prompt to the human and wait.
1375
+ # `auto` and `dontAsk` do not: in `auto` the permission prompt is answered by the auto-mode classifier, and
1376
+ # `dontAsk` is by definition the mode where nothing is asked. The hook still returns "ask" there, the prompt
1377
+ # is still raised, and it is still approved — by software. Measured in a real session: 14 `ASK §4.4` lines in
1378
+ # the gate log, 14 commits and pushes through, zero human keypresses. The gate fired, logged itself, and
1379
+ # stopped nothing, while DISCIPLINE.md promised an approval "only the user can answer".
1380
+ #
1381
+ # Those two therefore belong in the fail-closed branch, with bypassPermissions and plan. The rule Crewforth
1382
+ # has carried from the start is that a commit or a push does not happen without the user, and a mode where
1383
+ # the answer comes from a classifier is a mode where it cannot be proven that it did. A gate whose prompt is
1384
+ # answered by the thing it is gating is not a gate.
1385
+ case "$PERM_MODE" in
1386
+ default|acceptEdits)
1387
+ # A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
1388
+ SHORT="$CMD"
1389
+ [ "${#SHORT}" -gt 300 ] && SHORT="$(printf '%s' "$SHORT" | cut -c1-300)…"
1390
+ # §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
1391
+ # lives on main), but it is surfaced in the approval prompt so the user can send it to a branch instead.
1392
+ BRANCH_WARN=""
1393
+ # symbolic-ref names the branch even before its first commit, where rev-parse answers "HEAD" and the very
1394
+ # first commit onto main went by without this line. A detached HEAD reads empty: not the default branch.
1395
+ case "$(git symbolic-ref --short -q HEAD 2>/dev/null)" in
1396
+ main|master) BRANCH_WARN="⚠️ This commits DIRECTLY to the default branch. Prefer a feature branch unless you meant to.
1397
+
1398
+ " ;;
1399
+ esac
1400
+ ask_user "§4.4 commit/push approval gate. Claude wants to run:
1401
+
1402
+ $SHORT
1403
+
1404
+ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you agree with it. Approving lets Claude run the command itself."
1405
+ ;;
1406
+ *)
1407
+ # bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
1408
+ # human, so we fail closed rather than let the gate silently evaporate.
1409
+ gatelog BLOCK 4.4 "commit/push under a mode that cannot prompt (${PERM_MODE:-unknown})"
1410
+ echo "GUARD (§4.4): 'git commit/push' is gated by approval AT THE TOOL LEVEL, and this session's permission mode ('${PERM_MODE:-unknown}') cannot put that prompt in front of a person." >&2
1411
+ echo "Present the commit MESSAGE to the user and get EXPLICIT approval. Then one of:" >&2
1412
+ echo " (a) the user presses Shift+Tab to switch to default/acceptEdits — IN THIS SESSION, no restart — and this gate asks them directly, OR" >&2
1413
+ echo " (b) the NEXT session is started with 'CLAUDE_GIT_OK=1' (headless/CI) — the key cannot be added to a session already running. It covers the §4.4 APPROVAL set (commit · push; staging and branching need no key) and nothing else;" >&2
1414
+ echo " force-push, git add -f, hook tampering and the §4.5 destructive set all still block, OR" >&2
1415
+ echo " (c) the user runs the command in their own terminal." >&2
1416
+ exit 2 ;;
1417
+ esac
1418
+ fi
1419
+
1420
+ # §4.4 — STAGING AND BRANCHING ARE FREE, IN EVERY MODE. Commit and push are the approval set; `git add` and
1421
+ # creating a branch are not, by the user's decision: neither publishes anything, both are undone locally, and
1422
+ # asking for them made auto mode stop and demand a mode switch for work that cannot hurt anyone. So this hook
1423
+ # returns NO decision for them in any mode, and Crewforth's settings.json already allows Bash — they simply run.
1424
+ #
1425
+ # What stays gated around them, unchanged: `git add -f` is §4.5 (it bypasses a .gitignore rule) and blocks above;
1426
+ # forced branch operations (-D/-f/-M/-C) block above; commit and push fail closed where nobody can be asked and
1427
+ # ask where someone can. CLAUDE_GIT_OK still returns an explicit allow for add and branch creation (the block
1428
+ # before §4.6), which a headless session needs when its own settings do not allow Bash.
1429
+ #
1430
+ # Branch creation used to be matched here in every spelling git accepts (checkout -b/-B/--orphan, switch
1431
+ # -c/-C/--create/--force-create/--orphan, with global options in front) so that default mode could ask. That
1432
+ # matcher is gone with the question; smoke §4e now pins the opposite — every spelling runs without a prompt.
1433
+ exit 0