crewforth 3.0.4 → 3.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +200 -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 +2572 -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 +124 -0
  34. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +81 -0
  35. package/kit/skills/frontend-flutter/references/platform-async-release.md +100 -0
  36. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  37. package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -25,7 +25,7 @@ set -uo pipefail
25
25
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
26
26
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
27
27
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
28
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
28
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
29
29
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
30
30
  # staged key all passed.
31
31
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -126,9 +126,36 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
126
126
  CMD="$_JL"
127
127
  }
128
128
  # ---- /CREW-JOIN
129
- DIR="$(cd "$(dirname "$0")" && pwd)"
129
+ # DIR is resolved only once a `git commit` is in front of this hook (below): `$(cd "$(dirname "$0")" && pwd)` is two
130
+ # subshells and a process, and this hook runs before EVERY Bash call.
130
131
 
131
132
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
133
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
134
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
135
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
136
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
137
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
138
+ # 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
139
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
140
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
141
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
142
+ # taken from the whole text, and the small pieces from the block.
143
+ local LC_ALL=C
144
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
145
+ _JF=-1
146
+ while [ "$i" -lt "$n" ]; do
147
+ b="${1:i:B+kl-1}"
148
+ case "$b" in *"$2"*)
149
+ j=0; bn=${#b}
150
+ while [ "$j" -lt "$bn" ]; do
151
+ c="${b:j:C+kl-1}"
152
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
153
+ j=$((j+C))
154
+ done ;;
155
+ esac
156
+ i=$((i+B))
157
+ done
158
+ }
132
159
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
133
160
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
134
161
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -152,8 +179,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
152
179
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
153
180
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
154
181
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
155
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
156
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
182
+ # _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
157
183
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
158
184
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
159
185
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -177,7 +203,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
177
203
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
178
204
  # the battery sees both.
179
205
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
180
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
206
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
181
207
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
182
208
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
183
209
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -193,14 +219,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
193
219
  # raising or removing it later is not a tidy-up.
194
220
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
195
221
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
196
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
222
+ # 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
197
223
  # predates this change and belongs to the walk, not to the cap.
198
224
  local _cap=64 _seen=0
199
225
  while :; do
200
- pre="${hay%%"$k"*}" # everything before the next `"key"`
201
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
226
+ _json_find "$hay" "$k" # where the next `"key"` starts
227
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
202
228
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
203
- rest="${hay:${#pre}+${#k}}" # past `"key"`
229
+ rest="${hay:_JF+${#k}}" # past `"key"`
204
230
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
205
231
  case "$rest" in
206
232
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -240,7 +266,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
240
266
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
241
267
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
242
268
  #
243
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
269
+ # 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
244
270
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
245
271
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
246
272
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -292,7 +318,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
292
318
  # the gate cases.
293
319
  local LC_ALL=C
294
320
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
295
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
321
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
296
322
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
297
323
  while :; do
298
324
  lim=$((cl - j))
@@ -322,12 +348,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
322
348
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
323
349
  case "$h" in
324
350
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
351
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
352
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
353
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
325
354
  *) acc+=("?") ;;
326
355
  esac ;;
327
356
  *) acc+=("$c") ;;
328
357
  esac
329
358
  done
330
- local IFS=''; printf '%s' "${acc[*]}"
359
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
360
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
361
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
331
362
  }
332
363
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
333
364
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -360,16 +391,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
360
391
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
361
392
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
362
393
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
363
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
394
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
364
395
  _KC=0; _KC_CAPPED=0
365
396
  while :; do
366
- pre="${hay%%"$k"*}"
367
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
397
+ _json_find "$hay" "$k"
398
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
368
399
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
369
400
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
370
401
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
371
402
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
372
- rest="${hay:${#pre}+${#k}}"
403
+ rest="${hay:_JF+${#k}}"
373
404
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
374
405
  case "$rest" in :*) c=$((c+1)) ;; esac
375
406
  hay="$rest"
