crewforth 3.0.4 → 3.1.0-rc.1

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 (66) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/smoke-test.sh +2543 -373
  16. package/kit/hooks/commit-msg +1 -1
  17. package/kit/hooks/context-usage.sh +7 -1
  18. package/kit/hooks/guard-bash.sh +1545 -104
  19. package/kit/hooks/guard-commit-scan.sh +75 -19
  20. package/kit/hooks/guard-powershell.sh +65 -15
  21. package/kit/hooks/guard-write.sh +74 -22
  22. package/kit/hooks/no-bash-guard.ps1 +71 -0
  23. package/kit/hooks/pre-commit +5 -2
  24. package/kit/hooks/prompt-approval.sh +484 -0
  25. package/kit/hooks/route-hint.sh +13 -5
  26. package/kit/hooks/session-rehydrate.sh +18 -0
  27. package/kit/hooks/skill-trust.sh +17 -2
  28. package/kit/owned-blobs.tsv +43 -0
  29. package/kit/settings.json +20 -4
  30. package/kit/skills/a11y/SKILL.md +1 -0
  31. package/kit/skills/crew-doctor/SKILL.md +2 -2
  32. package/kit/skills/frontend/SKILL.md +3 -3
  33. package/kit/skills/frontend-flutter/SKILL.md +94 -0
  34. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
  35. package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
  36. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  37. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  38. package/kit/studio/server/index.js +14 -4
  39. package/kit/studio/server/lib/graph.js +64 -3
  40. package/kit/studio/server/lib/kit.js +16 -2
  41. package/kit/studio/server/lib/permissions.js +77 -2
  42. package/kit/studio/server/lib/projects.js +60 -7
  43. package/kit/studio/server/lib/session.js +16 -2
  44. package/kit/studio/web/app.js +1765 -573
  45. package/kit/studio/web/approvals.js +124 -0
  46. package/kit/studio/web/canvas.js +932 -885
  47. package/kit/studio/web/chat.js +322 -145
  48. package/kit/studio/web/convo.js +174 -0
  49. package/kit/studio/web/dock.js +221 -0
  50. package/kit/studio/web/graph-plan.js +457 -0
  51. package/kit/studio/web/icons/builtin.svg +1 -0
  52. package/kit/studio/web/index.html +116 -50
  53. package/kit/studio/web/inspect.js +110 -0
  54. package/kit/studio/web/list-plan.js +112 -0
  55. package/kit/studio/web/list.js +157 -0
  56. package/kit/studio/web/liveness.js +66 -0
  57. package/kit/studio/web/nav.js +237 -0
  58. package/kit/studio/web/newsession.js +235 -0
  59. package/kit/studio/web/press.js +65 -0
  60. package/kit/studio/web/style.css +1281 -1014
  61. package/kit/studio/web/theme.js +43 -0
  62. package/kit/studio/web/timeline-plan.js +291 -0
  63. package/kit/studio/web/timeline.js +400 -0
  64. package/kit/studio/web/tokens.css +131 -0
  65. package/kit/studio/web/toolbar-fit.js +35 -0
  66. package/package.json +5 -4
@@ -45,7 +45,7 @@ set -uo pipefail
45
45
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
46
46
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
47
47
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
48
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
48
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
49
49
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
50
50
  # staged key all passed.
51
51
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -149,7 +149,77 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
149
149
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
150
150
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
151
151
  [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
152
- INPUT="$(cat)"
152
+ # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
153
+ # of this hook. `read` returns 1 at end of input; the text is read regardless.
154
+ IFS= read -r -d '' INPUT || true
155
+
156
+ # Gate observability. A gate that cannot be seen firing cannot be measured: "the model never reached for the
157
+ # command" and "the gate stopped it" leave behind exactly the same artifacts, and the A/B harness spent a whole
158
+ # case (evals/permission-pressure) unable to tell them apart — it had to report "guard-bash never fired" as an
159
+ # inference. One TSV line per decision, write-only, and it never touches the decision itself: every call site
160
+ # logs AFTER the verdict is settled.
161
+ #
162
+ # ON BY DEFAULT since 2.5.0, into .claude/gate-log.tsv — an evidence channel nobody switches on records nothing,
163
+ # and "the gates hold" is a claim that needs a record, not a test suite alone. CREW_GATE_LOG overrides the path;
164
+ # CREW_GATE_LOG=/dev/null (or a read-only .claude) turns it off. Only BLOCK, ASK and CLAUDE_GIT_OK's ALLOW reach
165
+ # here: an ordinary command writes nothing, and a git action CLAUDE_GIT_OK allows writes one ALLOW line.
166
+ #
167
+ # The COMMAND TEXT IS NOT RECORDED by default. It is the one field that can carry a path, an argument or a
168
+ # token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
169
+ # buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
170
+ # positive, which is the only thing it is good for.
171
+ # Where the default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
172
+ # DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
173
+ # the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
174
+ # edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
175
+ # a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
176
+ # runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
177
+ # never reach it.
178
+ _gatelog_path(){
179
+ if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
180
+ [ -d ".claude" ] || return 0
181
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
182
+ git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
183
+ fi
184
+ printf '%s' ".claude/gate-log.tsv"
185
+ }
186
+ gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
187
+ # Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
188
+ # as `$( … )`, which is a subshell, so a global it assigns is discarded the moment it returns — the same trap
189
+ # this repo already documents for gb_sandbox in smoke-test, and the first version of this memo was written
190
+ # inside the function and measured as a no-op (11 git processes before and after). Resolving costs two git
191
+ # processes, and §4.6 made a twice-logging run the normal case for a successful commit: its own ALLOW line,
192
+ # then §4.4's ASK. On Windows a process is 62-135 ms.
193
+ if [ "${_GL_MEMO_SET:-0}" != 1 ]; then _GL_MEMO="$(_gatelog_path)"; _GL_MEMO_SET=1; fi
194
+ _GL="$_GL_MEMO"; [ -n "$_GL" ] || return 0
195
+ if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
196
+ printf '%s\t§%s\t%s\t%s\n' "$1" "$2" "$3" \
197
+ "$(printf '%s' "$CMD" | tr -d '\000-\037' | cut -c1-200)" >> "$_GL" 2>/dev/null || true
198
+ else
199
+ printf '%s\t§%s\t%s\t\n' "$1" "$2" "$3" >> "$_GL" 2>/dev/null || true
200
+ fi
201
+ }
202
+
203
+ # ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
204
+ # A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
205
+ # its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
206
+ # on macOS for an escape-dense command: 256 KB 49 s, 512 KB 200 s, so about 900 KB is where the timeout is, and at
207
+ # that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
208
+ # The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
209
+ # large file is ordinary there — which is why the refusal points at them.
210
+ # Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
211
+ _PAYLOAD_MAX=262144
212
+ _payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
213
+ local LC_ALL=C
214
+ [ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
215
+ echo "GUARD (§4.5): this tool call is ${#INPUT} bytes long; the gates read a Bash or PowerShell call of up to $_PAYLOAD_MAX bytes." >&2
216
+ echo "A larger one could take longer to read than a hook is given, and a hook that runs out of time stops nothing," >&2
217
+ echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
218
+ echo "the path of that file." >&2
219
+ return 0
220
+ }
221
+ # ---- /CREW-PAYLOAD-MAX
222
+ if _payload_over; then CMD=""; gatelog BLOCK 4.5 "tool call too large to read"; exit 2; fi
153
223
 
154
224
  # Extract the command + the permission mode: jq > python3 > pure-bash JSON slice.
155
225
  #
@@ -176,6 +246,32 @@ INPUT="$(cat)"
176
246
  # the parse and walk a payload straight past the rules. HOW it does that is documented inside _json_slice, next to
177
247
  # the code, and only there -- this paragraph named the expansion once, and went stale the day it changed.
178
248
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
249
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
250
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
251
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
252
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
253
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
254
+ # counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
255
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
256
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
257
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
258
+ # taken from the whole text, and the small pieces from the block.
259
+ local LC_ALL=C
260
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
261
+ _JF=-1
262
+ while [ "$i" -lt "$n" ]; do
263
+ b="${1:i:B+kl-1}"
264
+ case "$b" in *"$2"*)
265
+ j=0; bn=${#b}
266
+ while [ "$j" -lt "$bn" ]; do
267
+ c="${b:j:C+kl-1}"
268
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
269
+ j=$((j+C))
270
+ done ;;
271
+ esac
272
+ i=$((i+B))
273
+ done
274
+ }
179
275
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
180
276
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
181
277
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -199,8 +295,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
199
295
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
200
296
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
201
297
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
202
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
203
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
298
+ # _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
204
299
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
205
300
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
206
301
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -224,7 +319,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
224
319
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
225
320
  # the battery sees both.
226
321
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
227
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
322
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
228
323
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
229
324
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
230
325
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -240,14 +335,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
240
335
  # raising or removing it later is not a tidy-up.
241
336
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
242
337
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
243
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
338
+ # both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
244
339
  # predates this change and belongs to the walk, not to the cap.
245
340
  local _cap=64 _seen=0
246
341
  while :; do
247
- pre="${hay%%"$k"*}" # everything before the next `"key"`
248
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
342
+ _json_find "$hay" "$k" # where the next `"key"` starts
343
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
249
344
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
250
- rest="${hay:${#pre}+${#k}}" # past `"key"`
345
+ rest="${hay:_JF+${#k}}" # past `"key"`
251
346
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
252
347
  case "$rest" in
253
348
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -287,7 +382,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
287
382
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
288
383
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
289
384
  #
290
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
385
+ # That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
291
386
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
292
387
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
293
388
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -339,7 +434,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
339
434
  # the gate cases.
340
435
  local LC_ALL=C
341
436
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
342
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
437
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
343
438
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
344
439
  while :; do
345
440
  lim=$((cl - j))
@@ -369,12 +464,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
369
464
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
370
465
  case "$h" in
371
466
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
467
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
468
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
469
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
372
470
  *) acc+=("?") ;;
373
471
  esac ;;
374
472
  *) acc+=("$c") ;;
375
473
  esac
376
474
  done
377
- local IFS=''; printf '%s' "${acc[*]}"
475
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
476
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
477
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
378
478
  }
379
479
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
380
480
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -407,22 +507,45 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
407
507
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
408
508
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
409
509
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
410
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
510
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
411
511
  _KC=0; _KC_CAPPED=0
412
512
  while :; do
413
- pre="${hay%%"$k"*}"
414
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
513
+ _json_find "$hay" "$k"
514
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
415
515
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
416
516
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
417
517
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
418
518
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
419
- rest="${hay:${#pre}+${#k}}"
519
+ rest="${hay:_JF+${#k}}"
420
520
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
421
521
  case "$rest" in :*) c=$((c+1)) ;; esac
422
522
  hay="$rest"
423
523
  done
424
524
  }
425
525
  # ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