@@ -465,7 +496,29 @@ crew_opt_values() {
465
496
  }
466
497
  crew_msg_values() { crew_opt_values "$1" -m --message; }
467
498
 
468
- INPUT="$(cat)"
499
+ # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
500
+ # of this hook. `read` returns 1 at end of input; the text is read regardless.
501
+ IFS= read -r -d '' INPUT || true
502
+ # ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
503
+ # A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
504
+ # its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
505
+ # 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
506
+ # that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
507
+ # The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
508
+ # large file is ordinary there — which is why the refusal points at them.
509
+ # Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
510
+ _PAYLOAD_MAX=262144
511
+ _payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
512
+ local LC_ALL=C
513
+ [ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
514
+ 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
515
+ 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
516
+ echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
517
+ echo "the path of that file." >&2
518
+ return 0
519
+ }
520
+ # ---- /CREW-PAYLOAD-MAX
521
+ _payload_over && exit 2
469
522
  # Same ladder as guard-bash.sh, and for the same reason: the raw-text fallback leaves JSON escapes in place,
470
523
  # so `-m \"…\"` never matches a quote-based extraction and the message silently goes unscanned. That is
471
524
  # precisely how the first version of this hook passed a commit carrying a co-author trailer.
@@ -503,7 +556,7 @@ fi
503
556
  # The shared reader also replaced a `sed | head | sed` pipeline: four processes on EVERY Bash tool call, to
504
557
  # re-derive a string guard-bash.sh had already parsed one hook earlier. Measured on `ls -la`: guard-bash.sh 2
505
558
  # processes, this hook 7. With the ladder gone neither hook spawns a process to SELECT a reader.
506
- CMD="$(_json_unescape "$(_json_slice "$INPUT" command)")"
559
+ _json_slice "$INPUT" command >/dev/null; _json_unescape "$_JS" >/dev/null; CMD="$_JU" # via _JS / _JU: no fork
507
560
  _join_cmd # continued lines joined, as the shell runs them (CREW-JOIN)
508
561
  [ -z "$CMD" ] && exit 0
509
562
 
@@ -515,7 +568,10 @@ case "$CMD" in *[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
515
568
 
516
569
  # Only git commit. Matching mirrors guard-bash.sh's tolerance for `git -C dir commit`, TAB separators and a
517
570
  # quoted binary, because a gate that a whitespace change walks past is not a gate.
518
- _grep "$CMD" -qE '(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' || exit 0
571
+ # A builtin match: `printf | grep` was two processes on every command that merely mentions git (`git status`).
572
+ _GC_RE='(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)'
573
+ [[ $CMD =~ $_GC_RE ]] || exit 0
574
+ DIR="$(cd "$(dirname "$0")" && pwd)"
519
575
 
520
576
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
521
577
 
@@ -24,7 +24,7 @@ set -uo pipefail
24
24
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
25
25
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
26
26
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
27
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
27
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
28
28
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
29
29
  # staged key all passed.
30
30
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -62,9 +62,55 @@ _gate_main(){
62
62
  # process costs 62-135 ms. With this, a command that cannot match (no `bash` in the payload) opens no process at all
63
63
  # (measured with the xtrace counter: `cat` 1 -> 0). `read` returns 1 at end of input; the text is read regardless.
64
64
  IFS= read -r -d '' INPUT || true
65
+ # ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
66
+ # A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
67
+ # its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
68
+ # 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
69
+ # that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
70
+ # The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
71
+ # large file is ordinary there — which is why the refusal points at them.
72
+ # Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
73
+ _PAYLOAD_MAX=262144
74
+ _payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
75
+ local LC_ALL=C
76
+ [ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
77
+ 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
78
+ 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
79
+ echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
80
+ echo "the path of that file." >&2
81
+ return 0
82
+ }
83
+ # ---- /CREW-PAYLOAD-MAX
84
+ _payload_over && exit 2
65
85
  case "$INPUT" in *[Bb][Aa][Ss][Hh]*) ;; *) exit 0 ;; esac
66
86
 
67
87
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
88
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
89
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
90
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
91
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
92
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
93
+ # 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
94
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
95
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
96
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
97
+ # taken from the whole text, and the small pieces from the block.
98
+ local LC_ALL=C
99
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
100
+ _JF=-1
101
+ while [ "$i" -lt "$n" ]; do
102
+ b="${1:i:B+kl-1}"
103
+ case "$b" in *"$2"*)
104
+ j=0; bn=${#b}
105
+ while [ "$j" -lt "$bn" ]; do
106
+ c="${b:j:C+kl-1}"
107
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
108
+ j=$((j+C))
109
+ done ;;
110
+ esac
111
+ i=$((i+B))
112
+ done
113
+ }
68
114
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
69
115
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
70
116
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -88,8 +134,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
88
134
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
89
135
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
90
136
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
91
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
92
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
137
+ # _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
93
138
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
94
139
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
95
140
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -113,7 +158,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
113
158
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
114
159
  # the battery sees both.
115
160
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
116
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
161
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
117
162
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
118
163
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
119
164
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -129,14 +174,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
129
174
  # raising or removing it later is not a tidy-up.
130
175
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
131
176
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
132
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
177
+ # 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
133
178
  # predates this change and belongs to the walk, not to the cap.
134
179
  local _cap=64 _seen=0
135
180
  while :; do
136
- pre="${hay%%"$k"*}" # everything before the next `"key"`
137
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
181
+ _json_find "$hay" "$k" # where the next `"key"` starts
182
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
138
183
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
139
- rest="${hay:${#pre}+${#k}}" # past `"key"`
184
+ rest="${hay:_JF+${#k}}" # past `"key"`
140
185
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
141
186
  case "$rest" in
142
187
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -176,7 +221,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
176
221
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
177
222
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
178
223
  #
179
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
224
+ # 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
180
225
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
181
226
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
182
227
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -228,7 +273,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
228
273
  # the gate cases.
229
274
  local LC_ALL=C
230
275
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
231
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
276
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
232
277
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
233
278
  while :; do
234
279
  lim=$((cl - j))
@@ -258,12 +303,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
258
303
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
259
304
  case "$h" in
260
305
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
306
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
307
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
308
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
261
309
  *) acc+=("?") ;;
262
310
  esac ;;
263
311
  *) acc+=("$c") ;;
264
312
  esac
265
313
  done
266
- local IFS=''; printf '%s' "${acc[*]}"
314
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
315
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
316
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
267
317
  }
268
318
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
269
319
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -296,16 +346,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
296
346
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
297
347
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
298
348
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
299
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
349
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
300
350
  _KC=0; _KC_CAPPED=0
301
351
  while :; do
302
- pre="${hay%%"$k"*}"
303
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
352
+ _json_find "$hay" "$k"
353
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
304
354
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
305
355
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
306
356
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
307
357
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
308
- rest="${hay:${#pre}+${#k}}"
358
+ rest="${hay:_JF+${#k}}"
309
359
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
310
360
  case "$rest" in :*) c=$((c+1)) ;; esac
311
361
  hay="$rest"
@@ -36,7 +36,7 @@ set -uo pipefail
36
36
  # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
37
37
  # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
38
38
  # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
39
- # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
39
+ # turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
40
40
  # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
41
41
  # staged key all passed.
42
42
  # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