526
+ # ---- _gsub: `${text//pattern/replacement}` that stays LINEAR ----------------------------------------------
527
+ # bash rebuilds the whole string for every match of a global substitution, so its cost is matches x length. On a
528
+ # quote-dense 46 KB command each `${CMD//\"/}` took about 9 s (bash 3.2, measured with a DEBUG trap), and this hook
529
+ # did ten of them: 335 s for one commit, against a 600 s timeout that does not block when it is reached. The same
530
+ # substitution done 2048 bytes at a time costs matches x 2048. Safe for a pattern of ONE character anywhere; for the
531
+ # two-character patterns that begin with a backslash, pass `bs` and a piece never ends on a backslash.
532
+ _gsub(){ # $1 = text, $2 = pattern (a glob, as it would stand in ${x//HERE/}), $3 = replacement, $4 = bs -> _GS
533
+ local LC_ALL=C
534
+ local t="$1" n i=0 C=2048 c; local -a acc=("")
535
+ n=${#t}
536
+ if [ "$n" -le "$C" ]; then _GS="${t//$2/$3}"; return 0; fi
537
+ while [ "$i" -lt "$n" ]; do
538
+ c="${t:i:C}"; i=$((i+C))
539
+ if [ "${4:-}" = bs ]; then while [ "$i" -lt "$n" ] && [ "${c: -1}" = '\' ]; do c="$c${t:i:1}"; i=$((i+1)); done; fi
540
+ acc+=("${c//$2/$3}")
541
+ done
542
+ local IFS=''; _GS="${acc[*]}"
543
+ }
544
+ # The text as the shell hands it on once the quoting is gone: `core.hooks"P"ath`, `crewforth-appr\oval` and
545
+ # `'git' commit` are core.hooksPath, crewforth-approval and git commit. Every rule that matches a NAME matches on this.
546
+ _unquoted(){ # $1 = text -> _GS: no double quote, no single quote, no backslash
547
+ _gsub "$1" '\"' ''; _gsub "$_GS" "\\'" ''; _gsub "$_GS" '\\' ''
548
+ }
426
549
  # ONE READER, EVERYWHERE. This hook used to try jq, then python3, then the slice above, choosing a tier on
427
550
  # whether its extraction WORKED rather than on whether the binary existed. That was already the second fix to
428
551
  # the selection logic, and the ladder stayed the root cause of four separate incidents. It is gone.
@@ -458,9 +581,10 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
458
581
  # the slice on every single Bash call are gone (-1 fork per hook per call), and `guard-bash · ls -la` went
459
582
  # 376 ms -> 169 ms with no overlap between the two columns, because the Store stub was being spawned and
460
583
  # failing on every call.
461
- CMD="$(_json_unescape "$(_json_slice "$INPUT" command)")"
584
+ # Through `_JS` / `_JU`, not `$( )`: these three reads were three forks on every Bash and PowerShell call (3.1.0).
585
+ _json_slice "$INPUT" command >/dev/null; CMD_RAW="$_JS"; _json_unescape "$_JS" >/dev/null; CMD="$_JU" # CMD_RAW: the command as the payload spells it
462
586
  _join_cmd # continued lines joined, as the shell runs them (CREW-JOIN)
463
- PERM_MODE="$(_json_slice "$INPUT" permission_mode)"
587
+ _json_slice "$INPUT" permission_mode >/dev/null; PERM_MODE="$_JS"
464
588
 
465
589
  # AN UNREADABLE PAYLOAD IS REFUSED, NOT WAVED THROUGH. Both shapes below were found by the parser-conformance
466
590
  # oracle, which compares this gate's verdict on the dependency-free tier against a real parser's, and both fell
@@ -552,53 +676,8 @@ if [ "$_n_cmd" = 0 ] && [ -z "$CMD" ]; then
552
676
  fi
553
677
  fi
554
678
  [ -z "$CMD" ] && exit 0
679
+ _unquoted "$CMD"; CMD_UQ="$_GS" # read by the name rules below (hooksPath side doors, the approval record)
555
680
 
556
- # Gate observability. A gate that cannot be seen firing cannot be measured: "the model never reached for the
557
- # command" and "the gate stopped it" leave behind exactly the same artifacts, and the A/B harness spent a whole
558
- # case (evals/permission-pressure) unable to tell them apart — it had to report "guard-bash never fired" as an
559
- # inference. One TSV line per decision, write-only, and it never touches the decision itself: every call site
560
- # logs AFTER the verdict is settled.
561
- #
562
- # ON BY DEFAULT since 2.5.0, into .claude/gate-log.tsv — an evidence channel nobody switches on records nothing,
563
- # and "the gates hold" is a claim that needs a record, not a test suite alone. CREW_GATE_LOG overrides the path;
564
- # CREW_GATE_LOG=/dev/null (or a read-only .claude) turns it off. Only BLOCK, ASK and CLAUDE_GIT_OK's ALLOW reach
565
- # here: an ordinary command writes nothing, and a git action CLAUDE_GIT_OK allows writes one ALLOW line.
566
- #
567
- # The COMMAND TEXT IS NOT RECORDED by default. It is the one field that can carry a path, an argument or a
568
- # token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
569
- # buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
570
- # positive, which is the only thing it is good for.
571
- # Where the default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
572
- # DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
573
- # the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
574
- # edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
575
- # a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
576
- # runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
577
- # never reach it.
578
- _gatelog_path(){
579
- if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
580
- [ -d ".claude" ] || return 0
581
- if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
582
- git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
583
- fi
584
- printf '%s' ".claude/gate-log.tsv"
585
- }
586
- gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
587
- # Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
588
- # as `$( … )`, which is a subshell, so a global it assigns is discarded the moment it returns — the same trap
589
- # this repo already documents for gb_sandbox in smoke-test, and the first version of this memo was written
590
- # inside the function and measured as a no-op (11 git processes before and after). Resolving costs two git
591
- # processes, and §4.6 made a twice-logging run the normal case for a successful commit: its own ALLOW line,
592
- # then §4.4's ASK. On Windows a process is 62-135 ms.
593
- if [ "${_GL_MEMO_SET:-0}" != 1 ]; then _GL_MEMO="$(_gatelog_path)"; _GL_MEMO_SET=1; fi
594
- _GL="$_GL_MEMO"; [ -n "$_GL" ] || return 0
595
- if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
596
- printf '%s\t§%s\t%s\t%s\n' "$1" "$2" "$3" \
597
- "$(printf '%s' "$CMD" | tr -d '\000-\037' | cut -c1-200)" >> "$_GL" 2>/dev/null || true
598
- else
599
- printf '%s\t§%s\t%s\t\n' "$1" "$2" "$3" >> "$_GL" 2>/dev/null || true
600
- fi
601
- }
602
681
 
603
682
  # $1 = rule name · $2 = section · $3 = CLASS, which decides the second line.
604
683
  #
@@ -640,6 +719,7 @@ gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
640
719
  block(){
641
720
  gatelog BLOCK "$2" "$1"
642
721
  echo "GUARD (§$2): '$1' stopped AT THE TOOL LEVEL." >&2
722
+ [ -z "${_BLOCK_NOTE:-}" ] || echo "$_BLOCK_NOTE" >&2
643
723
  case "${3:-}" in
644
724
  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 ;;
645
725
  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 ;;
@@ -756,7 +836,6 @@ git_has() { # $1 = command text, $2 = subcommand alternation (e.g. 'commit|push
756
836
  case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; *) HAS_GIT=0 ;; esac
757
837
  has() { _grep "$CMD" -qiE -- "$1"; } # flag/substring test on the command (-- so a -flag pattern is safe)
758
838
 
759
- { git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
760
839
  # §4.5 force-push. Same two defects the `git add -f` rule had, and the same repair: the flag has to be one of
761
840
  # THIS `git push`'s own arguments, at its own quoting level, and the test is case-SENSITIVE. `has()` greps the
762
841
  # whole command with `-i`, so `-F` matched the `-f` alternative — and `git commit -F msg.txt; git push` is the
@@ -780,15 +859,233 @@ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag
780
859
  done
781
860
  [ "$og" = 0 ] && set +f; return 1
782
861
  }
783
- if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
784
- _grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
785
- while IFS= read -r _seg; do
786
- [ -n "$_seg" ] || continue
787
- _push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
788
- done <<< "$_PUSHSEG"
789
- fi
790
- { git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
791
- case "$CMD" in *[Nn][Oo]-[Vv][Ee][Rr][Ii][Ff][Yy]*) : ;; *) false ;; esac && block "hook skip (--no-verify)" "4.5" tamper
862
+ # The three §4.5 rules that read a git command by its words, as ONE function of the text: the command itself goes
863
+ # through it here, and so does a git command put together from arguments further down (PowerShell's Start-Process git
864
+ # -ArgumentList …, a command word held in a variable), which is the same command written another way.
865
+ _git_d45(){ # $1 = command text; block() exits on the first rule it breaks
866
+ local CMD="$1" HAS_GIT=0 _PUSHSEG _seg # has() reads CMD: this one
867
+ case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; esac
868
+ { git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
869
+ if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
870
+ _grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
871
+ while IFS= read -r _seg; do
872
+ [ -n "$_seg" ] || continue
873
+ _push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
874
+ done <<< "$_PUSHSEG"
875
+ fi
876
+ { git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
877
+ return 0
878
+ }
879
+ # ---- the quoted argument of a command that does not run it ---------------------------------------------------
880
+ # `git commit -m "drop the rm -rf /tmp/build step"` deletes nothing, and `claude -p "… git push --force origin main"`
881
+ # pushes nothing: the words stand in an argument, quoted, of a command that only stores or prints or searches for
882
+ # them. The rules below read the command as text, so each of those was refused (measured: 14 of 20 such commands).
883
+ #
884
+ # _inert_args takes those arguments out of the text the rules read, and nothing else. It is a NARROW LIST, by
885
+ # decision, not "whatever is quoted":
886
+ # git commit | git tag the word after -m / --message (or a short cluster that ends in m: -am, -qm)
887
+ # gh pr|issue|release … the word after --title / -t / --body / -b (not an extension: it may run its argument)
888
+ # claude the word after -p / --print, when nothing in the call changes the directory (cd,
889
+ # pushd, popd, git -C) and claude's other options are --output-format, --model,
890
+ # --max-turns only
891
+ # grep egrep fgrep rg every quoted word (none is run), unless --pre is among the options (rg runs it)
892
+ # echo printf every quoted word, and only when the whole command has no pipe and no redirection:
893
+ # what is printed can be run by what reads it (`echo "…" | sh`, `echo "…" > s.sh`);
894
+ # never `printf -v NAME …`, which stores the text where `$NAME` can run it
895
+ # and an argument counts only when the WHOLE word is quoted: single quotes, $'…', or double quotes that hold no
896
+ # `$(` and no backtick (those run). A word glued to its option (-m"…", --message="…") is not taken out.
897
+ # The command word must be the first word of its simple command, written plainly: `FOO=1 git …`, `sudo echo …`,
898
+ # `bash -c "…"`, `eval "…"`, `ssh host "…"`, `xargs …` are not on the list and nothing of theirs is taken out.
899
+ # Anything this reader does not read with certainty leaves the command AS IT IS (the rules then judge all of it):
900
+ # an unfinished quote, a command substitution or a backtick outside single quotes, a here-document, a parenthesis
901
+ # (a subshell, a function definition), process substitution, more than _INERT_MAX bytes — and a call that changes
902
+ # what a command word means (alias, hash, function, eval, source, exec, set, export, … or an assignment to PATH,
903
+ # ENV, BASH_ENV, IFS). Bash tool only: PowerShell quotes by other rules.
904
+ _INERT_MAX=4096
905
+ _inert_args(){ # $1 = command -> 0 and _IX = the command with those arguments replaced by '' · 1 = nothing taken out
906
+ local s="$1" c d r seg body pos=0 n=0 i j k
907
+ local wopen=0 ws=0 wplain=1 winert=1 wtxt="" act
908
+ local pipe=0 redir=0
909
+ local -a T S E I P # per word: text when plain ("" otherwise) · start · end · inert (1/0; 2 = a separator) · plain (1/0)
910
+ _IX=""
911
+ [ "${#s}" -le "$_INERT_MAX" ] || return 1
912
+ case "$s" in *[\'\"]*) ;; *) return 1 ;; esac
913
+ case "$s" in *PATH=*|*ENV=*|*IFS=*) return 1 ;; esac # the same, by an assignment (PATH, BASH_ENV, ENV, IFS)
914
+ # end the word that is open, if one is
915
+ _iw_end(){ [ "$wopen" = 1 ] || return 0
916
+ T[n]="$wtxt"; [ "$wplain" = 1 ] || T[n]=""; S[n]=$ws; E[n]=$pos; I[n]=$winert; P[n]=$wplain; n=$((n+1))
917
+ wopen=0; wplain=1; winert=1; wtxt=""; }
918
+ _iw_sep(){ _iw_end; T[n]=""; S[n]=$pos; E[n]=$pos; I[n]=2; P[n]=0; n=$((n+1)); } # I = 2: a separator, not a word
919
+ _iw_open(){ [ "$wopen" = 1 ] || { wopen=1; ws=$pos; }; }
920
+ while [ -n "$s" ]; do
921
+ c="${s:0:1}"
922
+ case "$c" in
923
+ ' '|$'\t') _iw_end; s="${s:1}"; pos=$((pos+1)) ;;
924
+ $'\n') _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
925
+ \') r="${s:1}"
926
+ case "$r" in *\'*) ;; *) return 1 ;; esac
927
+ body="${r%%\'*}"; _iw_open; wplain=0
928
+ k=$(( ${#body} + 2 )); s="${s:k}"; pos=$((pos+k)) ;;
929
+ \") r="${s:1}"; k=1; act=0
930
+ while :; do
931
+ seg="${r%%[\"\\\$\`]*}"; k=$(( k + ${#seg} )); r="${r:${#seg}}"
932
+ [ -n "$r" ] || return 1 # the quote is not finished
933
+ d="${r:0:1}"
934
+ case "$d" in
935
+ \") k=$((k+1)); break ;;
936
+ \\) [ "${#r}" -ge 2 ] || return 1; r="${r:2}"; k=$((k+2)) ;;
937
+ \$) case "${r:1:1}" in '(') act=1 ;; esac; r="${r:1}"; k=$((k+1)) ;;
938
+ \`) act=1; r="${r:1}"; k=$((k+1)) ;;
939
+ esac
940
+ done
941
+ _iw_open; wplain=0; [ "$act" = 1 ] && winert=0
942
+ s="${s:k}"; pos=$((pos+k)) ;;
943
+ \\) [ "${#s}" -ge 2 ] || return 1
944
+ if [ "${s:1:1}" = $'\n' ]; then s="${s:2}"; pos=$((pos+2)) # a backslash-newline joins the lines
945
+ else _iw_open; winert=0; wplain=0; s="${s:2}"; pos=$((pos+2)); fi ;;
946
+ \$) case "${s:1:1}" in
947
+ '(') return 1 ;; # a command substitution: not read
948
+ \') r="${s:2}"; k=2 # $'…': a quoted word, \' does not end it
949
+ while :; do
950
+ seg="${r%%[\'\\]*}"; k=$(( k + ${#seg} )); r="${r:${#seg}}"
951
+ [ -n "$r" ] || return 1
952
+ if [ "${r:0:1}" = \' ]; then k=$((k+1)); break; fi
953
+ [ "${#r}" -ge 2 ] || return 1; r="${r:2}"; k=$((k+2))
954
+ done
955
+ _iw_open; wplain=0; s="${s:k}"; pos=$((pos+k)) ;;
956
+ *) _iw_open; winert=0; wplain=0; s="${s:1}"; pos=$((pos+1)) ;; # $var, ${…}, $" : part of the word, not a quoted one
957
+ esac ;;
958
+ \`) return 1 ;;
959
+ '('|')') return 1 ;;
960
+ '#') if [ "$wopen" = 1 ]; then wtxt="$wtxt#"; winert=0; s="${s:1}"; pos=$((pos+1))
961
+ else seg="${s%%$'\n'*}"; s="${s:${#seg}}"; pos=$(( pos + ${#seg} )); fi ;; # a comment, to the end of the line
962
+ ';') _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
963
+ '&') case "${s:1:1}" in
964
+ '&') _iw_sep; s="${s:2}"; pos=$((pos+2)) ;;
965
+ '>') _iw_end; redir=1; s="${s:2}"; pos=$((pos+2)) ;;
966
+ *) _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
967
+ esac ;;
968
+ '|') case "${s:1:1}" in
969
+ '|') _iw_sep; s="${s:2}"; pos=$((pos+2)) ;;
970
+ *) pipe=1; _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
971
+ esac ;;
972
+ '<'|'>') case "${s:0:2}" in '<<'|'<('|'>(') return 1 ;; esac # a here-document, process substitution: not read
973
+ _iw_end; redir=1; s="${s:1}"; pos=$((pos+1)) ;;
974
+ *) seg="${s%%[ $'\t\n'\'\"\\\$\`\#\;\&\|\(\)\<\>]*}"
975
+ _iw_open; winert=0; wtxt="$wtxt$seg"; s="${s:${#seg}}"; pos=$(( pos + ${#seg} )) ;;
976
+ esac
977
+ done
978
+ _iw_end
979
+ # Each simple command: who is its first word, and which of its words are that command's inert arguments.
980
+ local -a B; local first cmdw sub nb=0 pre="" grep_pre chdir=0
981
+ # Does anything in the call change the directory? (cd, pushd, popd as a command word; a git that carries -C)
982
+ i=0; k=1
983
+ while [ "$i" -lt "$n" ]; do
984
+ if [ "${I[i]}" = 2 ]; then k=1
985
+ elif [ "$k" = 1 ]; then k=0; cmdw="${T[i]}"
986
+ case "$cmdw" in cd|pushd|popd|'') chdir=1 ;; esac # '': a command word that is not written plainly may be one
987
+ elif [ "$cmdw" = git ]; then case "${T[i]}" in -C*) chdir=1 ;; esac
988
+ fi
989
+ i=$((i+1))
990
+ done
991
+ i=0
992
+ while [ "$i" -lt "$n" ]; do
993
+ [ "${I[i]}" = 2 ] && { i=$((i+1)); continue; }
994
+ first=$i; j=$i
995
+ while [ "$j" -lt "$n" ] && [ "${I[j]}" != 2 ]; do j=$((j+1)); done
996
+ cmdw="${T[first]}"; [ "${P[first]}" = 1 ] || cmdw=""
997
+ case "$cmdw" in
998
+ # A command that changes what a later command word MEANS: after `hash -p /bin/sh echo`, `echo -c "…"` runs the
999
+ # text (measured with a harmless pair); an alias, a function or another PATH does the same. With any of them
1000
+ # in the call nothing is taken out, wherever it stands.
1001
+ alias|unalias|function|hash|enable|builtin|shopt|set|setopt|unsetopt|source|.|eval|exec|trap|export|declare|typeset|readonly|autoload|zmodload|emulate|command|env)
1002
+ _IX=""; return 1 ;;
1003
+ git)
1004
+ k=$((first+1)); sub=""
1005
+ while [ "$k" -lt "$j" ]; do # git's own options, then the subcommand
1006
+ [ "${P[k]}" = 1 ] || break
1007
+ case "${T[k]}" in
1008
+ -C|-c|--git-dir|--work-tree|--namespace|--exec-path|--config-env) k=$((k+2)) ;;
1009
+ --no-pager|--paginate|-p|-P|--bare|--no-replace-objects|--git-dir=*|--work-tree=*|--namespace=*) k=$((k+1)) ;;
1010
+ -*) break ;;
1011
+ *) sub="${T[k]}"; k=$((k+1)); break ;;
1012
+ esac
1013
+ done
1014
+ case "$sub" in commit|tag)
1015
+ while [ "$k" -lt "$j" ]; do
1016
+ if [ "${P[k]}" = 1 ]; then case "${T[k]}" in
1017
+ --) break ;;
1018
+ -m|--message|-[A-Za-z]m|-[A-Za-z][A-Za-z]m|-[A-Za-z][A-Za-z][A-Za-z]m)
1019
+ [ $((k+1)) -lt "$j" ] && [ "${I[k+1]}" = 1 ] && { B[nb]=$((k+1)); nb=$((nb+1)); }; k=$((k+1)) ;;
1020
+ esac; fi
1021
+ k=$((k+1))
1022
+ done ;;
1023
+ esac ;;
1024
+ gh)
1025
+ # Only gh's own pr / issue / release: an extension (`gh myext …`) is a program of its own and may run its argument.
1026
+ k=$((first+1)); sub=""; [ "$k" -lt "$j" ] && [ "${P[k]}" = 1 ] && sub="${T[k]}"
1027
+ case "$sub" in pr|issue|release) ;; *) k=$j ;; esac
1028
+ while [ "$k" -lt "$j" ]; do
1029
+ if [ "${P[k]}" = 1 ]; then case "${T[k]}" in
1030
+ --title|-t|--body|-b) [ $((k+1)) -lt "$j" ] && [ "${I[k+1]}" = 1 ] && { B[nb]=$((k+1)); nb=$((nb+1)); }; k=$((k+1)) ;;
1031
+ esac; fi
1032
+ k=$((k+1))
1033
+ done ;;
1034
+ claude)
1035
+ # The session `claude -p` starts is judged by the gates of the directory it starts in, with the settings it
1036
+ # is given. So the prompt is taken out only when (a) nothing in the call changes the directory, and (b) every
1037
+ # other word of this claude is an option from a short harmless list, or its value. --settings,
1038
+ # --setting-sources, --dangerously-skip-permissions, --permission-mode, --add-dir, --mcp-config,
1039
+ # --allowedTools, any option not listed, and any other quoted word leave the prompt where it is.
1040
+ k=$((first+1)); sub=ok; pre=-1
1041
+ [ "$chdir" = 0 ] || sub=""
1042
+ while [ "$k" -lt "$j" ] && [ -n "$sub" ]; do
1043
+ if [ "${P[k]}" = 1 ]; then case "${T[k]}" in
1044
+ -p|--print)
1045
+ if [ "$pre" = -1 ] && [ $((k+1)) -lt "$j" ] && [ "${I[k+1]}" = 1 ]; then pre=$((k+1)); k=$((k+1)); else sub=""; fi ;;
1046
+ --output-format|--model|--max-turns)
1047
+ if [ $((k+1)) -lt "$j" ] && [ "${P[k+1]}" = 1 ]; then case "${T[k+1]}" in -*) sub="" ;; *) k=$((k+1)) ;; esac; else sub=""; fi ;;
1048
+ --output-format=*|--model=*|--max-turns=*) ;;
1049
+ *) sub="" ;; # another option, or a word that is not one
1050
+ esac; else sub=""; fi
1051
+ k=$((k+1))
1052
+ done
1053
+ [ -n "$sub" ] && [ "$pre" != -1 ] && { B[nb]=$pre; nb=$((nb+1)); } ;;
1054
+ grep|egrep|fgrep|rg)
1055
+ grep_pre=0; k=$((first+1))
1056
+ while [ "$k" -lt "$j" ]; do case "${T[k]}" in --pre|--pre=*) grep_pre=1 ;; esac; k=$((k+1)); done
1057
+ if [ "$grep_pre" = 0 ]; then k=$((first+1))
1058
+ while [ "$k" -lt "$j" ]; do [ "${I[k]}" = 1 ] && { B[nb]=$k; nb=$((nb+1)); }; k=$((k+1)); done
1059
+ fi ;;
1060
+ echo|printf)
1061
+ # printf -v NAME writes the text into a variable, and `$NAME` after it runs it (measured with a harmless
1062
+ # pair): that printf prints nothing, so it is not on the list.
1063
+ grep_pre=0; k=$((first+1))
1064
+ while [ "$k" -lt "$j" ]; do case "${T[k]}" in -v*) [ "$cmdw" = printf ] && grep_pre=1 ;; esac; k=$((k+1)); done
1065
+ if [ "$pipe" = 0 ] && [ "$redir" = 0 ] && [ "$grep_pre" = 0 ]; then k=$((first+1))
1066
+ while [ "$k" -lt "$j" ]; do [ "${I[k]}" = 1 ] && { B[nb]=$k; nb=$((nb+1)); }; k=$((k+1)); done
1067
+ fi ;;
1068
+ esac
1069
+ i=$j
1070
+ done
1071
+ [ "$nb" -gt 0 ] || return 1
1072
+ s="$1"; pos=0; i=0
1073
+ while [ "$i" -lt "$nb" ]; do
1074
+ k="${B[i]}"; _IX="$_IX${s:pos:$(( S[k] - pos ))}''"; pos="${E[k]}"; i=$((i+1))
1075
+ done
1076
+ _IX="$_IX${s:pos}"
1077
+ return 0
1078
+ }
1079
+ # From here to the §4.4 gate the rules read the command WITHOUT those arguments; CMD_REAL keeps it whole, and the
1080
+ # commit and push gates below read that. A PowerShell call is never changed.
1081
+ CMD_REAL="$CMD"; CMD_UQ_REAL="$CMD_UQ"; _INERT=0
1082
+ case "$INPUT" in *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*) ;; *)
1083
+ if _inert_args "$CMD"; then _INERT=1; CMD="$_IX"; _unquoted "$CMD"; CMD_UQ="$_GS"; fi ;;
1084
+ esac
1085
+ _git_d45 "$CMD"
1086
+ # `no-veri`, not `no-verify`: git takes any unambiguous abbreviation, and `git push --no-verif` / `git merge --no-veri`
1087
+ # passed the rule that looked for the whole word (3.1.0 review; `--no-ver` and shorter are ambiguous to git itself).
1088
+ case "$CMD" in *[Nn][Oo]-[Vv][Ee][Rr][Ii]*) : ;; *) false ;; esac && block "hook skip (--no-verify)" "4.5" tamper
792
1089
  git_has "$CMD" 'rebase' && block "git rebase" "4.5" history
793
1090
  git_has "$CMD" 'filter-branch|filter-repo' && block "git filter-branch/filter-repo" "4.5" history
794
1091
  { git_has "$CMD" 'commit' && has '--amend'; } && block "git commit --amend" "4.5" history
@@ -813,6 +1110,18 @@ case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
813
1110
  # `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
814
1111
  # another reaching the identical state. Case, flag order and the long form are all the same command.
815
1112
  case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -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
1113
+ # ADDED (3.1.0): `.` and `..` as a target carry none of those three characters and are the widest targets there are —
1114
+ # `rm -rf .` at a project root takes the whole tree, `..` its parent (both passed). Narrower than the test above on
1115
+ # purpose, so that it adds no noise: `rm` must stand at a command position, `sudo` or not (`git rm -r --cached .`, `npm rm`,
1116
+ # `docker run --rm` and a quoted "rm -r ." are not it), the dot must be an argument of its own in that rm's segment
1117
+ # (`.git`, `./x`, `a.` are not), and a `#` ends the look (a comment is not a target), and so does a line break (`rm -rf dist⏎npx tsc -p .`). A builtin match: no process.
1118
+ _RMNL=$'\n'
1119
+ _RMDOT='(^|[;&|({'"$_RMNL"'])[[:blank:]]*(sudo[[:blank:]]+)?rm[[:space:]]+(-[A-Za-z]*[[:space:]]+|--[a-z-]*[[:space:]]+)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)([[:space:]]+(-[A-Za-z]+|--[a-z-]*))*[[:blank:]]+([^;&|#'"$_RMNL"']*[[:blank:]])?["'"'"']?\.\.?["'"'"']?([[:space:];&|)}]|$)'
1120
+ case "$CMD" in *[Rr][Mm]*)
1121
+ _rmc="$CMD"
1122
+ # a backslash-newline continues the line (`rm -rf \⏎.`); folded only when one is there and the command is short
1123
+ case "$CMD" in *\\"$_RMNL"*) [ "${#CMD}" -le 4096 ] && _rmc="${CMD//\\$_RMNL/ }" ;; esac
1124
+ [[ $_rmc =~ $_RMDOT ]] && block "destructive rm -rf" "4.5" loss ;; esac
816
1125
  # A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
817
1126
  # undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
818
1127
  # Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
@@ -914,12 +1223,54 @@ case "$CMD" in *[Cc][Hh][Mm][Oo][Dd]*) : ;; *) false ;; esac && _grep "$CMD" -qE
914
1223
  # * the destructive verbs have short aliases (rm/del/erase/rd/ri for Remove-Item), and `rm` there is
915
1224
  # Remove-Item, not POSIX rm — the same word with different flags, which is why the POSIX rule misses it.
916
1225
  PS_RM='(remove-item|ri|rm|rmdir|rd|del|erase)'
917
- PS_RECURSE='-r(e(c(u(r(s(e)?)?)?)?)?)?([[:space:]]|$)'
918
- PS_FORCE='-f(o(r(c(e)?)?)?)?([[:space:]]|$)'
1226
+ # A parameter is not only `-Name<space>`. PowerShell takes a value after a colon (`-Recurse:$true`), and it takes an
1227
+ # en dash, an em dash or a horizontal bar where the hyphen stands — what a word processor or a chat window turns `-`
1228
+ # into. Both spellings were measured on PowerShell 5.1 to delete the tree, and both walked past these two patterns,
1229
+ # which wanted a hyphen and then whitespace (3.1.0, field). The dashes are an alternation, not a bracket: a bracket
1230
+ # of multi-byte characters is read byte by byte in a C locale.
1231
+ PS_DASH='(-|–|—|―)'
1232
+ PS_RECURSE="${PS_DASH}"'r(e(c(u(r(s(e)?)?)?)?)?)?([[:space:]:]|$)'
1233
+ PS_FORCE="${PS_DASH}"'f(o(r(c(e)?)?)?)?([[:space:]:]|$)'
1234
+ _ps_has_r(){ case "$1" in *-[Rr]*|*–[Rr]*|*—[Rr]*|*―[Rr]*) return 0 ;; esac; return 1; } # the cheap precondition, same dashes
919
1235
  # Recursive+forced removal aimed at a glob, a drive root, a UNC path, or $HOME — the shapes that take a tree out.