@@ -73,13 +73,41 @@ _gate_main(){
73
73
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
74
74
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
75
75
  [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
76
- INPUT="$(cat)"
76
+ # Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
77
+ # of this hook. `read` returns 1 at end of input; the text is read regardless.
78
+ IFS= read -r -d '' INPUT || true
77
79
 
78
80
  # The two helpers below are a byte-identical copy of guard-bash.sh's block. A shared file would have to be
79
81
  # added to build-plugin.sh's explicit copy list and a miss there breaks the plugin channel silently — the same
80
82
  # reasoning as the CREW-TRANSCRIPT-DIR resolver, which is duplicated for the same reason. Two copies are only
81
83
  # safe while they cannot drift, so smoke-test pins these markers byte-identical rather than trusting it.
82
84
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
85
+ _json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
86
+ # `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
87
+ # text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
88
+ # square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
89
+ # look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
90
+ # 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
91
+ # timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
92
+ # occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
93
+ # Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
94
+ # taken from the whole text, and the small pieces from the block.
95
+ local LC_ALL=C
96
+ local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
97
+ _JF=-1
98
+ while [ "$i" -lt "$n" ]; do
99
+ b="${1:i:B+kl-1}"
100
+ case "$b" in *"$2"*)
101
+ j=0; bn=${#b}
102
+ while [ "$j" -lt "$bn" ]; do
103
+ c="${b:j:C+kl-1}"
104
+ case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
105
+ j=$((j+C))
106
+ done ;;
107
+ esac
108
+ i=$((i+B))
109
+ done
110
+ }
83
111
  _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
84
112
  local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
85
113
  # Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
@@ -103,8 +131,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
103
131
  # payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
104
132
  # * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
105
133
  # against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
106
- # `${hay%%"$k"*}` finds the FIRST occurrence -- the longest suffix that starts with the key starts at the
107
- # earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
134
+ # _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
108
135
  # command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
109
136
  # that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
110
137
  # equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
@@ -128,7 +155,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
128
155
  # empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
129
156
  # the battery sees both.
130
157
  # THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
131
- # a PreToolUse hook is a gate with an off switch, because a hook killed at its 60s timeout emits no exit 2.
158
+ # a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
132
159
  # Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
133
160
  # Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
134
161
  # 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
@@ -144,14 +171,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
144
171
  # raising or removing it later is not a tidy-up.
145
172
  # The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
146
173
  # is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
147
- # both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
174
+ # 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
148
175
  # predates this change and belongs to the walk, not to the cap.
149
176
  local _cap=64 _seen=0
150
177
  while :; do
151
- pre="${hay%%"$k"*}" # everything before the next `"key"`
152
- [ "$pre" != "$hay" ] || return 0 # no further occurrence: emit nothing
178
+ _json_find "$hay" "$k" # where the next `"key"` starts
179
+ [ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
153
180
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
154
- rest="${hay:${#pre}+${#k}}" # past `"key"`
181
+ rest="${hay:_JF+${#k}}" # past `"key"`
155
182
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
156
183
  case "$rest" in
157
184
  :*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
@@ -191,7 +218,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
191
218
  # CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
192
219
  # entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
193
220
  #
194
- # That was not a comfort question. This hook's timeout is 60s -- set in settings.json, NOT Claude Code's
221
+ # 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
195
222
  # default, and reading the default instead is how a first pass at this got the consequence wrong. A
196
223
  # PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
197
224
  # the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
@@ -243,7 +270,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
243
270
  # the gate cases.
244
271
  local LC_ALL=C
245
272
  local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
246
- case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
273
+ case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
247
274
  n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
248
275
  while :; do
249
276
  lim=$((cl - j))
@@ -273,12 +300,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
273
300
  # `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
274
301
  case "$h" in
275
302
  00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
303
+ # The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
304
+ # `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
305
+ 2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
276
306
  *) acc+=("?") ;;
277
307
  esac ;;
278
308
  *) acc+=("$c") ;;
279
309
  esac
280
310
  done
281
- local IFS=''; printf '%s' "${acc[*]}"
311
+ # ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
312
+ # and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
313
+ local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
282
314
  }
283
315
  _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
284
316
  # IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
@@ -311,16 +343,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
311
343
  # over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
312
344
  # Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
313
345
  # `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
314
- local LC_ALL=C hay="$1" k="\"$2\"" pre rest c=0 _cap=64 _seen=0
346
+ local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
315
347
  _KC=0; _KC_CAPPED=0
316
348
  while :; do