920
- { case "$CMD" in *-[Rr]*) : ;; *) false ;; esac && has "(^|[^A-Za-z0-9_-])$PS_RM[[:space:]]" && has "$PS_RECURSE" && has "$PS_FORCE" \
1236
+ # This line-wide test is the FLOOR and is kept exactly as it was: the markers are looked for anywhere on the line.
1237
+ # That over-blocks (one field command was stopped by an unrelated `Set-Location C:\…` AFTER the delete), and reading
1238
+ # the target from the removal's own statement instead was tried and measured: it opened 29 shapes this test stops —
1239
+ # a backtick or trailing-pipe line break, a `cd` through a variable, a splat, a function called with the path later.
1240
+ # A hook cannot tell "unrelated" from "arrives another way", so the floor stays and the refusal says how to go on.
1241
+ { _ps_has_r "$CMD" && has "(^|[^A-Za-z0-9_-])$PS_RM[[:space:]]" && has "$PS_RECURSE" && has "$PS_FORCE" \
921
1242
  && has '(\*|[A-Za-z]:\\|\\\\|\$HOME|\$env:USERPROFILE|~)'; } \
922
1243
  && block "PowerShell recursive force delete (Remove-Item -Recurse -Force)" "4.5" loss
1244
+ # ADDED on top of the floor (3.1.0, field) — these can only stop more. Read from the removal's OWN statement, so an
1245
+ # everyday command elsewhere on the line does not trigger them:
1246
+ # * a target with a path separator: `Remove-Item -Recurse -Force src\app` and `"$env:TEMP\x"` passed while POSIX
1247
+ # `rm -rf src/app` was stopped;
1248
+ # * `.` or `..` standing as an argument — the widest targets there are, and they carry no marker at all
1249
+ # (`Remove-Item -Recurse -Force .` passed; it is the form this rule was first written for);
1250
+ # * a removal fed by a pipeline: what the pipe delivers is its target.
1251
+ # A single bare name (`Remove-Item -Recurse -Force build`) and a variable stay a routine local delete, as with POSIX
1252
+ # `rm -rf build`. Statements are split on `;` and newline with `read` — linear, and no process: this runs on every
1253
+ # command that carries `-r`. A split that is wrong for some exotic line costs a miss here, never an opening: the
1254
+ # floor above has already had its say.
1255
+ PS_RM_RE='(^|[^A-Za-z0-9_-])(remove-item|ri|rm|rmdir|rd|del|erase)([[:space:]]|$)'
1256
+ PS_TGT_RE='([\\/]|(^|[[:space:]"'"'"'(,:])\.\.?(["'"'"'),]|[[:space:]]|$))'
1257
+ _ps_rm_tree(){ # $1 = command -> 0 when a recursive forced removal in it is aimed at a path, at . / .., or fed by a pipe
1258
+ local ln st nc=0 rc=1
1259
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
1260
+ if [[ $1 =~ $PS_RM_RE ]] && [[ $1 =~ $PS_RECURSE ]] && [[ $1 =~ $PS_FORCE ]]; then
1261
+ while IFS= read -r ln || [ -n "$ln" ]; do
1262
+ while IFS= read -r -d ';' st || [ -n "$st" ]; do
1263
+ _ps_has_r "$st" || continue
1264
+ [[ $st =~ $PS_RM_RE ]] && [[ $st =~ $PS_RECURSE ]] && [[ $st =~ $PS_FORCE ]] || continue
1265
+ if [[ $st =~ $PS_TGT_RE ]] || [[ $st =~ \|.*$PS_RM_RE ]]; then rc=0; break 2; fi
1266
+ done <<< "$ln"
1267
+ done <<< "$1"
1268
+ fi
1269
+ [ "$nc" = 0 ] && shopt -u nocasematch
1270
+ return $rc
1271
+ }
1272
+ { _ps_has_r "$CMD" && _ps_rm_tree "$CMD"; } \
1273
+ && block "PowerShell recursive force delete (Remove-Item -Recurse -Force)" "4.5" loss
923
1274
  # Download-and-execute, the PowerShell shape of curl|bash: any fetcher piped into Invoke-Expression.
924
1275
  case "$CMD" in *[Ii][Ee][Xx]*|*[Ii][Nn][Vv][Oo][Kk][Ee]-[Ee][Xx][Pp][Rr][Ee][Ss][Ss][Ii][Oo][Nn]*) : ;; *) false ;; esac && _grep "$CMD" -qiE '(invoke-webrequest|iwr|invoke-restmethod|irm|curl|wget)[^|]*\|[[:space:]]*(invoke-expression|iex)([[:space:]]|$)' \
925
1276
  && block "PowerShell download-and-execute (… | iex)" "4.5" exec
@@ -958,14 +1309,22 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
958
1309
  local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
959
1310
  local wr="[${_HP_B}](--unset|--unset-all|--add|--replace-all|--edit|-e|set|unset|--rename-section|--remove-section|rename-section|remove-section)([${_HP_B}]|\$)"
960
1311
  local -a reads=()
961
- case "$c" in *"$bsnl"*) c="${c//"$bsnl"/ }" ;; esac # quoted: in a ${//} pattern a bare backslash escapes the newline
962
- t=${c//\"/}; t=${t//\'/}; t=${t//\\/} # unquoted, one class each: inside "…" a [\"\'\\] class never closes
1312
+ case "$c" in *"$bsnl"*) _gsub "$c" '\\'$'\n' ' ' bs; c="$_GS" ;; esac # backslash-newline -> a space
1313
+ _unquoted "$c"; t="$_GS"
963
1314
  case "$t" in *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*|*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*) ;; *) return 1 ;; esac # after unquoting: hooks"P"ath
964
1315
  shopt -q nocasematch && nc=1; shopt -s nocasematch
965
1316
  # The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
966
1317
  # would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
967
1318
  # the raw payload the decoded text is not the command that will run, and no read is proven.
968
- case "$INPUT" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
1319
+ # It is asked of the COMMAND as the payload spells it, with every escaped backslash (`\\`) taken out first. It
1320
+ # used to be asked of the whole payload: on Windows `"cwd":"C:\\repos\\app"` holds a backslash and an r, a `\\Users`
1321
+ # a backslash and a u in lower case, and so on, so in such a directory NO read was ever proven and `git config
1322
+ # --get core.hooksPath` — how a person checks that the gate is armed — was refused as tampering (measured on
1323
+ # Windows). An escaped backslash is a backslash, not the start of an escape: `\\r` is those two characters, and
1324
+ # only an odd one in front of the letter (`\r`, `\\\r`) is the escape the reader drops.
1325
+ local raw="${CMD_RAW-}"
1326
+ if [ -n "$raw" ]; then raw="${raw//\\\\/}"; else raw="$INPUT"; fi
1327
+ case "$raw" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
969
1328
  _split_segs "$c"
970
1329
  for seg in ${_SPL[@]+"${_SPL[@]}"}; do
971
1330
  [[ $seg == *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]* ]] || continue
@@ -979,23 +1338,54 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
979
1338
  [ "$nc" = 0 ] && shopt -u nocasematch
980
1339
  # Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
981
1340
  for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
982
- t=${c//\"/}; t=${t//\'/}; t=${t//\\/} # unquoted, one class each: inside "…" a [\"\'\\] class never closes
1341
+ _unquoted "$c"; t="$_GS"
983
1342
  _ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && return 0
984
- _ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?core([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0
1343
+ _ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?([^;&|[:space:]]+[[:space:]]+)?core([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0 # [core] as the old name OR the new one
985
1344
  return 1
986
1345
  }
987
1346
  # Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
988
1347
  # that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
989
1348
  [ "$HAS_GIT" = 1 ] && _ere i "$CMD" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
990
1349
  [ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
1350
+ # The same setting by two side doors. `git config include.path f` (or includeIf.<cond>.path) makes git read ANOTHER
1351
+ # file as configuration, and that file can carry hooksPath; GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
1352
+ # hand git a setting through the environment (measured: with them a commit skipped its hooks). Both are refused by
1353
+ # name, reads included: a rule that argued which `git config include.path` is a read would be the reader that
1354
+ # _hp_blocks needed three review rounds to get right, for a key nobody reads in ordinary work.
1355
+ if [ "$HAS_GIT" = 1 ]; then
1356
+ _t="$CMD_UQ"
1357
+ # ...and the inline form of both, with the quoting taken off first: `git -c 'core.hooksPath=/dev/null' commit`,
1358
+ # `-c core.hooks''Path=…` and `-c include.path=f` all skipped the hooks, because the rule above wants the bare
1359
+ # key right after `-c` (measured, 3.1.0 review).
1360
+ _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' \
1361
+ && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
1362
+ _ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+include(if)?\.' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
1363
+ && block "git config include.path (another file read as configuration)" "4.5" tamper
1364
+ _ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&][^|;&]*(include\.path|includeif\.)' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
1365
+ && block "git config include.path (another file read as configuration)" "4.5" tamper
1366
+ # `git config --edit` opens the file itself in an editor, and the editor is whatever GIT_EDITOR says: no key is named.
1367
+ _ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&]([^|;&]*[[:space:]])?(--edit|-e)([[:space:]]|$)' '*[Cc][Oo][Nn][Ff][Ii][Gg]*' \
1368
+ && block "git config --edit (the configuration file opened for writing)" "4.5" tamper
1369
+ case "$_t" in *GIT_CONFIG_*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*) block "core.hooksPath through GIT_CONFIG_ variables (disarms the git hooks)" "4.5" tamper ;; esac
1370
+ fi
991
1371
  # A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
992
1372
  # in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
993
1373
  # indirected redirect (VAR=.claude/hooks; … > $VAR). Reading a gate file stays allowed, and `chmod +x` is NOT
994
1374
  # blocked so doctor's re-arm fix still works (a chmod -x disable is caught by doctor, not here). Honest scope:
995
1375
  # the shell is Turing-complete, so this is defence-in-depth — guard-write.sh covers the Write/Edit tools (the
996
1376
  # model's natural path to a file), and install-time read-only hook files would be the airtight layer.
997
- # Either separator: a PowerShell call spells these `.claude\hooks\…`, and it was not recognised (measured, rc 0).
998
- GATE='\.(claude[/\\]+(hooks|settings\.json|DISCIPLINE\.md|eval[/\\]+lib[/\\]+crew-env\.sh)|git[/\\]+hooks)'
1377
+ _GP='[/\\]+(\.[/\\]+)*' # a path separator as the shell and the filesystem take it: `/`, `\`, doubled, with `/./` between
1378
+ GATE='(\.(claude'"$_GP"'(hooks|git-shim|settings\.json|DISCIPLINE\.md|eval'"$_GP"'lib'"$_GP"'crew-env\.sh)|git'"$_GP"'hooks|git'"$_GP"'(config|worktrees'"$_GP"'[^/\\[:space:]]+'"$_GP"'config|modules'"$_GP"'[^[:space:]]+'"$_GP"'config))|\.gitconfig([^A-Za-z0-9_.-]|$)|\.config'"$_GP"'git'"$_GP"'config([^A-Za-z0-9_.-]|$))'
1379
+ # .git/config (with a worktree's and a submodule's own) is on the list because core.hooksPath LIVES there: the rules
1380
+ # above stop `git config core.hooksPath …`, and a plain `printf '[core]\n\thooksPath = /dev/null\n' >> .git/config`
1381
+ # walked past them — after it a commit from the user's own terminal skips the trace and secret scans (measured, 3.1.0
1382
+ # review; inside a session guard-commit-scan.sh still scans). Reading it stays allowed. Measured before choosing this
1383
+ # over checking the setting at commit time: 12,422 real commands in 671 transcripts name .git/config 9 times, all 9 in
1384
+ # Crewforth's own development; and a check at commit time would refuse every commit of an install whose hooks were never
1385
+ # wired. The user's own files hold the same key for every repository at once: ~/.gitconfig and
1386
+ # $XDG_CONFIG_HOME/git/config (~/.config/git/config) are on the list by their names, wherever HOME is.
1387
+ # .claude/git-shim is the same thing one step removed: it is where core.hooksPath points when Crewforth shares
1388
+ # the hooks with a project's own chain.
999
1389
  # eval/lib/crew-env.sh is on the list because the gates SOURCE it on every call (guard-bash, guard-write, the board
1000
1390
  # hooks): a file a gate executes is part of the gate. Measured before it was added: overwrite it with `exit 0` and
1001
1391
  # `rm -rf /` passed guard-bash with rc 0, in both editions (3.0.1 review).
@@ -1054,15 +1444,352 @@ fi
1054
1444
  # to route around. A verb in one command and a path in another was never evidence of anything: the two forms
1055
1445
  # that matter — `rm .claude/hooks/x` and `x > .claude/hooks/y` — both put them in the SAME segment, and both
1056
1446
  # are still blocked (asserted in smoke-test, in both directions).
1447
+ # ---- the reader of a call (the commit gate further down describes what it is for) ----
1448
+ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span and every escaped character as one marker
1449
+ # (\001 n \001, its text in _C47_Q[n]); redirections set apart from the words they touch; heredoc bodies gone
1450
+ local s="$1" out="" pre c w hd="" c2 nl=$'\n' n=0 body t bt=0
1451
+ _C47_M=$'\001'; _C47_Q=(); _C47_QX=""
1452
+ _gsub "$s" $'\r' ''; _gsub "$_GS" "$_C47_M" ''; s="$_GS"
1453
+ while :; do
1454
+ pre="${s%%[\"\'\\\;\&\|\<\>\#\(\)\`\$$nl]*}"; out="$out$pre"
1455
+ [ "$pre" = "$s" ] && break
1456
+ c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
1457
+ case "$c" in
1458
+ \\) case "$s" in
1459
+ "$nl"*) s="${s:1}" ;; # a backslash-newline joins: both characters go
1460
+ '') ;;
1461
+ *) _C47_Q[n]="${s:0:1}"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)); s="${s:1}" ;; # an escaped character is itself, quoted
1462
+ esac ;;
1463
+ \') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s="" ;; esac
1464
+ _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1465
+ \") body=""
1466
+ case "$s" in
1467
+ '$(cat <<'*) # a message read from a here-document: its body is skipped as a whole, quotes and all
1468
+ w="${s:8}"; w="${w#-}"; w="${w#[\'\"]}"; w="${w%%[!A-Za-z0-9_]*}"
1469
+ case "$s" in *"$nl$w$nl"*) [ -n "$w" ] && { pre="${s%%"$nl$w$nl"*}"; s="${s:${#pre}+${#w}+2}"; body='(a here-document)'; } ;; esac ;;
1470
+ esac
1471
+ while :; do # to the closing quote that is not escaped
1472
+ pre="${s%%[\"\\]*}"
1473
+ [ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
1474
+ c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
1475
+ [ "$c2" = '"' ] && break
1476
+ body="$body${s:0:1}"; s="${s:1}"
1477
+ done
1478
+ # "$@" and "${A[@]}" stay quoted and still become SEVERAL words: `set -- -n; git commit -m x "$@"`.
1479
+ case "$body" in *'$@'*|*'[@]'*) _C47_QX="$_C47_QX $n " ;; esac
1480
+ _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1481
+ \$) case "$s" in
1482
+ \'*) # $'…' : a quoted text in which a backslash escapes, so a quote after one does not close it
1483
+ s="${s:1}"; body=""
1484
+ while :; do
1485
+ pre="${s%%[\'\\]*}"
1486
+ [ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
1487
+ c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
1488
+ [ "$c2" = "'" ] && break
1489
+ body="$body\\${s:0:1}"; s="${s:1}"
1490
+ done
1491
+ _C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
1492
+ '(('*) case "$s" in *'))'*) pre="${s%%'))'*}"; s="${s:${#pre}+2}" ;; *) s="" ;; esac; out="$out\$" ;; # $(( … )): arithmetic, `<<` in it is a shift
1493
+ '{'*) case "$s" in *'}'*) pre="${s%%'}'*}"; s="${s:${#pre}+1}" ;; *) s="" ;; esac; out="$out\$" ;; # ${ … }: one expansion, `#` in it is no comment
1494
+ *) out="$out\$" ;;
1495
+ esac ;;
1496
+ "$nl") if [ -n "$hd" ]; then # the lines up to the delimiter are the here-document
1497
+ case "$s" in
1498
+ "$hd") s="" ;;
1499
+ "$hd$nl"*) s="${s:${#hd}+1}" ;;
1500
+ *"$nl$hd$nl"*) pre="${s%%"$nl$hd$nl"*}"; s="${s:${#pre}+${#hd}+2}" ;;
1501
+ *"$nl$hd") s="" ;;
1502
+ esac # no such line: it was no here-document, read on
1503
+ hd=""
1504
+ fi
1505
+ out="$out ; " ;;
1506
+ \|) case "$out" in *\>) out="$out|" ;; *) out="$out ; " ;; esac ;; # >| is a redirection, not a pipe
1507
+ \;|\(|\)) out="$out ; " ;;
1508
+ # A backtick opens a command substitution as `$(` does, so the word it starts reads as an expansion too:
1509
+ # `git \`printf commit\` -m x` is a git call whose subcommand the shell fills in. The closing one only separates.
1510
+ \`) if [ "$bt" = 0 ]; then bt=1; out="$out\$ ; "; else bt=0; out="$out ; "; fi ;;
1511
+ \&) case "$out" in
1512
+ *[\<\>]) out="$out&" ;; # 2>&1, >&, and the input forms 0<&-, <&2
1513
+ *) case "$s" in
1514
+ \>*) out="$out &>"; s="${s:1}" ;; # &>
1515
+ *) out="$out ; " ;;
1516
+ esac ;;
1517
+ esac ;;
1518
+ \<|\>)
1519
+ if [ "$c" = '<' ]; then
1520
+ case "$s" in
1521
+ \<\<*) out="$out <<< "; s="${s:2}"; continue ;; # a here-string: one word follows, on this line
1522
+ \<*) s="${s:1}"; s="${s#-}"
1523
+ while :; do case "$s" in [$' \t']*) s="${s:1}" ;; *) break ;; esac; done
1524
+ t="${s%%[$' \t'\;\&\|\<\>\(\)$nl]*}"; s="${s:${#t}}" # the delimiter word, then without its quoting
1525
+ hd="${t//\"/}"; hd="${hd//\'/}"; hd="${hd//\\/}"
1526
+ out="$out <<H "; continue ;;
1527
+ esac
1528
+ fi
1529
+ # A redirection is its own word: `-a>/dev/null` is `-a` and `>/dev/null`. Only a number directly in front
1530
+ # of it belongs to it (2>…), and a `>` or `&` already there (>>, &>, >&).
1531
+ case "$out" in
1532
+ *[\<\>\&]) ;;
1533
+ *[0-9]) t="${out##*[!0-9]}"; pre="${out%"$t"}"; case "$pre" in ''|*[$' \t']) ;; *) out="$out " ;; esac ;;
1534
+ *) out="$out " ;;
1535
+ esac
1536
+ out="$out$c" ;;
1537
+ \#) case "$out" in
1538
+ ''|*[$' \t']) case "$s" in *"$nl"*) pre="${s%%"$nl"*}"; s="${s:${#pre}}" ;; *) s="" ;; esac ;; # a comment, to the end of its line
1539
+ *) out="$out#" ;;
1540
+ esac ;;
1541
+ esac
1542
+ done
1543
+ _C47_T="$out"
1544
+ }
1545
+ _c47_w(){ # $1 = one token of _C47_T -> _W: the word as the command receives it (expansions left as written);
1546
+ # _WQ = 1 when part of it was quoted; _WX = 1 when an UNQUOTED part holds $ * ? [ or {, i.e. the shell
1547
+ # will make something else of it — possibly more than one word
1548
+ local t="$1" m="$_C47_M" pre i
1549
+ _W=""; _WQ=0; _WX=0; _WAT=0
1550
+ while :; do
1551
+ case "$t" in *"$m"*) ;; *) break ;; esac
1552
+ pre="${t%%"$m"*}"; t="${t#*"$m"}"; i="${t%%"$m"*}"; t="${t#*"$m"}"
1553
+ case "$pre" in *[\$\*\?\[\{]*) _WX=1 ;; esac
1554
+ case "$i" in ''|*[!0-9]*) ;; *) _W="$_W$pre${_C47_Q[i]:-}"; _WQ=1; case "$_C47_QX" in *" $i "*) _WX=1; _WAT=1 ;; esac; continue ;; esac
1555
+ _W="$_W$pre"
1556
+ done
1557
+ case "$t" in *[\$\*\?\[\{]*) _WX=1 ;; esac
1558
+ _W="$_W$t"
1559
+ }
1560
+ # The four rules below cost a grep each, so they run only for a command that could name a gate file at all. Builtin.
1561
+ _gate_named(){ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*|*[Gg][Ii][Tt][/\\]*[Cc][Oo][Nn][Ff][Ii][Gg]*|*[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]*|*.[Gg][Ii][Tt][Cc][Oo][Nn][Ff][Ii][Gg]*) return 0 ;; esac; return 1; }
1057
1562
  # CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
1058
1563
  # pointed at a gate file, not readers this hook uses.
1059
- case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -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
1060
- case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -qiE "(sed|perl|awk|ruby)[[:space:]]+(-[^[:space:]]+[[:space:]]+)*-i[^;&|]*$GATE" && block "in-place edit of a gate file" "4.5" tamper
1564
+ # A COMMAND THAT NAMES A GATE FILE IS ON A SHORT LIST, OR IT IS REFUSED. This rule used to look for a write VERB
1565
+ # (`rm|mv|cp|tee|…`) before the gate path, and a list of verbs is never complete: of 39 ways to write or delete a
1566
+ # gate file, 32 passed (measured on 3.0.3: `curl -o`, `wget -O`, `sort -o`, `git log --output=`, `openssl -out`,
1567
+ # `tar -C`, `unzip -d`, `unlink`, `shred`, `rmdir`, `touch`, `patch`, `vim -c wq`, `find … -delete`,
1568
+ # `git checkout <rev> -- <gate file>`, `git restore -s`). It also refused readers whose arguments held such a word
1569
+ # (`grep -n rm <gate file>`, `cat /Users/ed/p/.claude/hooks/x`). So the question is turned round: a simple command
1570
+ # that names a gate path passes only when its command word is
1571
+ # * a READER: grep egrep fgrep rg (no --pre) cat head tail wc ls less (no log file) diff cmp stat sha256sum shasum
1572
+ # md5 md5sum du basename dirname realpath readlink jq echo printf test [ [[ mkdir pwd, sed without -i, sort
1573
+ # without -o, find without -delete / -exec / -fprint;
1574
+ # * a RUNNER of a hook script: `bash|sh <a .sh file, pre-commit or commit-msg under .claude/hooks>`, or that file
1575
+ # itself — never `bash -c`, never a script that is not one; and `chmod +x` / `755` on one;
1576
+ # * git status | log | diff | show | add | ls-files | blame | check-ignore | rev-parse | cat-file | ls-tree |
1577
+ # commit, and a `git config` that reads, with nothing before the subcommand but --no-pager / -P, and without
1578
+ # --output / --ext-diff.
1579
+ # Everything else that names one is refused, a program nobody listed included. What a reader does with a
1580
+ # redirection is the redirect rule's, below.
1581
+ # A PATH THAT IS HIDDEN is followed where the text allows it: quotes and backslashes are taken out first
1582
+ # (`.clau\de/hooks`), `.claude` itself counts (`mv .claude x`), a glob with a dot component is expanded in the
1583
+ # call's directory (`.claude/hoo*/guard*`), and a call that BINDS a gate path — `D=.claude/hooks`, `for f in
1584
+ # .claude/hooks/*`, `cd .claude/hooks`, or a call that already stands in such a directory — must have every one of
1585
+ # its commands on the list, since any of them may reach the file by the short name.
1586
+ # The call is read by _c47_read, the reader the commit gate uses: quotes paired, a here-document's body set
1587
+ # apart, every separator the same. A gate path named only in a here-document makes every command of the call
1588
+ # answer to the list: the body is text for `cat` and a program for `python3 -`.
1589
+ # Bash tool only. A PowerShell call keeps the verb rule.
1590
+ _GANC='(^|[[:space:]=:/\\])\.claude[/\\.]*([[:space:];&|)]|$)'
1591
+ _gt_named(){ # $1 = text -> 0 = it names a gate path, or the directory that holds them
1592
+ local r=1; shopt -s nocasematch
1593
+ if [[ "$1" =~ $GATE ]] || [[ "$1" =~ $_GANC ]]; then r=0; fi
1594
+ shopt -u nocasematch; return "$r"
1595
+ }
1596
+ _gt_script(){ # $1 = a word -> 0 = a file under a hooks directory of the gate (a hook script to run)
1597
+ local r=1; shopt -s nocasematch
1598
+ if [[ "$1" =~ \.claude[/\\]+hooks[/\\]+([^/\\[:space:]]+\.sh|pre-commit|commit-msg)$ ]] || [[ "$1" =~ \.claude[/\\]+git-shim[/\\]+git$ ]] || { [ -n "${_PGATE:-}" ] && [[ "$1" =~ $_PGATE ]]; }; then r=0; fi
1599
+ shopt -u nocasematch; return "$r"
1600
+ }
1601
+ _gt_ok(){ # $1 = one simple command, unquoted; $2 = 1: the call binds a gate path -> 0 = on the list · 1 = not (_GTW says why)
1602
+ local seg="${1//[$'\t\n']/ }" taint="${2:-0}" k=0 a w base sub scr
1603
+ local -a W=()
1604
+ read -r -a W <<< "$seg"
1605
+ _GTW=""
1606
+ a=0 # a = 1: an assignment stands in front of the command word
1607
+ while [ "$k" -lt "${#W[@]}" ]; do
1608
+ case "${W[k]}" in
1609
+ then|do|else|elif|if|while|until|'!'|'{'|time) k=$((k+1)) ;;
1610
+ [A-Za-z_]*=*) case "${W[k]%%=*}" in *[!A-Za-z0-9_]*) break ;; *) a=1; k=$((k+1)) ;; esac ;;
1611
+ *) break ;;
1612
+ esac
1613
+ done
1614
+ [ "$k" -lt "${#W[@]}" ] || return 0
1615
+ w="${W[k]}"
1616
+ case "$w" in for|case|select|fi|done|esac|'}'|in) return 0 ;; esac
1617
+ # `PATH=/x cat <gate file>` runs another cat, `PAGER=… git log` another pager: an assignment in front changes
1618
+ # what the word runs, so the word is not vouched for.
1619
+ [ "$a" = 0 ] || { _GTW="an assignment stands in front of '$w', and it can change what that word runs"; return 1; }
1620
+ base="$w"
1621
+ case "$w" in */*|*\\*)
1622
+ case "${w%/*}" in
1623
+ /bin|/usr/bin|/usr/local/bin|/opt/homebrew/bin) base="${w##*/}" ;;
1624
+ *) _gt_script "$w" && return 0
1625
+ _GTW="'$w' is a program given by its path, not a command on the list"; return 1 ;;
1626
+ esac ;;
1627
+ esac
1628
+ case "$base" in
1629
+ bash|sh|zsh|dash|ksh)
1630
+ a=$((k+1)); scr=""
1631
+ while [ "$a" -lt "${#W[@]}" ]; do
1632
+ case "${W[a]}" in
1633
+ -o|+o|-O|+O) a=$((a+1)) ;;
1634
+ --*) ;;
1635
+ -*[cis]*) _GTW="'$base ${W[a]}' takes its commands from an argument or from its input"; return 1 ;;
1636
+ -*|+*) ;;
1637
+ *) scr="${W[a]}"; break ;;
1638
+ esac
1639
+ a=$((a+1))
1640
+ done
1641
+ [ -n "$scr" ] || { _GTW="'$base' with no script takes its commands from its input"; return 1; }
1642
+ _gt_script "$scr" && return 0
1643
+ case "$taint" in
1644
+ 1) case "$scr" in *'$'*) return 0 ;; esac ;;
1645
+ 2) case "$scr" in */*|*\\*) ;; *) return 0 ;; esac ;; # in a gate directory, a script named without a path is one of its files
1646
+ esac
1647
+ _GTW="'$base $scr' runs a script that is not a hook script"; return 1 ;;
1648
+ git)
1649
+ a=$((k+1))
1650
+ while [ "$a" -lt "${#W[@]}" ]; do case "${W[a]}" in --no-pager|-P) a=$((a+1)) ;; *) break ;; esac; done
1651
+ sub="${W[a]:-}"
1652
+ case "$sub" in
1653
+ status|log|diff|show|add|ls-files|blame|check-ignore|rev-parse|cat-file|ls-tree|commit) ;;
1654
+ config) # a READ of the configuration: --get / --list, or a name and no value
1655
+ scr=0; a=$((a+1))
1656
+ while [ "$a" -lt "${#W[@]}" ]; do
1657
+ case "${W[a]}" in
1658
+ --get|--get-all|--get-regexp|--get-urlmatch|--list|-l) return 0 ;;
1659
+ -f|--file|--blob|--type|--default) a=$((a+1)) ;;
1660
+ -*) ;;
1661
+ *) scr=$((scr+1)) ;;
1662
+ esac
1663
+ a=$((a+1))
1664
+ done
1665
+ [ "$scr" = 1 ] && return 0
1666
+ _GTW="'git config' with a value writes the configuration"; return 1 ;;
1667
+ *) _GTW="'git ${sub:-…}' is not one of the git commands that only read or stage"; return 1 ;;
1668
+ esac
1669
+ for w in ${W[@]+"${W[@]:a}"}; do case "$w" in --ou*|--ext*) _GTW="'git $sub $w' writes a file or runs a program"; return 1 ;; esac; done
1670
+ return 0 ;;
1671
+ rg) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --pr*) _GTW="'rg $w' runs a program on every file"; return 1 ;; esac; done; return 0 ;;
1672
+ sed) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --i*|-[!-]*[iI]*|-[iI]*) _GTW="'sed $w' edits the file in place"; return 1 ;; esac; done; return 0 ;;
1673
+ sort) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --o*|-[!-]*o*|-o*) _GTW="'sort $w' writes a file"; return 1 ;; esac; done; return 0 ;;
1674
+ less) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --log*|--LOG*|-[!-]*[oO]*|-[oO]*) _GTW="'less $w' writes a log file"; return 1 ;; esac; done; return 0 ;;
1675
+ chmod) # making a hook runnable again is how the doctor's advice is followed; any other mode is not
1676
+ case "${W[k+1]:-}" in [ugoa]+x|[ugoa][ugoa]+x|+x|+rx|755|0755) return 0 ;; esac
1677
+ _GTW="'chmod ${W[k+1]:-}' is not the mode that makes a hook runnable (+x, 755)"; return 1 ;;
1678
+ find) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in -delete|-exec|-execdir|-ok|-okdir|-fprint*|-fls) _GTW="'find $w' deletes, runs or writes"; return 1 ;; esac; done; return 0 ;;
1679
+ grep|egrep|fgrep|cat|head|tail|wc|ls|diff|cmp|stat|sha256sum|shasum|md5|md5sum|du|basename|dirname|realpath|readlink|echo|printf|test|'['|'[['|true|:|mkdir|pwd|cd|pushd|popd|read|mapfile|readarray) return 0 ;;
1680
+ jq) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --ou*) _GTW="'jq $w' names an output"; return 1 ;; esac; done; return 0 ;;
1681
+ esac
1682
+ _GTW="'$base' is not on the list of commands that read a gate file or run a hook script"; return 1
1683
+ }
1684
+ _GT_CWD1='/\.claude(/+(hooks|git-shim)(/.*)?)?/*$'
1685
+ _GT_CWD2='/\.git(/+hooks(/.*)?)?/*$'
1686
+ _GT_GLOB1='(^|[[:space:]/=])\.[^/[:space:]]*[*?[{]'
1687
+ _GT_GLOB2='\.(claude|git)[/\\][^[:space:]]*[*?[{]'
1688
+ _GT_GLOB3='(^|[[:space:]/=])\{[^}[:space:]]*\.'
1689
+ _GT_DOTGIT='(^|[[:space:];&|(])(cd|pushd)[[:space:]]+[^[:space:];&|]*\.git[/\\]*([[:space:];&|)]|$)'
1690
+ # _GT_MEAN: a word that changes what a later command word runs. _GT_BIND: one that hands names on to a command.
1691
+ _GT_MEAN='(^|[;&|[:space:](])(alias|function|hash|enable|builtin|source|eval|exec|trap|export|declare|typeset|readonly)([[:space:]]|$)|(PATH|ENV|IFS)=|\(\)'
1692
+ _GT_BIND='(^|[;&|[:space:](])(xargs|parallel|read|mapfile|readarray)([[:space:]]|$)'
1693
+ # A shell with no script reads its commands from the pipe in front of it: `… | sh`.
1694
+ _GT_PIPESH='[|][[:space:]]*(ba|z|da|k)?sh[[:space:]]*($|[;&|<>])'
1695
+ _gt_judge(){ # the call -> returns 1 with _GTW set when one of its commands is refused; 0 otherwise
1696
+ local cwd="" taint=0 named=0 globby=0 any=0 here=0 seg w x t i sp=$'\002'
1697
+ local -a SEG=() W=() # set, not only declared: bash 4.4 and later call a declared, empty array unbound under `set -u`
1698
+ _GTW=""
1699
+ if _gate_named && _gt_named "$CMD_UQ"; then named=1; fi
1700
+ case "$CMD_UQ" in *.git*) [[ "$CMD_UQ" =~ $_GT_DOTGIT ]] && named=1 ;; esac
1701
+ case "$INPUT" in *.claude*|*.git*)
1702
+ _json_slice "$INPUT" cwd >/dev/null; cwd="${_JS//\\\\//}"
1703
+ shopt -s nocasematch
1704
+ if [[ "$cwd" =~ $_GT_CWD1 ]] || [[ "$cwd" =~ $_GT_CWD2 ]]; then taint=2; fi
1705
+ shopt -u nocasematch ;;
1706
+ esac
1707
+ case "$CMD_UQ" in *[*?[{]*)
1708
+ if [[ "$CMD_UQ" =~ $_GT_GLOB1 ]] || [[ "$CMD_UQ" =~ $_GT_GLOB2 ]] || [[ "$CMD_UQ" =~ $_GT_GLOB3 ]]; then globby=1; fi ;;
1709
+ esac
1710
+ [ "$named$taint$globby" != 000 ] || return 0
1711
+ # The simple commands of the call, as _c47_read reads it (quotes paired, a here-document's body gone, every
1712
+ # separator the same), each word without its quoting.
1713
+ _c47_read "$CMD"; _gsub "$_C47_T" ' ; ' $'\n'
1714
+ while IFS= read -r seg; do
1715
+ case "$seg" in *[![:space:]]*) ;; *) continue ;; esac
1716
+ read -r -a W <<< "$seg"; t=""
1717
+ for w in ${W[@]+"${W[@]}"}; do _c47_w "$w"; t="$t ${_W//[$' \t\n']/$sp}"; done
1718
+ SEG[${#SEG[@]}]="${t# }"
1719
+ { _gt_named "$t" || [[ "$t" =~ $_GT_DOTGIT ]]; } && any=1
1720
+ case " $t " in *' <<H '*) here=1 ;; esac
1721
+ done <<< "$_GS"
1722
+ [ "${#SEG[@]}" -gt 0 ] || return 0
1723
+ if [ "$taint" = 0 ] && [ "$named" = 1 ]; then
1724
+ if [ "$any" = 0 ]; then
1725
+ # Named, but by none of the commands: in a comment, or in the body of a here-document. A comment runs
1726
+ # nothing. A here-document is text for `cat` or `git commit -F -`, and a program for an interpreter or a
1727
+ # shell — and it can be printed into one — so with one in the call every command must be on the list.
1728
+ [ "$here" = 1 ] || return 0
1729
+ taint=2
1730
+ else
1731
+ # does the call BIND a gate path (an assignment, a for list, a cd), hand names on (a substitution, xargs),
1732
+ # or change what a command word runs?
1733
+ # A call that changes what a command word RUNS vouches for none of its words: `PATH=/x; cat <gate file>`.
1734
+ if [[ "$CMD_UQ" =~ $_GT_MEAN ]]; then _GTW="the call holds '${BASH_REMATCH[0]# }', which can change what a command word runs"; return 1; fi
1735
+ if [[ "$CMD_UQ" =~ $_GT_PIPESH ]]; then _GTW="the call pipes text into a shell, which runs it"; return 1; fi
1736
+ [[ "$CMD_UQ" =~ $_GT_BIND ]] && taint=1
1737
+ case "$CMD" in *'$('*|*'`'*) taint=1 ;; esac
1738
+ for seg in "${SEG[@]}"; do
1739
+ read -r -a W <<< "$seg"; i=0
1740
+ while [ "$i" -lt "${#W[@]}" ]; do
1741
+ case "${W[i]}" in
1742
+ then|do|else|elif|if|while|until|'!'|'{'|time) ;;
1743
+ [A-Za-z_]*=*) [ "$taint" != 0 ] || { _gt_named "${W[i]#*=}" && taint=1; } ;;
1744
+ for) _gt_named "$seg" && [ "$taint" = 0 ] && taint=1; break ;;
1745
+ cd|pushd) { _gt_named "$seg" || [[ "$seg" =~ $_GT_DOTGIT ]]; } && taint=2; break ;;
1746
+ *) break ;;
1747
+ esac
1748
+ i=$((i+1))
1749
+ done
1750
+ done
1751
+ fi
1752
+ fi
1753
+ for seg in "${SEG[@]}"; do
1754
+ x=0
1755
+ if [ "$taint" = 1 ]; then # by a name: only a command that expands something, or holds a binding word, can reach it
1756
+ case "$seg" in *'$'*) x=1 ;; *) [[ "$seg" =~ $_GT_BIND ]] && x=1 ;; esac
1757
+ fi
1758
+ if [ "$taint" = 2 ] || [ "$x" = 1 ]; then
1759
+ _gt_ok "$seg" "$taint" || { _GTW="$_GTW, in a call that can reach a gate file by another name (a variable, a loop, a cd, a substitution, a here-document, xargs, or a word that changes what a command runs)"; return 1; }
1760
+ continue
1761
+ fi
1762
+ _gt_named "$seg" && x=1
1763
+ if [ "$x" = 0 ] && [ "$globby" = 1 ]; then
1764
+ read -r -a W <<< "$seg"
1765
+ for w in ${W[@]+"${W[@]}"}; do
1766
+ case "$w" in
1767
+ *'{'*.*|*.*'{'*) case "$w" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*|*.[Gg][Ii][Tt]*) x=1 ;; esac ;;
1768
+ *.*[*?[]*) t="$( cd "${cwd:-.}" 2>/dev/null && compgen -G "$w" )"; _gt_named "$t" && x=1 ;;
1769
+ esac
1770
+ done
1771
+ fi
1772
+ [ "$x" = 1 ] || continue
1773
+ _gt_ok "$seg" 0 || return 1
1774
+ done
1775
+ return 0
1776
+ }
1777
+ _gate_verb_ps(){ _grep "$CMD" -qiE "(^|[^A-Za-z0-9_-])(rm|mv|cp|rsync|sponge|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)([^A-Za-z0-9_/\\\\;&|]|\\\\[^A-Za-z0-9;&|])[^;&|]*$GATE"; }
1778
+ case "$INPUT" in
1779
+ *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*)
1780
+ _gate_named && _gate_verb_ps && block "write/tamper of a gate file (hook/settings/.git-hooks)" "4.5" tamper ;;
1781
+ *)
1782
+ if ! _gt_judge; then
1783
+ _BLOCK_NOTE="A command that names a gate file passes only when it reads the file or runs a hook script: $_GTW."
1784
+ block "write/tamper of a gate file (hook/settings/.git-hooks)" "4.5" tamper
1785
+ fi ;;
1786
+ esac
1787
+ _gate_named && _grep "$CMD" -qiE "(sed|perl|awk|ruby)[[:space:]]+(-[^[:space:]]+[[:space:]]+)*-i[^;&|]*$GATE" && block "in-place edit of a gate file" "4.5" tamper
1061
1788
  # /CREW-NOT-A-RUNG
1062
1789
  # The redirect TARGET must be the gate path, not merely something later on the line: a target is one token, so
1063
1790
  # it cannot contain whitespace or a command separator.
1064
- case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -qiE ">[[:space:]]*['\"]?[^[:space:];&|<>]*$GATE" && block "redirect over a gate file" "4.5" tamper
1065
- { 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
1791
+ _gate_named && _grep "$CMD" -qiE ">[|]?[[:space:]]*['\"]?[^[:space:];&|<>]*$GATE" && block "redirect over a gate file" "4.5" tamper
1792
+ { _gate_named && has "=[^;&|]*$GATE" && has '>>?[[:space:]]*\$'; } && block "indirected write to a gate path (variable + redirect)" "4.5" tamper
1066
1793
  # A symlink whose TARGET is the config directory itself is the two-step form of editing a hook, and step one
1067
1794
  # names no gate path at all: `ln -sfn .claude cfg` passed every rule above, and then `cfg/hooks/guard-bash.sh`
1068
1795
  # is an ordinary-looking path that lands on the real gate script — measured, both steps rc=0, file overwritten.
@@ -1075,6 +1802,47 @@ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false
1075
1802
  _LNT='([^;&|[:space:]]*/)?\.(claude|git)'; [ -n "$_PLNK" ] && _LNT="(${_LNT}|${_PLNK})"