317
- pre="${hay%%"$k"*}"
318
- [ "$pre" != "$hay" ] || { _KC=$c; return 0; }
349
+ _json_find "$hay" "$k"
350
+ [ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
319
351
  # OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
320
352
  # the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
321
353
  # sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
322
354
  _seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
323
- rest="${hay:${#pre}+${#k}}"
355
+ rest="${hay:_JF+${#k}}"
324
356
  while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
325
357
  case "$rest" in :*) c=$((c+1)) ;; esac
326
358
  hay="$rest"
@@ -355,13 +387,15 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
355
387
  WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
356
388
  WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
357
389
  WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
390
+ WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
391
+ WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
358
392
  WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
359
393
  # Sized from the cost curve, not from PATH_MAX. Tier 3 walks the value character by character and bash string
360
394
  # append is O(n) each time, so the walk is quadratic: measured 0.09s at 512 raw bytes, 0.52s at 1,024, 3.7s at
361
395
  # 2,048, ~30s at 4,096. Real paths are nowhere near: measured on a Windows install, actual `file_path` values
362
396
  # average ~60 bytes and Windows itself stops at 260 without the long-path opt-in (macOS PATH_MAX is 1,024).
363
397
  # 2,048 therefore keeps every path anyone can really produce and bounds the worst case at a few seconds,
364
- # well inside this hook's 60s timeout — the number that matters, because a hook killed at its timeout emits
398
+ # well inside the 60s timeout this hook had then — the number that mattered, because a hook killed at its timeout emits
365
399
  # no exit 2 and the write proceeds.
366
400
  MAXPATH=2048
367
401
 
@@ -399,14 +433,15 @@ fi
399
433
  # Microsoft Store redirector stub named python3 on PATH by default, `command -v` finds it, it exits 49 with an
400
434
  # empty stdout, FP came back "" and `[ -z "$FP" ] && exit 0` let the model rewrite .claude/hooks/guard-bash.sh
401
435
  # with its Write tool. Pure parameter expansion cannot fail that way.
402
- _raw="$(_json_slice "$INPUT" file_path)"
403
- [ -n "$_raw" ] || _raw="$(_json_slice "$INPUT" notebook_path)"
436
+ # Through `_JS` / `_JU`, not `$( )`: no fork on the path every Write and Edit takes (3.1.0).
437
+ _json_slice "$INPUT" file_path >/dev/null; _raw="$_JS"
438
+ [ -n "$_raw" ] || { _json_slice "$INPUT" notebook_path >/dev/null; _raw="$_JS"; }
404
439
  # THE CAP GOES BEFORE THE UNESCAPER, and the cost it bounds is one the shared reader INTRODUCED — worth saying
405
440
  # plainly rather than dressing up as a pre-existing bug. What it replaced was a single `sed`, which is linear
406
441
  # and was never slow; it was replaced because it truncated the value at the first escaped quote and never
407
442
  # looked at `notebook_path`. The parser that fixes those walks character by character, which is quadratic in
408
443
  # bash, and on Windows every separator is a backslash, i.e. an escape, so the "no escapes" fast path never
409
- # fires. Uncapped, that is a gate with an off switch: a hook killed at its 60s timeout emits no exit 2 and the
444
+ # fires. Uncapped, that is a gate with an off switch: a hook killed at its timeout emits no exit 2 and the
410
445
  # write proceeds. Refusing above the cap is safe in the direction that matters, and the cap sits far above any
411
446
  # path a filesystem will accept.
412
447
  # ONE cap, not two. The second check used to follow the unescaper because the jq tier reached the fold with no
@@ -414,7 +449,7 @@ _raw="$(_json_slice "$INPUT" file_path)"
414
449
  # measured over 23 escape forms including a surrogate pair and a 4900-byte run of `€` across the chunk
415
450
  # edge, with a deliberately-growing stand-in as the calibration, so the check could be seen to fail.
416
451
  [ "${#_raw}" -le "$MAXPATH" ] || { FP="(oversized path: ${#_raw} bytes)"; block "gate-file edit (oversized path)" "$WHY_LONG"; }