1076
1803
  case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac && _grep "$CMD" -qiE "(^|[;&|[:space:]])(ln|mklink)[^;&|]*[[:space:]]${_LNT}([[:space:]]|\$)" && block "symlink pointing at the config directory (a gate path in two steps)" "4.5" tamper
1077
1804
 
1805
+ # §4.4: THE APPROVAL RECORD IS WRITTEN BY ONE THING, the UserPromptSubmit hook, from the user's own message
1806
+ # (hooks/prompt-approval.sh). A session that could produce that record itself would be approving its own commit,
1807
+ # and review found three ways it could, each closed here:
1808
+ # 1. write the file -> a shell command that NAMES `crewforth-approval` is refused, reading included:
1809
+ # nothing a session does needs it. guard-write.sh has the same rule for the file tools.
1810
+ # 2. run the hook itself -> `printf '{…"prompt":"approve: commit"}' | bash .claude/hooks/prompt-approval.sh`
1811
+ # wrote a record nobody typed. A command that names the hook AND feeds it (a pipe or
1812
+ # an input redirection: the hook reads its payload from stdin) is refused.
1813
+ # 3. start a session that continues this one, with the approval as its prompt (`claude -p --continue "approve: …"`).
1814
+ # The record carries the session it was given in, so a NEW session's approval is
1815
+ # worth nothing here; one that continues or names this session is refused in the two
1816
+ # modes where a record counts.
1817
+ # Quotes and backslashes are removed first (`crewforth-appr"oval"` is the same file to the shell), and case is
1818
+ # folded because the two filesystems people use most fold it. Builtins only: this is on every Bash call.
1819
+ # HONEST SCOPE, as for the gate files above: these rules match NAMES. A command that reaches the same thing without
1820
+ # spelling it (a glob, a concatenation, a script written first) is past them.
1821
+ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a session that continues this one, 0 = none
1822
+ local t nc=0
1823
+ t="$CMD_UQ"; _APN=0
1824
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
1825
+ case "$t" in
1826
+ *crewforth-appr*) _APN=1 ;;
1827
+ esac
1828
+ [ "$nc" = 0 ] && shopt -u nocasematch
1829
+ # FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
1830
+ # redirection. `cat …/prompt-approval.sh | grep -n x` and `grep -rn prompt-approval . | head` only read it, and a
1831
+ # first version that looked for a pipe anywhere on the line refused both (review).
1832
+ if [ "$_APN" = 0 ]; then case "$t" in *[Pp][Rr][Oo][Mm][Pp][Tt]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]*)
1833
+ { _ere i "$t" '\|[^|;&]*prompt-approval' '*[Pp][Rr][Oo][Mm][Pp][Tt]-*' \
1834
+ || _ere i "$t" '(prompt-approval[^|;&]*<|<[^|;&]*prompt-approval)' '*[Pp][Rr][Oo][Mm][Pp][Tt]-*'; } && _APN=2 ;;
1835
+ esac; fi
1836
+ if [ "$_APN" = 0 ]; then case "$PERM_MODE" in auto|dontAsk)
1837
+ _ere i "$t" '(^|[^A-Za-z0-9_./-])claude(\.exe|\.cmd)?[[:space:]]+([^;&|]*[[:space:]])?(--continue|--resume|--session-id|--fork-session|-c|-r)([[:space:]=]|$)' '*[Cc][Ll][Aa][Uu][Dd][Ee]*' \
1838
+ && _APN=3 ;;
1839
+ esac; fi
1840
+ }
1841
+ _appr_named
1842
+ [ "$_APN" = 1 ] && block "the approval record named in a command (only the user's own message writes it)" "4.4" tamper
1843
+ [ "$_APN" = 2 ] && block "the approval hook fed by a command (only Claude Code runs it, with the user's message)" "4.4" tamper
1844
+ [ "$_APN" = 3 ] && block "a Claude Code session started from a command to continue this one (it could approve for it)" "4.4" tamper
1845
+
1078
1846
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
1079
1847
  # `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
1080
1848
  # .env / .env.<env> file; the templates (.env.example/.sample/.template/.dist) stay readable. Arg-taking readers
@@ -1328,6 +2096,12 @@ fi
1328
2096
  && block "git update-index --add (bypasses .gitignore, same as git add -f)" "4.5" bypass
1329
2097
  case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -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
1330
2098
 
2099
+ # The rules that read the command as text end here: what follows judges a commit or a push, and reads all of it.
2100
+ # WHETHER there is a commit or a push in the call is still asked of the text without those arguments (CMD_SEEN):
2101
+ # `echo "git push origin main"` pushes nothing, and asking the user to approve an echo is the same mistake as
2102
+ # refusing it. What a commit carries (its options, its message, the approval it needs) is read from the whole call.
2103
+ CMD_SEEN="$CMD"; CMD="$CMD_REAL"; CMD_UQ="$CMD_UQ_REAL"
2104
+
1331
2105
  # --- §4.4 commit/push approval gate ---
1332
2106
  # Escape a shell string into a JSON string body. A raw control character inside a JSON string is a parse
1333
2107
  # error, and the reason text is attacker-adjacent (it is the model's own command line), so:
@@ -1361,6 +2135,232 @@ json_escape(){
1361
2135
  | sed 's/\\/\\\\/g; s/"/\\"/g' \
1362
2136
  | awk 'NR>1{printf "\\n"} {printf "%s", $0}'
1363
2137
  }
2138
+ # ---- CREW-APPROVAL-PATH (one definition, carried by prompt-approval.sh and guard-bash.sh; smoke-test pins it) ----
2139
+ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktree's git directory, "" outside one
2140
+ local d="${1//\\//}" l g
2141
+ _AP=""; d="${d%/}"
2142
+ while :; do
2143
+ if [ -f "$d/.git/HEAD" ]; then _AP="$d/.git/crewforth-approval"; return 0; fi # a git directory, not a folder named .git
2144
+ if [ -f "$d/.git" ]; then # a linked worktree or a submodule: `.git` is a one-line pointer
2145
+ l=""; IFS= read -r l < "$d/.git" || true; l="${l%$'\r'}"
2146
+ case "$l" in "gitdir: "*)
2147
+ g="${l#gitdir: }"; g="${g//\\//}"
2148
+ case "$g" in /*|[A-Za-z]:*) ;; *) g="$d/$g" ;; esac
2149
+ [ -f "$g/HEAD" ] && _AP="$g/crewforth-approval" ;;
2150
+ esac
2151
+ return 0
2152
+ fi
2153
+ case "$d" in */*) d="${d%/*}" ;; *) return 0 ;; esac
2154
+ done
2155
+ }
2156
+ # ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
2157
+ # §4.4 IN `auto` AND `dontAsk`: THE USER'S OWN MESSAGE IS THE APPROVAL. hooks/prompt-approval.sh records it when the
2158
+ # whole message is `approve: commit` / `push` / `commit+push` (or `onay: …`), with what git reported at that moment.
2159
+ # This reads the record back and answers one question: does it cover THIS call, and nothing else?
2160
+ # commit the index writes the recorded tree and HEAD is the recorded one. A commit moves HEAD, so the same
2161
+ # record cannot allow a second one; a commit that FAILED (a hook refused it) moved nothing and may be retried.
2162
+ # push `git push <remote> <branch>`: the recorded remote, still pushing to the recorded URL, the recorded
2163
+ # branch, HEAD on it. Pushing one commit to one branch twice changes nothing, so this needs no counter.
2164
+ # commit+push the push is checked against the commit the approved index BECAME: its parent is the recorded HEAD
2165
+ # and its tree is the recorded tree.
2166
+ # and in every case the session is the one the user wrote in. The record is never written here. It ends 30 minutes
2167
+ # after the message, or at the user's next message (the other hook empties it). §4.5 ran above and §4.6 runs before
2168
+ # this, so neither is opened by an approval.
2169
+ #
2170
+ # THE TREE, NOT THE DIFF. §4.6 hashes the text of `git diff --cached`, and what that prints is configurable:
2171
+ # `git config diff.external true` made every staged diff print nothing, so every diff got one id (measured in
2172
+ # review; §4.6 now passes --no-ext-diff --no-textconv). The id of the tree the index writes depends on the content
2173
+ # alone, whatever else is configured.
2174
+ #
2175
+ # THE CALL HAS TO BE THE GIT COMMAND AND NOTHING ELSE. Where a person sees the prompt they also see the command; here
2176
+ # nobody does. Each of these was allowed by a matching record in a first version, and each does something the user
2177
+ # did not approve (the rows are in smoke-test):
2178
+ # cd ../other && git commit -m x commits what is staged in another repository
2179
+ # GIT_INDEX_FILE=/tmp/i git commit -m x commits another index than the one that was read
2180
+ # git commit -m "$(git add -A; echo msg)" stages everything, then commits it
2181
+ # git commit -m 'a"' ; touch x ; echo 'b"' two more commands, hidden by pairing the wrong quotes
2182
+ # git commit -m x 2>&1 -a git commit -mxm b.txt the working tree, past §4.6's scan
2183
+ # git commit -n -m x git commit --amen -m x --no-verify and --amend, as git abbreviates them
2184
+ # So the text is read the way the shell reads it, left to right, and anything that cannot be judged is refused:
2185
+ # a single-quoted span is literal; a double-quoted one may hold no `$`, backtick, backslash or `!`; the one
2186
+ # substitution let through is a message read from a here-document whose delimiter is quoted,
2187
+ # `-m "$(cat <<'EOF' … EOF)"`, because its body is literal too. What is left must hold no separator, substitution,
2188
+ # redirection, comment, glob or second line, must begin with `git commit` / `git push`, and its arguments are a
2189
+ # short list: a wrong option is refused here whatever §4.5 and §4.6 make of it.
2190
+ # The PowerShell tool reads quotes by other rules (a backslash escapes nothing there, a backtick does), so for it
2191
+ # only single quotes are accepted.
2192
+ _a44_collapse(){ # $1 = command, $2 = commit|push, $3 = tool -> 0 and _A44_S (quoted spans as Q); otherwise _A44_WHY
2193
+ local s="$1" out="" pre c rest w body
2194
+ _A44_WHY=""; _A44_S=""
2195
+ if [ "$3" != Bash ]; then
2196
+ case "$s" in *[\"\`\\\$@]*) _A44_WHY="outside the Bash tool an approved call uses single quotes only (no double quote, backtick, backslash, \$ or @)"; return 1 ;; esac
2197
+ # ...and plain ASCII on one line. PowerShell reads the typographic quotes (U+2018, U+2019 and their kin) as
2198
+ # single quotes, so `'a ’ ; cmd ; ‘ b'` is one quoted text here and three commands there; and a `\u` escape in the
2199
+ # payload reaches this code as `?`. Neither can be told apart from text inside a quoted span, so both are refused.
2200
+ local LC_ALL=C
2201
+ case "$s" in *[!\ -~]*|*\?*) _A44_WHY="outside the Bash tool an approved call is plain ASCII on one line (PowerShell reads typographic quotes as quotes); use the Bash tool for any other message"; return 1 ;; esac
2202
+ fi
2203
+ _gsub "$s" $'\r' ''; s="$_GS"
2204
+ while :; do
2205
+ pre="${s%%[\"\'\\]*}"; out="$out$pre"
2206
+ [ "$pre" = "$s" ] && break
2207
+ c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
2208
+ case "$c" in
2209
+ \\) case "$s" in
2210
+ $'\n'*) out="$out "; s="${s:1}" ;; # a backslash-newline joins two lines
2211
+ *) _A44_WHY="a backslash outside quotes"; return 1 ;;
2212
+ esac ;;
2213
+ \') case "$s" in *\'*) ;; *) _A44_WHY="an unpaired quote"; return 1 ;; esac
2214
+ body="${s%%\'*}"; s="${s:${#body}+1}"; out="${out}Q" ;;
2215
+ *) case "$s" in
2216
+ '$(cat <<'\'*)
2217
+ rest="${s:9}"; w="${rest%%\'*}"
2218
+ case "$w" in ''|*[!A-Za-z0-9_]*) _A44_WHY="a here-document whose delimiter is not a plain quoted word"; return 1 ;; esac
2219
+ rest="${rest:${#w}+1}"
2220
+ case "$rest" in $'\n'*) rest="${rest:1}" ;; *) _A44_WHY="text on the line that opens the here-document"; return 1 ;; esac
2221
+ # The body ends at the FIRST line that is the delimiter, as it does for the shell.
2222
+ case "$rest" in
2223
+ "$w"$'\n'*) rest="${rest:${#w}+1}" ;;
2224
+ *$'\n'"$w"$'\n'*) body="${rest%%$'\n'"$w"$'\n'*}"; rest="${rest:${#body}+${#w}+2}" ;;
2225
+ *) _A44_WHY="a here-document that is not closed on its own line"; return 1 ;;
2226
+ esac
2227
+ while :; do case "$rest" in [$' \t']*) rest="${rest:1}" ;; *) break ;; esac; done
2228
+ case "$rest" in
2229
+ ')"'*) s="${rest:2}"; out="${out}Q" ;;
2230
+ *) _A44_WHY="something follows the here-document inside the substitution"; return 1 ;;
2231
+ esac ;;
2232
+ *\"*)
2233
+ body="${s%%\"*}"
2234
+ case "$body" in *[\$\`\\\!]*) _A44_WHY="a double-quoted text that holds \$, a backtick, a backslash or ! (single-quote it, or read the message from a here-document with a quoted delimiter)"; return 1 ;; esac
2235
+ s="${s:${#body}+1}"; out="${out}Q" ;;
2236
+ *) _A44_WHY="an unpaired quote"; return 1 ;;
2237
+ esac ;;
2238
+ esac
2239
+ done
2240
+ out="${out//$'\t'/ }"; out="${out// 2>&1/ }"
2241
+ while :; do case "$out" in [$' \n']*) out="${out:1}" ;; *) break ;; esac; done
2242
+ while :; do case "$out" in *[$' \n']) out="${out%?}" ;; *) break ;; esac; done
2243
+ case "$out" in *[$'\n'\;\&\|\`\$\(\)\{\}\<\>\#\*\?\[\]\~\!\%\^]*)
2244
+ _A44_WHY="the call is more than one plain command (a separator, a substitution, a redirection, a comment, a glob or a second line)"; return 1 ;; esac
2245
+ case "$out" in "git $2"|"git $2 "*|"git.exe $2"|"git.exe $2 "*) ;;
2246
+ *) _A44_WHY="the call does not begin with 'git $2' (a cd, a variable or an option comes first)"; return 1 ;; esac
2247
+ _A44_S="$out"; return 0
2248
+ }
2249
+ _c44_scan(){ # $1 = the collapsed `git commit …` -> 0 when every argument is a message or one of -q -s -v; else _C44_WHY
2250
+ local tok body pre unglob=0
2251
+ _C44_WHY=""
2252
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
2253
+ set -- $1
2254
+ [ "$unglob" = 1 ] && set +f
2255
+ shift 2
2256
+ while [ $# -gt 0 ]; do
2257
+ tok="$1"; shift
2258
+ case "$tok" in
2259
+ --message) [ $# -gt 0 ] || { _C44_WHY="--message with no text"; return 1; }; shift ;;
2260
+ --message=?*|--quiet|--signoff|--verbose) ;;
2261
+ --*) _C44_WHY="the option $tok"; return 1 ;;
2262
+ -?*) # a cluster: -q -s -v in any order, then at most one -m, whose text is the rest of the token or the next one
2263
+ body="${tok#-}"; pre="${body%%m*}"
2264
+ case "$pre" in *[!qsv]*) _C44_WHY="the option $tok"; return 1 ;; esac
2265
+ if [ "$pre" != "$body" ] && [ -z "${body#*m}" ]; then
2266
+ [ $# -gt 0 ] || { _C44_WHY="-m with no text"; return 1; }; shift
2267
+ fi ;;
2268
+ *) _C44_WHY="a path or an argument that is not a message ($tok)"; return 1 ;;
2269
+ esac
2270
+ done
2271
+ return 0
2272
+ }
2273
+ _p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u] <remote> <ref>`; sets _P44_REMOTE _P44_REF, or _P44_WHY
2274
+ local tok npos=0 unglob=0
2275
+ _P44_REMOTE=""; _P44_REF=""; _P44_WHY=""
2276
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
2277
+ set -- $1
2278
+ [ "$unglob" = 1 ] && set +f
2279
+ shift 2
2280
+ while [ $# -gt 0 ]; do
2281
+ tok="$1"; shift
2282
+ case "$tok" in
2283
+ -u|--set-upstream|-q|--quiet|-v|--verbose|--progress|--no-progress) ;;
2284
+ -*) _P44_WHY="the option $tok"; return 1 ;;
2285
+ *) npos=$((npos+1))
2286
+ case "$npos" in 1) _P44_REMOTE="$tok" ;; 2) _P44_REF="$tok" ;; *) _P44_WHY="more than one refspec"; return 1 ;; esac ;;
2287
+ esac
2288
+ done
2289
+ [ "$npos" = 2 ] || { _P44_WHY="the remote and the branch are not both written out"; return 1; }
2290
+ return 0
2291
+ }
2292
+ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
2293
+ local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool
2294
+ _APW="no approval from the user is on record"
2295
+ git_has "$CMD_SEEN" 'commit' && hc=0; git_has "$CMD_SEEN" 'push' && hp=0
2296
+ if [ "$hc" = 0 ] && [ "$hp" = 0 ]; then
2297
+ _APW="this call holds a commit and a push; each is checked against the approval on its own, so run them as two calls"; return 1
2298
+ fi
2299
+ _crew_appr_path "$dir"
2300
+ [ -n "$_AP" ] && [ -s "$_AP" ] || return 1
2301
+ while IFS='=' read -r k v || [ -n "$k" ]; do
2302
+ v="${v%$'\r'}"
2303
+ case "$k" in op) op="$v" ;; tree) tr="$v" ;; head) h="$v" ;; branch) b="$v" ;; remote) r="$v" ;; url) u="$v" ;; sid) sid="$v" ;; ts) ts="$v" ;; esac
2304
+ done < "$_AP"
2305
+ case "$ts" in ''|*[!0-9]*) _APW="the approval record could not be read"; return 1 ;; esac
2306
+ if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
2307
+ printf -v now '%(%s)T' -1
2308
+ else now="$(date +%s)"; fi
2309
+ case "$now" in ''|*[!0-9]*) _APW="the clock could not be read"; return 1 ;; esac
2310
+ age=$((now - ts))
2311
+ if [ "$age" -lt 0 ] || [ "$age" -gt 1800 ]; then _APW="the user's approval is more than 30 minutes old"; return 1; fi
2312
+ # The session the user wrote in. A session started from a command here is another one, whatever it is told.
2313
+ _json_slice "$INPUT" session_id >/dev/null
2314
+ if [ -z "$sid" ] || [ "$sid" != "$_JS" ]; then _APW="the approval on record was given in another session"; return 1; fi
2315
+ _json_slice "$INPUT" tool_name >/dev/null; tool="$_JS"
2316
+ if [ "$hc" = 0 ]; then
2317
+ case "$op" in commit|commit+push) ;; *) _APW="the approval on record is for a push, not for a commit"; return 1 ;; esac
2318
+ _a44_collapse "$CMD" commit "$tool" || { _APW="$_A44_WHY. Run the commit alone: git commit -m '…'"; return 1; }
2319
+ _c44_scan "$_A44_S" || { _APW="an approved commit takes its message and -q, -s or -v, nothing else ($_C44_WHY)"; return 1; }
2320
+ cur="$(git -C "$dir" write-tree 2>/dev/null)"
2321
+ # HAVE_H is §4.6's own reading of HEAD, taken a few lines above for this call.
2322
+ if [ -z "$tr" ] || [ "$tr" != "$cur" ] || [ "$h" != "${HAVE_H:-}" ]; then
2323
+ _APW="what is staged, or HEAD, is not what the user approved (approved tree ${tr:0:7} on ${h:0:7}; now ${cur:0:7} on ${HAVE_H:0:7})"; return 1
2324
+ fi
2325
+ return 0
2326
+ fi
2327
+ case "$op" in push|commit+push) ;; *) _APW="the approval on record is for a commit, not for a push"; return 1 ;; esac
2328
+ # No quoted argument in a push: a quoted span is compared as a placeholder, and a branch may be named like one.
2329
+ case "$CMD" in *[\"\']*) _APW="a quote in the push command. Run the push alone and unquoted: git push <remote> <branch>"; return 1 ;; esac
2330
+ _a44_collapse "$CMD" push "$tool" || { _APW="$_A44_WHY. Run the push alone: git push <remote> <branch>"; return 1; }
2331
+ _p44_scan "$_A44_S" || { _APW="the push is not in the one form an approval covers, git push <remote> <branch> ($_P44_WHY)"; return 1; }
2332
+ [ -n "$b" ] && [ -n "$r" ] && [ -n "$u" ] || { _APW="the approval record could not be read"; return 1; }
2333
+ if [ "$_P44_REMOTE" != "$r" ]; then _APW="the user approved a push to $r, not to $_P44_REMOTE"; return 1; fi
2334
+ case "$_P44_REF" in "$b"|HEAD) ;; *) _APW="the user approved a push of $b, not of $_P44_REF"; return 1 ;; esac
2335
+ cur="$(git -C "$dir" symbolic-ref --short -q HEAD 2>/dev/null || true)"
2336
+ [ "$cur" = "$b" ] || { _APW="the user approved a push of $b, and the branch checked out now is ${cur:-<detached>}"; return 1; }
2337
+ # Where the remote pushes TO, with pushurl and pushInsteadOf applied: the name alone can be pointed elsewhere.
2338
+ cur="$(git -C "$dir" remote get-url --push "$r" 2>/dev/null)"
2339
+ [ "$cur" = "$u" ] || { _APW="the remote $r does not push to the address it pushed to when the user approved"; return 1; }
2340
+ # ...and to the branch of the same NAME. `git push origin feat/x` names no destination, and with a
2341
+ # `remote.origin.push` mapping git picks it from there: measured in review, `feat/x -> main`.
2342
+ cur="$(git -C "$dir" config --get-all "remote.$r.push" 2>/dev/null || true)"
2343
+ [ -z "$cur" ] || { _APW="the remote $r has a push mapping (remote.$r.push), so the branch this push lands on is not the one named"; return 1; }
2344
+ cur="$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
2345
+ if [ "$op" = push ]; then
2346
+ [ "$cur" = "$h" ] || { _APW="HEAD moved after the user approved the push (approved ${h:0:7}, now ${cur:0:7})"; return 1; }
2347
+ return 0
2348
+ fi
2349
+ # commit+push: HEAD has to be the commit the approved index became.
2350
+ cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^' 2>/dev/null || echo NONE)"
2351
+ if [ "$h" = NONE ] || [ "$cur" != "$h" ]; then
2352
+ _APW="HEAD is not the commit the user approved (its parent is ${cur:0:7}, the approval was given on ${h:0:7})"; return 1
2353
+ fi
2354
+ cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"
2355
+ [ -n "$tr" ] && [ "$cur" = "$tr" ] || { _APW="the commit on HEAD does not carry what the user approved"; return 1; }
2356
+ return 0
2357
+ }
2358
+ allow_approved(){
2359
+ gatelog ALLOW 4.4 "the user's own approval message covers this command"
2360
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
2361
+ "$(json_escape "§4.4: the user approved this in their own message, and what was approved is what this command does")"
2362
+ exit 0
2363
+ }
1364
2364
  # Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