417
- FP="$(_json_unescape "$_raw")"
452
+ _json_unescape "$_raw" >/dev/null; FP="$_JU"
418
453
 
419
454
  # Nothing extractable. Exiting 0 unconditionally is what a future field rename turns into a silent bypass, so
420
455
  # look at the RAW payload instead: refuse only when the text itself names a gate tree. A payload that mentions
@@ -422,7 +457,7 @@ FP="$(_json_unescape "$_raw")"
422
457
  # block on a file whose own path says `.claude/…hooks`, which is the trade this gate exists to make.
423
458
  if [ -z "$FP" ]; then
424
459
  case "$INPUT" in
425
- *.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
460
+ *.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*|*crewforth-approval*|*.git/config*|*.git\\config*|*git-shim*|*.gitconfig*|*.config/git/config*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
426
461
  esac
427
462
  exit 0
428
463
  fi
@@ -489,6 +524,9 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
489
524
  GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
490
525
  */.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*|.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*)
491
526
  GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
527
+ # .claude/git-shim is where core.hooksPath points when Crewforth shares the hooks with a project's own chain.
528
+ */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]/*|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]/*|*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm])
529
+ GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
492
530
  # The gates SOURCE eval/lib/crew-env.sh on every call, so it is part of them: overwritten with `exit 0`, every
493
531
  # rule stopped firing (3.0.1 review). Same file in the plugin edition, below.
494
532
  */.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ee][Vv][Aa][Ll]/[Ll][Ii][Bb]/[Cc][Rr][Ee][Ww]-[Ee][Nn][Vv].[Ss][Hh]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ee][Vv][Aa][Ll]/[Ll][Ii][Bb]/[Cc][Rr][Ee][Ww]-[Ee][Nn][Vv].[Ss][Hh])
@@ -501,6 +539,20 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
501
539
  # `hooks/` directory is untouched.
502
540
  */[Hh][Oo][Oo][Kk][Ss]/[Gg][Uu][Aa][Rr][Dd]-*.[Ss][Hh]|*/[Hh][Oo][Oo][Kk][Ss]/[Ss][Ee][Ss][Ss][Ii][Oo][Nn]-[Gg][Uu][Aa][Rr][Dd].[Ss][Hh])
503
541
  GATE_RULE="gate-file edit (Crewforth gate script)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
542
+ # The record of the user's approval for a commit or a push (§4.4, hooks/prompt-approval.sh). It sits in the git
543
+ # directory and is written from the user's own message only; a session that writes it approves its own commit.
544
+ # Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
545
+ */[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
546
+ GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
547
+ esac
548
+ # core.hooksPath lives in git's configuration files: the repository's (.git/config, with a linked worktree's and a
549
+ # submodule's own) and the user's (~/.gitconfig, ~/.config/git/config). Writing one switches the git hooks off without
550
+ # touching a hook. guard-bash.sh carries the same list for the shell. The path gets a slash in front, so one pattern
551
+ # serves both `<dir>/.git/config` and a bare `.git/config`; matched by name, not against $HOME — on Windows the
552
+ # payload says C:\Users\… where the shell says /c/Users/…, and two spellings of one path never compare equal.
553
+ case "/$1" in
554
+ */.[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg].[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee]|*/.[Gg][Ii][Tt]/[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt]/[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg].[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee]|*/.[Gg][Ii][Tt]/[Mm][Oo][Dd][Uu][Ll][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt][Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Cc][Oo][Nn][Ff][Ii][Gg]/[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg])
555
+ GATE_RULE="git-config edit (Write/Edit tools)"; GATE_WHY="$WHY_GITCFG"; return 0 ;;
504
556
  esac
505
557
  # ...and not only the gate scripts it knew by name: everything under the plugin root's hooks/ (hooks.json, the
506
558
  # blocklists, the git hooks, every hook), .claude-plugin/ and the sourced crew-env.sh is the plugin's equivalent of