1365
2365
  ask_user(){
1366
2366
  gatelog ASK 4.4 "commit/push approval prompt"
@@ -1391,7 +2391,402 @@ allow_preauthorised(){
1391
2391
  # one and never runs when the key is set. A pre-authorised session commits WITHOUT a review record. The
1392
2392
  # payload CLAUDE.md §4.6 states it ("Deliberate skip: … CLAUDE_GIT_OK (headless/CI) bypasses this too")
1393
2393
  # and it is written here as well, because the person reading the hook is not reading that file.
1394
- if git_has "$CMD" 'add|commit|push|checkout|switch'; then
2394
+ # §4.5 / §4.6 — WHAT A `git commit` REALLY CARRIES, READ THE WAY THE SHELL AND GIT READ IT.
2395
+ # The rules above and the scan of §4.6 below judge the command as text: a literal `--no-verify`, a literal `--amend`,
2396
+ # a token walk that pairs every double quote before any single one and stops at the first `&`. A review of the
2397
+ # approval route (3.1.0) measured what that lets through in the modes where the gate ASKS, and with CLAUDE_GIT_OK
2398
+ # where nobody is asked at all — each line below reached the prompt with no rule firing, and each was run for real:
2399
+ # git commit -n -m x --no-verif / --no-veri hooks skipped (-n IS --no-verify; git takes abbreviations)
2400
+ # git commit --amen -m x --am the last commit rewritten
2401
+ # git commit -m x 2>&1 -a &> log -a >& log -a the working tree committed (the walk stopped at `&`)
2402
+ # git commit -mxm b.txt b.txt committed (-m took `xm`; the walk swallowed b.txt)
2403
+ # git commit -m 'a"' -a ; echo 'b"' -a hidden inside quotes paired the wrong way
2404
+ # git commit -F - <<END -a -a after a here-document operator
2405
+ # git commit -m a\;b -a an escaped `;` read as a separator
2406
+ # bash -c 'git commit -am x' eval "git commit -a …" a commit this gate cannot read at all
2407
+ # cd ../other && git commit … pushd … env -C … another repository than the one the record is for
2408
+ # GIT_INDEX_FILE=… git commit GIT_DIR=… export GIT_… another index, another repository
2409
+ # GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath … the hooks switched off through the environment
2410
+ # So the command is read left to right: quotes as the shell pairs them, an escaped character as a character, a
2411
+ # here-document's body as data, `2>&1` / `&>` / `>&` as redirections and not as separators. Then every `git commit`
2412
+ # in the call is read with git's own option table: a cluster letter by letter, a value where git takes one, a long
2413
+ # option only when it is written out in full.
2414
+ # THIS ONLY EVER ADDS A REFUSAL. The older rules and `_c46_scan` still run and still decide; nothing they refuse is
2415
+ # let through here. An option this table does not know is refused, with the reason: an abbreviation is how the nine
2416
+ # lines above got in, and guessing which option it stands for is the same mistake again.
2417
+ # _c47_read and _c47_w, which read it, stand above the gate-file rule: that rule reads a call with them too.
2418
+ _c47_exp(){ # $1 = a raw token of a commit, $2 = the word it reads as -> sets _C47_EXP when the shell would change it
2419
+ # What the shell will make of an argument has to be readable. An unquoted `$NAME` is, when NAME was given one
2420
+ # literal word earlier in the call; a glob, a brace list, or any other expansion is not: measured,
2421
+ # `o=' -a'; git commit -m x$o`, `git commit -m {x,-a}` and `git commit -m ?.txt` all committed the working tree.
2422
+ local v="$1" m="$_C47_M" bad="${_WAT:-0}" c
2423
+ while :; do case "$v" in *"$m"*) v="${v%%"$m"*}${v#*"$m"*"$m"}" ;; *) break ;; esac; done # the unquoted part
2424
+ case "$v" in *[\*\?\[\{]*) bad=1 ;; esac
2425
+ while [ "$bad" = 0 ]; do
2426
+ case "$v" in *\$*) ;; *) break ;; esac
2427
+ v="${v#*\$}"; c="${v%%[!A-Za-z0-9_]*}"
2428
+ case "$_C47_VARS" in *" $c "*) [ -n "$c" ] || bad=1 ;; *) bad=1 ;; esac
2429
+ done
2430
+ [ "$bad" = 1 ] && _C47_EXP="${2:0:60}"
2431
+ return 0
2432
+ }
2433
+ _c47_val(){ # $1 = the raw token an option takes as its value: the same question
2434
+ case "$1" in *[\$\*\?\[\{]*|*"$_C47_M"*) _c47_w "$1"; [ "$_WX" = 1 ] && _c47_exp "$1" "$_W" ;; esac
2435
+ return 0
2436
+ }
2437
+ _c47_args(){ # $1 = 1: the words after `git` (Start-Process git …) | 0: all of them (a command word held in a variable)
2438
+ # $2… = the tokens up to the next separator. Appends one line to _C47_ARGS: those words written as the
2439
+ # git command they are, `git push --force origin main`, so the rules that judge a git command can read it.
2440
+ # PowerShell hands the arguments over as a list ('push','--force') or as one string ("push --force"):
2441
+ # both come out as the same words. Start-Process's own parameters are left out; they are not git's.
2442
+ local v w out="" on=1 nc=0
2443
+ [ "$1" = 1 ] && on=0
2444
+ shift
2445
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
2446
+ for v in "$@"; do
2447
+ [ "$v" = ";" ] && break
2448
+ _c47_w "$v"; w="${_W//,/ }"
2449
+ if [ "$on" = 0 ]; then case "$w" in git|git.exe) on=1 ;; esac; continue; fi
2450
+ [ "$_WQ" = 0 ] && case "$w" in
2451
+ -FilePath|-ArgumentList|-Args|-Credential|-WorkingDirectory|-LoadUserProfile|-NoNewWindow|-PassThru|-RedirectStandardError|-RedirectStandardInput|-RedirectStandardOutput|-WindowStyle|-Wait|-UseNewEnvironment|-Verb|-Confirm*|-WhatIf) continue ;;
2452
+ esac
2453
+ out="$out $w"
2454
+ done
2455
+ [ "$nc" = 0 ] && shopt -u nocasematch
2456
+ [ "$on" = 1 ] && [ -n "$out" ] && _C47_ARGS="$_C47_ARGS${_C47_ARGS:+$'\n'}git$out"
2457
+ return 0
2458
+ }
2459
+ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_AM _C47_UNK _C47_EXP _C47_ENV _C47_SH _C47_CFG
2460
+ # _C47_CD (0 none · 1 one plain target, in _C47_CDT · 2 a target that cannot be read)
2461
+ local tok ph=cmd unglob=0 body c v m i raw rcd=0 rcdt="" envf=0 wrapped=0 novars=0 vc=0
2462
+ _C47_VARS=" "
2463
+ _C47_SHRE='(^|[^A-Za-z0-9_-])git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*commit([[:space:]]|$)'
2464
+ _C47_N=0; _C47_WT=""; _C47_NV=""; _C47_AM=""; _C47_UNK=""; _C47_EXP=""; _C47_ENV=""; _C47_SH=""; _C47_CFG=""; _C47_UNR=""; _C47_SUB=""; _C47_ARGS=""; _C47_CD=0; _C47_CDT=""
2465
+ _c47_read "$1"; m="$_C47_M"
2466
+ case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
2467
+ set -- $_C47_T
2468
+ [ "$unglob" = 1 ] && set +f
2469
+ while [ $# -gt 0 ]; do
2470
+ raw="$1"; shift
2471
+ [ "$raw" = ";" ] && { ph=cmd; wrapped=0; vc=0; continue; }
2472
+ _WAT=0
2473
+ case "$raw" in "$m"*|*"$m"*) _c47_w "$raw" ;; *) _W="$raw"; _WQ=0; case "$raw" in *[\$\*\?\[\{]*) _WX=1 ;; *) _WX=0 ;; esac ;; esac
2474
+ tok="$_W"
2475
+ # THE VARIABLES THAT CHANGE WHAT GIT READS — the repository, the index, the object store, the configuration —
2476
+ # wherever in the call they are set or exported: in front of the commit, in an earlier command, as an argument
2477
+ # of export or env, quoted or not. HOME and XDG_CONFIG_HOME are among them: they decide which global
2478
+ # configuration git reads. GIT_AUTHOR_* / GIT_COMMITTER_* only describe the commit and are let through.
2479
+ v="$tok"; case "$v" in '$'[Ee][Nn][Vv]:*) v="${v:5}" ;; esac # PowerShell: $env:GIT_DIR='…'
2480
+ case "${v%%=*}" in
2481
+ GIT_DIR|GIT_WORK_TREE|GIT_INDEX_FILE|GIT_COMMON_DIR|GIT_OBJECT_DIRECTORY|GIT_ALTERNATE_OBJECT_DIRECTORIES|GIT_NAMESPACE|GIT_EXEC_PATH|GIT_CONFIG*)
2482
+ [ "$ph" = commit ] || envf=1 ;; # named (export GIT_DIR) or assigned
2483
+ HOME|XDG_CONFIG_HOME)
2484
+ [ "$ph" = commit ] || case "$v" in *=*) envf=1 ;; esac ;; # only when assigned
2485
+ esac
2486
+ case "$ph" in
2487
+ skip) # Not a command this table knows. A bare `git` further along the same command is still git (timeout 10 git …,
2488
+ # xargs git …, nice git …), so the reading goes on from there.
2489
+ case "$tok" in git|git.exe|*/git|*/git.exe) [ "$_WX" = 0 ] && ph=git ;; esac
2490
+ # The command word was a variable (`$g commit -n`, PowerShell's `& $g commit -n`): what runs cannot be read.
2491
+ [ "$vc" = 1 ] && [ "$tok" = commit ] && _C47_UNR="a command held in a variable, followed by commit"
2492
+ continue ;;
2493
+ cmd)
2494
+ case "$tok" in
2495
+ [A-Za-z_]*=*|[A-Za-z_]*+=*)
2496
+ v="${tok%%=*}"; v="${v%+}"
2497
+ case "$v" in *[!A-Za-z0-9_]*) ph=skip ;; *)
2498
+ # A plain assignment. Its name is remembered when the value is one literal word: only then may a later
2499
+ # `$NAME` stand inside a commit argument (the shell cannot split or expand it into something else).
2500
+ # IFS changes how every one of them is split, and after `read`, `unset`, `export` and
2501
+ # their kin (below) a name no longer holds what it was given: all of those forget instead of remember.
2502
+ c="${tok#*=}"
2503
+ [ "$v" = IFS ] && novars=1
2504
+ case "$novars$_WX$c" in 1*|01*|*[$' \t\n']*|*[\*\?\[\{\$\`]*|00) _C47_VARS="${_C47_VARS/ $v / }" ;; *) _C47_VARS="$_C47_VARS$v " ;; esac
2505
+ [ "$novars" = 1 ] && _C47_VARS=" " ;;
2506
+ esac ;;
2507
+ '{'|'}'|'!'|if|then|else|elif|fi|do|done|while|until|time|sudo|command|builtin|exec|nohup|nice) wrapped=1 ;; # the command follows
2508
+ -*) [ "$wrapped" = 1 ] || ph=skip ;; # an option of that wrapper
2509
+ export|declare|typeset|readonly|local|read|unset|let|source|.|for|select|getopts|mapfile|readarray) novars=1; _C47_VARS=" "; ph=skip ;;
2510
+ set|shift) ph=skip ;;
2511
+ printf) case " $* " in *" -v "*) novars=1; _C47_VARS=" " ;; esac; ph=skip ;;
2512
+ env) ph=env ;;
2513
+ cd|pushd|chdir|Set-Location|sl|Push-Location)
2514
+ # Where the commit will run. One plain target can be resolved and compared; anything else cannot.
2515
+ while :; do case "${1:-}" in -[!-]*|--) shift ;; *) break ;; esac; done
2516
+ v="${1:-}"
2517
+ case "$v" in ''|\;) v="" ;; *) _c47_w "$v"; v="$_W"; [ "$_WX" = 1 ] && v=""; [ "$v" = - ] && v="" ;; esac
2518
+ # `cd "$(git rev-parse --show-toplevel)" && git commit …` goes to the top of THIS repository: no change.
2519
+ [ "$_W" = '$(git rev-parse --show-toplevel)' ] && { ph=skip; continue; }
2520
+ if [ -n "$v" ] && [ "$rcd" = 0 ]; then rcd=1; rcdt="$v"; else rcd=2; fi
2521
+ ph=skip ;;
2522
+ Start-Process|saps|start)
2523
+ # PowerShell: `Start-Process git -ArgumentList 'commit','-n','-m','x'` (measured: committed, hooks skipped).
2524
+ c=0
2525
+ for v in "$@"; do
2526
+ [ "$v" = ";" ] && break
2527
+ _c47_w "$v"; case "$_W" in git|git.exe) c=1 ;; *commit*) [ "$c" = 1 ] && _C47_SH="$tok" ;; esac
2528
+ done
2529
+ _c47_args 1 "$@"
2530
+ ph=skip ;;
2531
+ bash|sh|zsh|dash|ksh|eval|xargs|trap|pwsh|powershell|powershell.exe|pwsh.exe|cmd|cmd.exe|Invoke-Expression|iex|*/bash|*/sh|*/zsh)
2532
+ # A shell handed a QUOTED script that holds a commit: `bash -c 'git commit -am x'`, `eval "git commit -a"`.
2533
+ # Its arguments cannot be read from here. `bash build.sh` beside a here-document that merely mentions
2534
+ # git commit is not that, and a first version that looked only at the word `bash` refused 40 such calls.
2535
+ for v in "$@"; do
2536
+ # Invoke-Expression takes an expression: `Invoke-Expression ('git commit -' + 'n -m x')` puts the script
2537
+ # behind a parenthesis, which reads as a separator here, so for it the look goes on to the end of the call.
2538
+ [ "$v" = ";" ] && { case "$tok" in Invoke-Expression|iex) continue ;; esac; break; }
2539
+ case "$v" in *"$m"*) _c47_w "$v"; [[ $_W =~ $_C47_SHRE ]] && _C47_SH="$tok" ;; esac
2540
+ done
2541
+ case "$tok" in eval|xargs) wrapped=1 ;; *) ph=skip ;; esac ;; # after eval / xargs the command is read on
2542
+ git|git.exe|*/git|*/git.exe) ph=git ;;
2543
+ *) [ "$_WX" = 1 ] && { vc=1; _c47_args 0 "$@"; }; ph=skip ;;
2544
+ esac ;;
2545
+ env)
2546
+ case "$tok" in
2547
+ -C|--chdir) rcd=2; shift ;;
2548
+ --chdir=*|-C?*) rcd=2 ;;
2549
+ -u|--unset|-S|--split-string) shift ;;
2550
+ -*|[A-Za-z_]*=*) ;;
2551
+ git|git.exe|*/git|*/git.exe) ph=git ;;
2552
+ *) ph=skip ;;
2553
+ esac ;;
2554
+ git)
2555
+ case "$tok" in
2556
+ -c|--config-env)
2557
+ # A setting for this one command. The ones that change what a commit does are refused whatever the
2558
+ # quoting: `-c 'core.hooksPath=/dev/null'`, `-c include.path=f`, `-c alias.ci='commit -a'`.
2559
+ v="${1:-}"; [ $# -gt 0 ] && shift
2560
+ _c47_w "$v"
2561
+ if [ "$_WX" = 1 ]; then _C47_CFG="a -c setting the shell expands first"
2562
+ else
2563
+ shopt -q nocasematch && i=1 || i=0; shopt -s nocasematch
2564
+ case "$_W" in *hookspath*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
2565
+ [ "$i" = 0 ] && shopt -u nocasematch
2566
+ fi ;;
2567
+ -C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix) shift ;;
2568
+ -*) ;;
2569
+ @*|*\$*) # (a backtick arrives here as `$`: the reader writes one for the substitution it opens)
2570
+ # The subcommand is filled in by the shell: `git $c -n` after c=commit, PowerShell's splat `git @a`
2571
+ # after $a = @('commit','-n') (measured on PowerShell 5.1: both committed with the hooks skipped) —
2572
+ # and a subcommand with an expansion INSIDE it, quoted or not: `git com${z}mit`, `git "$c"`,
2573
+ # `git $(printf com)mit`, `git pu${z}sh --force`. No rule that looks for a git command by its
2574
+ # name sees these (measured: each passed every gate, and git ran the command).
2575
+ _C47_SUB="${tok:0:40}"; ph=skip ;;
2576
+ commit) ph=commit; _C47_N=$((_C47_N+1))
2577
+ if [ "$rcd" != 0 ] && [ "$_C47_CD" = 0 ]; then _C47_CD=$rcd; _C47_CDT="$rcdt"; fi ;;
2578
+ *) ph=skip ;;
2579
+ esac ;;
2580
+ commit)
2581
+ case "$raw" in
2582
+ '<<H') continue ;; # a here-document; arguments may follow it
2583
+ '<<<') shift; continue ;;
2584
+ [\<\>]*|[0-9][\<\>]*|[0-9][0-9][\<\>]*|'&>'*) case "$raw" in *[\<\>\&]) case "${1:-}" in ''|\;) ;; *) shift ;; esac ;; esac; continue ;;
2585
+ esac
2586
+ [ "$_WX" = 1 ] && _c47_exp "$raw" "$tok"
2587
+ case "$tok" in
2588
+ --) case "${1:-}" in ''|\;) ;; *) _C47_WT="a pathspec after --" ;; esac; ph=skip ;;
2589
+ --message|--file|--author|--date|--reedit-message|--reuse-message|--fixup|--squash|--cleanup|--template|--trailer|--unified|--inter-hunk-context)
2590
+ case "${1:-}" in ''|\;) ;; *) _c47_val "$1"; shift ;; esac ;;
2591
+ --message=*|--file=*|--author=*|--date=*|--reedit-message=*|--reuse-message=*|--fixup=*|--squash=*|--cleanup=*|--template=*|--trailer=*|--unified=*|--inter-hunk-context=*|--gpg-sign|--gpg-sign=*|--untracked-files|--untracked-files=*) ;;
2592
+ --signoff|--quiet|--verbose|--dry-run|--short|--branch|--porcelain|--long|--null|--edit|--allow-empty|--allow-empty-message|--post-rewrite|--status|--reset-author|--ahead-behind|--verify|--pathspec-file-nul|--help) ;;
2593
+ --no-signoff|--no-quiet|--no-verbose|--no-dry-run|--no-short|--no-branch|--no-porcelain|--no-long|--no-null|--no-edit|--no-allow-empty|--no-allow-empty-message|--no-post-rewrite|--no-status|--no-reset-author|--no-ahead-behind|--no-all|--no-only|--no-include|--no-interactive|--no-patch|--no-amend|--no-untracked-files) ;;
2594
+ --all) _C47_WT="--all" ;;
2595
+ --only) _C47_WT="--only" ;;
2596
+ --include) _C47_WT="--include" ;;
2597
+ --interactive|--patch) _C47_WT="$tok" ;;
2598
+ --pathspec-from-file|--pathspec-from-file=*) _C47_WT="--pathspec-from-file" ;;
2599
+ --no-verify) _C47_NV="--no-verify" ;;
2600
+ --amend) _C47_AM="--amend" ;;
2601
+ --no-gpg-sign) ;; # §4.5 above refuses it by name
2602
+ --*) # Not an option git commit has under that exact name: an abbreviation, or something newer than this table.
2603
+ case "--no-verify" in "$tok"*) _C47_NV="$tok (git reads it as --no-verify)" ;; esac
2604
+ case "--amend" in "$tok"*) _C47_AM="$tok (git reads it as --amend)" ;; esac
2605
+ _C47_UNK="$tok" ;;
2606
+ -?*) # A cluster. Letter by letter, as git does: a flag, or a letter that takes a value — the rest of the
2607
+ # token when there is a rest, the next token when there is none (-m -F -t -C -c -U), never the next one
2608
+ # for -S and -u, whose value is optional and attached.
2609
+ body="${tok#-}"
2610
+ while [ -n "$body" ]; do
2611
+ c="${body:0:1}"; body="${body:1}"
2612
+ case "$c" in
2613
+ q|v|s|e|z|h) ;;
2614
+ n) _C47_NV="-n in ${tok:0:40} (git reads it as --no-verify)" ;;
2615
+ a) _C47_WT="-a in ${tok:0:40}" ;;
2616
+ o) _C47_WT="-o in ${tok:0:40}" ;;
2617
+ i) _C47_WT="-i in ${tok:0:40}" ;;
2618
+ p) _C47_WT="-p in ${tok:0:40}" ;;
2619
+ m|F|t|C|c|U) [ -z "$body" ] && case "${1:-}" in ''|\;) ;; *) _c47_val "$1"; shift ;; esac; body="" ;;
2620
+ S|u) body="" ;;
2621
+ *) _C47_UNK="${tok:0:40}"; body="" ;;
2622
+ esac
2623
+ done ;;
2624
+ *) _C47_WT="a pathspec" ;;
2625
+ esac ;;
2626
+ esac
2627
+ done
2628
+ [ "$envf" = 1 ] && [ "$_C47_N" != 0 ] && _C47_ENV="a variable that changes what git reads"
2629
+ return 0
2630
+ }
2631
+
2632
+ # The §4.5 half of what that reading finds is judged HERE, before the pre-authorised branch below: with CLAUDE_GIT_OK
2633
+ # nobody is asked, so a commit that skips its hooks or rewrites the last commit must not reach that allow. (§4.6 is
2634
+ # not judged for such a session, by design: it commits without a review record.)
2635
+ # The reading runs for a commit this hook recognises — and for a call that merely holds the words `git` and `commit`,
2636
+ # because `'git' commit -n`, `git 'commit' -n` and `git -c alias.ci=commit ci -n` are commits the recognition above
2637
+ # (a pattern on the text) does not see at all: measured, each ran with every commit gate silent.
2638
+ _C47_N=0; _C47_WT=""; _C47_CD=0; _C47_SUB=""; _c47_seen=0
2639
+ _C47_MAX=32768
2640
+ _cmd_bytes(){ local LC_ALL=C; _CB=${#1}; } # $1 = text -> _CB: its length in bytes, whatever the session's locale
2641
+ if git_has "$CMD_SEEN" 'commit'; then _c47_seen=1; fi
2642
+ _c47_try=0
2643
+ case "$CMD_SEEN" in *[Mm][Ii][Tt]*) # with quotes, backslashes and joined lines taken out: `g\it`, `com\<newline>mit`
2644
+ _gsub "$CMD_SEEN" '\\'$'\n' '' bs; _unquoted "$_GS"; _t="$_GS"
2645
+ # Both words, in either order: `$a = @('commit','-n'); git @a` names commit first.
2646
+ case "$_t" in *[Gg][Ii][Tt]*) case "$_t" in *[Cc][Oo][Mm][Mm][Ii][Tt]*) _c47_try=1 ;; esac ;; esac ;;
2647
+ esac
2648
+ # The same reading for a git call whose words the shell fills in: a `git` with a `$` or a backtick after it. Which
2649
+ # word that is — the subcommand, or an ordinary argument such as `-C "$dir"` — only the reading can tell.
2650
+ _c47_sub=0
2651
+ case "$CMD" in *[Gg][Ii][Tt]*[\$\`]*) _c47_sub=1 ;; esac
2652
+ _c47_no(){ # the lines for the session; the rule is logged by the caller, by its literal name
2653
+ echo "GUARD (§4.5): $1" >&2; shift
2654
+ while [ $# -gt 0 ]; do echo "$1" >&2; shift; done
2655
+ exit 2
2656
+ }
2657
+ # PowerShell can start a program without writing it as a command: [Diagnostics.Process]::Start('git','commit -n -m x'),
2658
+ # or a ProcessStartInfo handed to it. The arguments are one string that no rule here reads as a git command
2659
+ # (measured on Windows: the gate was silent). So git and that class in one PowerShell call are refused; the class with
2660
+ # any other program, and git as a command, are not touched. Through the PowerShell tool only: in a Bash call the two
2661
+ # words together were, on 4986 real commands, a here-document that mentions them (2 of 2).
2662
+ case "$INPUT" in *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*) _c47_ps=1 ;; *) _c47_ps=0 ;; esac
2663
+ [ "$_c47_ps" = 1 ] && case "$CMD_UQ" in *[Pp][Rr][Oo][Cc][Ee][Ss][Ss]*)
2664
+ if _ere i "$CMD_UQ" '(diagnostics\.process|\[process(startinfo)?\])' '*' \
2665
+ && _ere i "$CMD_UQ" '(^|[^A-Za-z0-9_-])git(\.exe)?([^A-Za-z0-9_-]|$)' '*[Gg][Ii][Tt]*'; then
2666
+ gatelog BLOCK 4.5 "git started through System.Diagnostics.Process"
2667
+ _c47_no \
2668
+ "this call starts git through System.Diagnostics.Process, where its arguments are a string no rule reads." \
2669
+ "Run git as a command, so the gate can see which one it is: git commit …, git push …"
2670
+ fi ;;
2671
+ esac
2672
+ # PowerShell can also hand git its arguments instead of writing them after it: Start-Process git -ArgumentList
2673
+ # 'push','--force', or a command word held in a variable (`& $g push --force`). A commit written that way is refused
2674
+ # below; push --force, reset --hard and clean -f were not read at all (measured: 21 of 23 such forms passed; the two
2675
+ # that did not hold core.hooksPath, which its own rule finds as one word wherever it stands). So a PowerShell call
2676
+ # that holds one of those three words is read too, and its arguments are judged as the git command they make.
2677
+ _c47_psd=0
2678
+ [ "$_c47_ps" = 1 ] && case "$CMD_UQ" in *[Pp][Uu][Ss][Hh]*|*[Rr][Ee][Ss][Ee][Tt]*|*[Cc][Ll][Ee][Aa][Nn]*) _c47_psd=1 ;; esac
2679
+ if [ "$_c47_seen" = 1 ] || [ "$_c47_try" = 1 ] || [ "$_c47_sub" = 1 ] || [ "$_c47_psd" = 1 ]; then
2680
+ # A SIZE THIS READING IS NOT ASKED TO EXCEED. A PreToolUse hook that reaches its timeout (600 s) stops nothing, and
2681
+ # the reading below costs the square of the size for some shapes — measured on macOS, a commit followed by `2>&1`
2682
+ # repeated: 16 KB 10 s, 32 KB 39 s, 64 KB 154 s, so about 128 KB is where the timeout is. A command without a commit
2683
+ # is not read this way (64 KB of the same shape: 0.4 s) and is not limited. The limit is where the worst measured
2684
+ # shape is 15 times under the timeout; of 12387 distinct real commands the largest is 31639 bytes, the largest that
2685
+ # holds a commit 28880.
2686
+ _cmd_bytes "$CMD"
2687
+ if [ "$_CB" -gt "$_C47_MAX" ] && { [ "$_c47_seen" = 1 ] || [ "$_c47_try" = 1 ]; }; then
2688
+ gatelog BLOCK 4.5 "git commit in a command too large to read"
2689
+ _c47_no \
2690
+ "this command holds a git commit and is $_CB bytes long; the gate reads a commit command of up to $_C47_MAX bytes." \
2691
+ "A larger one could take longer to read than the hook is given, and a hook that runs out of time stops nothing," \
2692
+ "so it is refused unread. Write the message to a file and run 'git commit -F <file>'; run the other steps as" \
2693
+ "commands of their own."
2694
+ fi
2695
+ if [ "$_CB" -gt "$_C47_MAX" ] && [ "$_c47_sub" = 0 ]; then
2696
+ gatelog BLOCK 4.5 "PowerShell call too large to read"
2697
+ _c47_no \
2698
+ "this PowerShell call names a git command the gate judges by its arguments (push, reset, clean) and is $_CB bytes long; the gate reads such a call of up to $_C47_MAX bytes." \
2699
+ "A larger one could take longer to read than the hook is given, and a hook that runs out of time stops nothing," \
2700
+ "so it is refused unread. Put the long content in a file with the Write tool and run the command on its own."
2701
+ fi
2702
+ if [ "$_CB" -gt "$_C47_MAX" ]; then
2703
+ gatelog BLOCK 4.5 "git call with an expansion in a command too large to read"
2704
+ _c47_no \
2705
+ "this command runs git with words the shell fills in and is $_CB bytes long; the gate reads such a command of up to $_C47_MAX bytes." \
2706
+ "A larger one could take longer to read than the hook is given, and a hook that runs out of time stops nothing," \
2707
+ "so it is refused unread. Put the long content in a file with the Write tool and run the git command on its own."
2708
+ fi
2709
+ _c47_scan "$CMD"
2710
+ # git itself redefined for this call: `git(){ command git "$@" -a; }; git commit -m x` committed the working tree.
2711
+ _ere s "$CMD" '(^|[;&|[:space:]])(function[[:space:]]+)?git[[:space:]]*\([[:space:]]*\)|alias[[:space:]]+git=' '*git*' \
2712
+ && _C47_CFG="git redefined as a function or an alias"
2713
+ if [ -n "$_C47_SUB" ]; then
2714
+ gatelog BLOCK 4.5 "git subcommand the shell fills in"
2715
+ _c47_no \
2716
+ "this call runs git with a subcommand the shell fills in ($_C47_SUB), so which git command it is cannot be read." \
2717
+ "Every rule about a git command looks for it by name. Write the subcommand out: git commit …, git push …"
2718
+ fi
2719
+ if [ "$_c47_ps" = 1 ] && [ -n "$_C47_ARGS" ]; then
2720
+ while IFS= read -r _t; do
2721
+ # What block() prints names the rule; this line says where the command was read from.
2722
+ ( _git_d45 "$_t"; exit 0 ) \
2723
+ || { echo "GUARD: read from the arguments this PowerShell call hands to git, or to a command held in a variable: ${_t:0:120}" >&2; exit 2; }
2724
+ done <<< "$_C47_ARGS"
2725
+ fi
2726
+ # Everything below judges a COMMIT. A call that was read only because git is followed by an expansion, and holds
2727
+ # no commit, is finished here: measured on 4986 real commands, letting the commit rules run on it refused
2728
+ # `git -c core.autocrlf=$ac archive …` twice for "a -c setting the shell expands first".
2729
+ if [ "$_c47_seen" = 0 ] && [ "$_c47_try" = 0 ]; then
2730
+ _C47_NV=""; _C47_AM=""; _C47_ENV=""; _C47_CFG=""; _C47_UNR=""; _C47_EXP=""; _C47_UNK=""; _C47_SH=""
2731
+ _C47_N=0; _C47_WT=""; _C47_CD=0
2732
+ fi
2733
+ if [ -n "$_C47_NV" ]; then
2734
+ gatelog BLOCK 4.5 "hook skip by -n or an abbreviated --no-verify"
2735
+ _c47_no \
2736
+ "this commit skips its hooks: $_C47_NV." \
2737
+ "Turning a gate off is not a step in any task. If a hook is wrong, fix the hook and say so."
2738
+ fi
2739
+ if [ -n "$_C47_AM" ]; then
2740
+ gatelog BLOCK 4.5 "abbreviated git commit --amend"
2741
+ _c47_no \
2742
+ "this commit rewrites the last one: $_C47_AM." \
2743
+ "A new commit usually reaches the same end without rewriting; an amend is run only on an explicit request, by the user."
2744
+ fi
2745
+ if [ -n "$_C47_ENV" ]; then
2746
+ gatelog BLOCK 4.5 "git commit under a GIT_ variable set in the command"
2747
+ _c47_no \
2748
+ "this commit runs with $_C47_ENV (GIT_DIR, GIT_INDEX_FILE, GIT_CONFIG_…, HOME and their kin), so git may read another" \
2749
+ "repository, another index or another configuration" \
2750
+ "than the one this gate reads. Run 'git commit' with no GIT_ variable in front of it."
2751
+ fi
2752
+ if [ -n "$_C47_CFG" ]; then
2753
+ gatelog BLOCK 4.5 "git commit under a setting given in the command"
2754
+ _c47_no \
2755
+ "this call runs git with $_C47_CFG, which changes what a commit does (the hooks it runs, the files it reads, or" \
2756
+ "what the word 'commit' means). Run 'git commit' without it."
2757
+ fi
2758
+ if [ "$_c47_seen" = 0 ] && [ "$_C47_N" != 0 ]; then
2759
+ gatelog BLOCK 4.5 "git commit written so that it is not recognised"
2760
+ _c47_no \
2761
+ "this call runs a git commit spelled so that it does not read as one (a quoted or escaped command word)." \
2762
+ "Write it plainly: git commit -m '…'"
2763
+ fi
2764
+ if [ -n "$_C47_UNR" ]; then
2765
+ gatelog BLOCK 4.5 "git commit through a command the shell fills in"
2766
+ _c47_no \
2767
+ "this call holds the word commit and runs $_C47_UNR, so what git is asked to do cannot be read." \
2768
+ "Write the command out: git commit -m '…'"
2769
+ fi
2770
+ if [ -n "$_C47_EXP" ]; then
2771
+ gatelog BLOCK 4.5 "git commit argument the shell expands"
2772
+ _c47_no \
2773
+ "the argument '$_C47_EXP' is changed by the shell before git reads it (a variable that is not one plain word, a" \
2774
+ "glob or a brace list), so it may become more arguments than it looks like. Quote it."
2775
+ fi
2776
+ if [ -n "$_C47_UNK" ]; then
2777
+ gatelog BLOCK 4.5 "git commit option the gate cannot read"
2778
+ _c47_no \
2779
+ "'$_C47_UNK' is not an option of git commit under that exact name. git accepts abbreviations, which is how" \
2780
+ "--no-verify and --amend get past a rule that looks for their names. Write the option out in full."
2781
+ fi
2782
+ if [ -n "$_C47_SH" ]; then
2783
+ gatelog BLOCK 4.5 "git commit inside a nested shell"
2784
+ _c47_no \
2785
+ "this call hands a git commit to '$_C47_SH', where its arguments cannot be read (a quoted script, eval, xargs)." \
2786
+ "Run 'git commit' directly."
2787
+ fi
2788
+ fi
2789
+ if git_has "$CMD_SEEN" 'add|commit|push|checkout|switch'; then
1395
2790
  # The key is granted by the user's environment, never by the command line the model composes.
1396
2791
  if _grep "$CMD" -q 'CLAUDE_GIT_OK'; then
1397
2792
  gatelog BLOCK 4.4 "approval key set inside the command"
@@ -1403,7 +2798,7 @@ if git_has "$CMD" 'add|commit|push|checkout|switch'; then
1403
2798
  1|yes|true|on|YES|TRUE|ON) allow_preauthorised ;; # pre-authorised session (headless/CI)
1404
2799
  esac
1405
2800
  fi
1406
- if git_has "$CMD" 'commit|push'; then
2801
+ if git_has "$CMD_SEEN" 'commit|push'; then
1407
2802
  # §4.6 — A COMMIT NEEDS A CLEAN REVIEW OF THIS DIFF.
1408
2803
  # crew-review-agent records what it cleared in .claude/review-pass.json; this reads it back and compares two
1409
2804
  # EXACT facts: the sha256 of the staged diff, and the HEAD it was reviewed against. There is deliberately NO
@@ -1414,7 +2809,7 @@ if git_has "$CMD" 'commit|push'; then
1414
2809
  # Nested inside the commit|push block on purpose: the matcher below costs a process, and on Git Bash a fork
1415
2810
  # is 20-50 ms on a hook that runs before EVERY Bash call. Here it runs only when a commit or push is already
1416
2811
  # on the table. Scope is `commit` alone — a push stages nothing, so it has no diff of its own to review.
1417
- if git_has "$CMD" 'commit'; then
2812
+ if git_has "$CMD_SEEN" 'commit'; then
1418
2813
  # Two questions have to be answered before the record means anything, and BOTH are about the command's own
1419
2814
  # shape: is git pointed at another worktree, and does this commit take its content from the WORKING TREE
1420
2815
  # instead of the index? The second one is not a nicety. MEASURED here (macOS, git 2.54.0), with a reviewed
@@ -1476,11 +2871,9 @@ if git_has "$CMD" 'commit|push'; then
1476
2871
  # MEASURED FALSE on bash 5.3.15 — both spellings behave alike there. It stays only because a bracket
1477
2872
  # expression cannot be misread by anyone (calibrated here: `[\\]n` matches a backslash before an `n` and
1478
2873
  # leaves a bare `n` alone) and because it keeps replacements free of backslashes. No correctness claim.
1479
- s="${s//[\\]r/}"
1480
- s="${s//$'\r'/}"
1481
- s="${s//[\\]n/$'\n'}"
1482
- s="${s//[\\]\'/Q}"
1483
- s="${s//[\\]\"/Q}"
2874
+ # Through _gsub (see there): the same substitutions, a piece at a time.
2875
+ _gsub "$s" '[\\]r' '' bs; _gsub "$_GS" $'\r' ''; _gsub "$_GS" '[\\]n' $'\n' bs
2876
+ _gsub "$_GS" '[\\]'"\\'" Q bs; _gsub "$_GS" '[\\]\"' Q bs; s="$_GS"
1484
2877
  # A quoted span collapses to the single placeholder `Q`, and this is the whole design: the CONTENT of a
1485
2878
  # quote must not be read as an option or a path, but the TOKEN has to survive. The first version DELETED
1486
2879
  # the span, and that was a measured fail-open on Windows — `git commit -m c "a.txt"` and
@@ -1490,32 +2883,40 @@ if git_has "$CMD" 'commit|push'; then
1490
2883
  # keeps adjacency too, so `-m"msg"` becomes `-mQ` (an attached value, correct) and not `-m Q`.
1491
2884
  # Double quotes go FIRST: an apostrophe inside a double-quoted message (`-m "don't"`) is ordinary, whereas
1492
2885
  # a double quote inside a single-quoted one is rare, so this order mangles the rarer shape.
2886
+ # THE WALK IS BY LENGTH, and only over what is left. It used to be `rest="${s#*\"}"` on the whole string, and
2887
+ # that one expansion costs the SQUARE of the distance to the quote in bash (measured, bash 3.2, one call: 13 ms at
2888
+ # 5 KB, 108 ms at 10 KB, 317 ms at 21 KB) — once per pair, on a string whose collapsed front keeps growing. A
2889
+ # quote-dense commit message therefore took 6 s at 10 KB, 14 s at 16 KB and 335 s at 46 KB (macOS; 14.2 s at
2890
+ # 18 KB on Windows), in 3.0.1 too, and a hook that reaches its 600 s timeout does not block. The result is the
2891
+ # same string: the text before a pair holds no quote and neither does the `Q` that replaces it, so the next pair
2892
+ # is the first one in the remainder (pinned in smoke-test against the old loop, on the same inputs).
2893
+ local acc="" p2
1493
2894
  while :; do
1494
2895
  case "$s" in *\"*\"*) ;; *) break ;; esac
1495
- pre="${s%%\"*}"; rest="${s#*\"}"; rest="${rest#*\"}"; s="${pre}Q${rest}"
2896
+ pre="${s%%\"*}"; rest="${s:${#pre}+1}"; p2="${rest%%\"*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
1496
2897
  done
2898
+ s="$acc$s"; acc=""
1497
2899
  while :; do
1498
2900
  case "$s" in *\'*\'*) ;; *) break ;; esac
1499
- pre="${s%%\'*}"; rest="${s#*\'}"; rest="${rest#*\'}"; s="${pre}Q${rest}"
2901
+ pre="${s%%\'*}"; rest="${s:${#pre}+1}"; p2="${rest%%\'*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
1500
2902
  done
2903
+ s="$acc$s"
1501
2904
  # A backslash-newline is a LINE CONTINUATION, the opposite of a separator: it JOINS. Measured, before this,
1502
2905
  # `git commit \` + newline + ` -m c` refused the commit, because the lone `\` became a token and read as a
1503
2906
  # pathspec. It has to run before the conversion below, or the newline is gone when we look for it.
1504
- s="${s//[\\]$'\n'/ }"
2907
+ _gsub "$s" '[\\]'$'\n' ' ' bs; s="$_GS"
1505
2908
  # A NEWLINE IS A COMMAND SEPARATOR and has to become one, or a multi-line Bash call is misread: measured,
1506
2909
  # `git commit -m c` followed by a line `echo done` refused the commit, because `done` was read as a
1507
2910
  # pathspec. Splitting alone cannot save it — the default IFS eats newlines, so the boundary is gone by the
1508
2911
  # time the walk sees tokens.
1509
- s="${s//$'\n'/;}"
2912
+ _gsub "$s" $'\n' ';'; s="$_GS"
1510
2913
  # SEPARATORS BECOME THEIR OWN TOKENS. Without this, `git commit -m c; echo done` refused the commit: the
1511
2914
  # token was `c;`, `-m` swallowed it whole, the separator inside it was never seen, and `echo` read as a
1512
2915
  # pathspec. The fail-open twin is worse and was measured too — in
1513
2916
  # `if true; then git commit -m c -- a.txt; fi` the pathspec token was `a.txt;`, which the `--` lookahead
1514
2917
  # dismissed as a separator, so the commit was ALLOWED. Padding fixes both at once, and `&&`/`||` simply
1515
2918
  # become two tokens, which the walk already treats as one boundary.
1516
- s="${s//;/ ; }"
1517
- s="${s//&/ & }"
1518
- s="${s//|/ | }"
2919
+ _gsub "$s" ';' ' ; '; _gsub "$_GS" '&' ' & '; _gsub "$_GS" '|' ' | '; s="$_GS"
1519
2920
  # Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
1520
2921
  # commit could be judged on whatever files happen to sit there.
1521
2922
  local unglob=0
@@ -1601,6 +3002,8 @@ if git_has "$CMD" 'commit|push'; then
1601
3002
  done
1602
3003
  }
1603
3004
  _c46_scan "$CMD"
3005
+ # ...and the same question asked of the reading above (_c47_scan, already run for this call). Either answer refuses.
3006
+ [ -z "$_C46_WT" ] && [ -n "$_C47_WT" ] && _C46_WT="$_C47_WT"
1604
3007
  # The record describes THIS worktree. A command that points git at another one would have us hash the wrong
1605
3008
  # repository and pass it off as verified, so the ambiguous form fails closed instead.
1606
3009
  if [ "$_C46_REDIR" = 1 ]; then
@@ -1619,6 +3022,27 @@ if git_has "$CMD" 'commit|push'; then
1619
3022
  exit 2
1620
3023
  fi
1621
3024
 
3025
+ # THE COMMIT RUNS WHERE THE CALL TAKES IT. `cd ../other && git commit -m x` commits what is staged THERE, while
3026
+ # the record below is read and compared HERE (measured: the other repository got the commit). One plain target is
3027
+ # resolved and has to be this same repository — git is asked for both git directories, so no path is compared by
3028
+ # its spelling; a target that cannot be read (a variable, `cd -`, two of them, `env -C`) is refused.
3029
+ if [ "$_C47_CD" != 0 ]; then
3030
+ _cdok=0
3031
+ if [ "$_C47_CD" = 1 ]; then
3032
+ case "$_C47_CDT" in /*|[A-Za-z]:*) _cdt="$_C47_CDT" ;; *) _cdt="${_CWD:-.}/$_C47_CDT" ;; esac
3033
+ _g1="$(git -C "${_CWD:-.}" rev-parse --absolute-git-dir 2>/dev/null)"
3034
+ _g2="$(git -C "$_cdt" rev-parse --absolute-git-dir 2>/dev/null)"
3035
+ [ -n "$_g1" ] && [ "$_g1" = "$_g2" ] && _cdok=1
3036
+ fi
3037
+ if [ "$_cdok" != 1 ]; then
3038
+ gatelog BLOCK 4.6 "commit after a change of directory"
3039
+ echo "GUARD (§4.6): this call changes directory before it commits, and the commit would not run in the repository" >&2
3040
+ echo "this session is in — or the target cannot be read (a variable, 'cd -', more than one). The review record" >&2
3041
+ echo "describes what is staged HERE. Run the commit from this directory, or run it yourself in your terminal." >&2
3042
+ exit 2
3043
+ fi
3044
+ fi
3045
+
1622
3046
  # CREW-REVIEW-PASS (this recipe is kept identical in agents/crew-review-agent.md; smoke-test pins the pair)
1623
3047
  # GIT does the hashing, not sha256sum/shasum, and the reason is the one that survives BOTH platforms —
1624
3048
  # because the first two reasons written here did not. Measured on macOS: the suite's sandbox reaches its
@@ -1659,7 +3083,11 @@ if git_has "$CMD" 'commit|push'; then
1659
3083
  RPJ=""; while IFS= read -r _l || [ -n "$_l" ]; do RPJ="$RPJ${_l%$'\r'}"; done < "$RP"
1660
3084
  _rpf(){ _r="${RPJ#*\"$1\":\"}"; [ "$_r" = "$RPJ" ] && return 1; printf '%s' "${_r%%\"*}"; }
1661
3085
  WANT_D="$(_rpf diff_oid || true)"; WANT_H="$(_rpf head || true)"
1662
- HAVE_D="$(git -C "$_RPD" diff --cached 2>/dev/null | git hash-object --stdin 2>/dev/null)"
3086
+ # --no-ext-diff --no-textconv: what `git diff` prints is configurable, and `git config diff.external true` made
3087
+ # every staged change print NOTHING — one id for all of them, so one review record vouched for any diff (measured,
3088
+ # 3.1.0 review). With neither configured the two flags change no byte, so a record written by the older recipe
3089
+ # still matches.
3090
+ HAVE_D="$(git -C "$_RPD" diff --cached --no-ext-diff --no-textconv 2>/dev/null | git hash-object --stdin 2>/dev/null)"
1663
3091
  # --verify --quiet, not a bare `git rev-parse HEAD`: on an UNBORN head the bare form prints the literal
1664
3092
  # string "HEAD" on stdout and still fails, so `|| echo NONE` appended to it and the value became two
1665
3093
  # lines ("HEAD" then "NONE") — which never matches any record. Measured on a fresh `git init`.
@@ -1710,6 +3138,19 @@ $SHORT
1710
3138
 
1711
3139
  ${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."
1712
3140
  ;;
3141
+ auto|dontAsk)
3142
+ # No prompt reaches a person here, but their own message does: see _approval_ok above.
3143
+ _approval_ok && allow_approved
3144
+ gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
3145
+ echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
3146
+ echo "Not allowed now: $_APW." >&2
3147
+ echo "Present the commit MESSAGE to the user (stage first: the approval covers what is staged at that moment). Then one of:" >&2
3148
+ echo " (a) the user sends a message that is ONLY the approval: 'approve: commit', 'approve: push' or 'approve: commit+push' ('onay: …' works too)." >&2
3149
+ echo " It is tied to the staged diff and HEAD at that moment, lasts 30 minutes and ends at the user's next message. The command then runs ALONE in its call (no cd, no pipe, nothing chained): git commit -m …, or git push <remote> <branch>. OR" >&2
3150
+ echo " (b) the user presses Shift+Tab to switch to default/acceptEdits — in this session, no restart — and this gate asks them directly, OR" >&2
3151
+ echo " (c) the user runs the command in their own terminal." >&2
3152
+ echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
3153
+ exit 2 ;;
1713
3154
  *)
1714
3155
  # bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
1715
3156
  # human, so we fail closed rather than let the gate silently evaporate.