crewforth 3.0.1 → 3.0.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ Crewforth was named Claude Starter Kit until 3.0.0.
5
5
  Notable changes to this project are recorded here. Format follows [Keep a Changelog](https://keepachangelog.com/en/),
6
6
  versioning follows [SemVer](https://semver.org/).
7
7
 
8
+ ## [3.0.2] — 2026-10-03
9
+
10
+ ### Security
11
+
12
+ - **Under a Turkish system locale the gates missed what they are written to catch.** There `i` and `I` are not each
13
+ other's other case, and every case-insensitive match in the gates is written in ASCII. Measured under `tr_TR.UTF-8`
14
+ with GNU grep and bash 5 on Linux: `grep -i` did not find `INIT` with `init`, bash's `nocasematch` did not match
15
+ `GIT` against `git`, and a range like `[A-Za-z]` did not hold `i` or `I`, so a recursive `Remove-Item`, `git config
16
+ --remove-section core`, a read of a nested `.env`, a staged key and a vendor name in capitals all passed;
17
+ Crewforth's own suite, run whole under that locale, went from 2 failures to 23. In Git Bash on Windows `grep -iF`
18
+ aborted (exit 134) and the pre-commit scan for private strings read that as "no match". macOS folds the two letters
19
+ the ASCII way and was not affected. Every gate (`pre-commit`, `commit-msg`, `guard-commit-scan.sh`, `guard-bash.sh`,
20
+ `guard-write.sh`, `guard-powershell.sh`) now sets the C locale before it matches anything. A private string or a
21
+ pattern that holds a letter outside ASCII is still looked for under the session's own locale as well.
22
+ - **A scan that could not run is no longer a clean scan.** In `pre-commit` and `commit-msg`, grep failing for any
23
+ reason but a pattern that does not compile stops the commit and says so; the private-string scan used to read every
24
+ failure as "no match", `commit-msg` read a broken pattern as one too, silently, and a staged diff that could not be
25
+ collected was scanned as an empty one. A pattern that does not compile still only warns, now in `commit-msg` as well.
26
+ - **The approval prompt could carry invalid UTF-8.** The command it shows is cut to 300 a line with `cut -c`, and GNU
27
+ cut counts bytes in every locale: a two-byte letter across byte 300 left the prompt's JSON invalid (measured on
28
+ Linux under `en_US.UTF-8`, and on macOS under the C locale; Git Bash ships GNU cut too, not measured there). The cut
29
+ steps back over an unfinished letter now.
30
+
8
31
  ## [3.0.1] — 2026-10-01
9
32
 
10
33
  ### Security
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.0.1
1
+ 3.0.2
@@ -2,6 +2,12 @@
2
2
  # Kit smoke-test: structural validation (without running Claude Code).
3
3
  # Usage: bash .claude/eval/smoke-test.sh (from the repo root or from inside .claude/eval)
4
4
  set -uo pipefail
5
+ # The suite measures in the C locale, whatever the session's is. Its own patterns are ASCII, and under a Turkish
6
+ # locale a range like [a-z] does not hold `i`: run whole under tr_TR.UTF-8 on Linux, assertions failed for that
7
+ # reason alone (`skill-trust.sh` read as `ll-trust.sh`), and one more because start.sh answers in Turkish there,
8
+ # which is its design. A measurement that depends on who runs it is not one. The gates are handed a Turkish locale
9
+ # on purpose in section 12e.
10
+ export LC_ALL=C
5
11
  HERE="$(cd "$(dirname "$0")" && pwd)"
6
12
  ROOT="$(cd "$HERE/.." && pwd)" # .claude/
7
13
  AGENTS="$ROOT/agents"; SKILLS="$ROOT/skills"; HOOKS="$ROOT/hooks"
@@ -1347,6 +1353,8 @@ _blk_gate(){ # $1 = marker name, $2 = what the block is, in words
1347
1353
  }
1348
1354
  _blk_gate CREW-TRANSCRIPT-DIR "the duplicated transcript-dir resolver"
1349
1355
  _blk_gate CREW-JSON-PARSE "the duplicated JSON parser"
1356
+ _blk_gate CREW-LOCALE "the locale block every gate matches under"
1357
+ _blk_gate CREW-SCAN "the scan that tells 'could not look' from 'nothing there'"
1350
1358
  # End to end: called by hand from this repo, the hook must produce a reading rather than "transcript not found".
1351
1359
  cu_hand="$(cd "$ROOT/.." && bash "$HOOKS/context-usage.sh" 2>&1)"
1352
1360
  # The three arms used to be pass / note / note, and `note` touches no counter — so on any machine without a
@@ -6406,6 +6414,159 @@ for _c in "rm /Users/u/.claude/plugins/cache/acme/tool/1.0/hooks/x.sh" "diff $PG
6406
6414
  done
6407
6415
  [ -z "$PGF$_pgok" ] && pass "plugin gate files: $PGN writes, deletes, renames and links refused (this version, another cached version, ~/\$HOME/glob/..//. spellings, Windows spellings, a root outside the cache, the sourced crew-env.sh, Write tool); each negative twin — reading, running board.sh, a source tree, another plugin — stays free" \
6408
6416
  || fail "plugin gate files:$PGF${_pgok:+ | wrongly refused:$_pgok}"
6417
+ if [ "$UNITS" = 1 ]; then
6418
+ sec "== 12e) the gates answer the same under a Turkish locale: there i and I are not each other's other case =="
6419
+ # Under tr_TR.UTF-8 the partners are i/İ and ı/I. Every case-insensitive match in the gates is written in ASCII, so
6420
+ # with the locale left as the session had it: GNU grep -i did not find INIT with `init`, bash's nocasematch did not
6421
+ # match GIT against git, [A-Za-z] did not hold I, and in Git Bash `grep -iF` aborted (exit 134) and the
6422
+ # private-string scan read that as "no match". macOS folds i and I the ASCII way under tr_TR, so there the locale
6423
+ # rows below cannot fail whatever the gates do — the line says which kind of platform it ran on.
6424
+ # Every gate now sets LC_ALL=C before it matches anything (the CREW-LOCALE block).
6425
+ _thq="'"; _thbad=""; _thn=0
6426
+ for _h in pre-commit commit-msg guard-commit-scan.sh guard-bash.sh guard-write.sh guard-powershell.sh; do
6427
+ _thn=$((_thn+1))
6428
+ # the first line of code after `set -…` is the block: nothing matches before the locale is set
6429
+ _n="$(LC_ALL=C awk '/^[[:space:]]*(#|$)/{next} /^set -/{next} {print; exit}' "$HOOKS/$_h")"
6430
+ [ "$_n" = '_CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"' ] && grep -q '^export LC_ALL=C$' "$HOOKS/$_h" || _thbad="$_thbad $_h"
6431
+ done
6432
+ [ -z "$_thbad" ] && pass "all $_thn gates set the C locale before their first line of matching (pre-commit, commit-msg, guard-commit-scan, guard-bash, guard-write, guard-powershell)" \
6433
+ || fail "a gate matches before it sets the C locale, or does not set it:$_thbad"
6434
+
6435
+ # A scan that could not run is not a clean scan. grep is shadowed by one that aborts for -iF (what Git Bash's grep does
6436
+ # under a Turkish locale), for -iE, and for the grep that collects the added lines. Runs on every platform.
6437
+ _TH="$(mktemp -d)"; _TH="$(cd -P "$_TH" && pwd)"
6438
+ _th_repo(){ # $1 = directory -> a repository with the two git hooks beside their lists, nothing staged
6439
+ rm -rf "$1"; mkdir -p "$1"
6440
+ ( cd "$1" && git init -q && git config user.email t@example.com && git config user.name t \
6441
+ && cp "$HOOKS/pre-commit" "$HOOKS/commit-msg" "$HOOKS/trace-blocklist.txt" "$HOOKS/secret-blocklist.txt" "$HOOKS/floor-blocklist.txt" . ) >/dev/null 2>&1; }
6442
+ # $1 = repository, $2 = hook, $3 = locale or empty, $4 = a directory to put first on PATH or empty, $5 = the hook's argument -> _thr, output in $_TH/out
6443
+ _th_hook(){ ( cd "$1" && env ${3:+LC_ALL=$3} ${4:+PATH="$4:$PATH"} bash "./$2" ${5:+"$5"} ) > "$_TH/out" 2>&1; _thr=$?; }
6444
+ _thg="$(command -v grep)"
6445
+ mkdir -p "$_TH/bin" "$_TH/bin2" "$_TH/bin3"
6446
+ printf '#!/bin/sh\ncase " $* " in *" -qiF "*) kill -ABRT $$ ;; esac\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin/grep"
6447
+ printf '#!/bin/sh\ncase " $* " in *" -qiE "*) kill -ABRT $$ ;; esac\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin2/grep"
6448
+ printf '#!/bin/sh\n[ "$1" = -E ] && [ "$2" = "^\\+" ] && kill -ABRT $$\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin3/grep"
6449
+ chmod +x "$_TH/bin/grep" "$_TH/bin2/grep" "$_TH/bin3/grep"
6450
+ _th_repo "$_TH/r"
6451
+ printf 'acme-internal-host\n' > "$_TH/r/.private-terms.txt"
6452
+ ( cd "$_TH/r" && printf 'see ACME-INTERNAL-HOST for the dump\n' > notes.md && git add notes.md ) >/dev/null 2>&1
6453
+ _thbad=""
6454
+ _th_hook "$_TH/r" pre-commit ""; { [ "$_thr" = 1 ] && grep -q 'PRIVATE-PATH SCANNER: a staged line contains' "$_TH/out"; } \
6455
+ || _thbad="$_thbad [FIXTURE: with a working grep the private string is not reported: rc=$_thr]"
6456
+ _th_hook "$_TH/r" pre-commit "" "$_TH/bin"; { [ "$_thr" = 1 ] && grep -q 'could not run' "$_TH/out"; } \
6457
+ || _thbad="$_thbad [grep aborting for -iF: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
6458
+ _th_repo "$_TH/e"; ( cd "$_TH/e" && printf 'an ordinary line\n' > a.txt && git add a.txt && printf 'docs: an ordinary message\n' > msg.txt ) >/dev/null 2>&1
6459
+ _th_hook "$_TH/e" pre-commit "" "$_TH/bin2"; { [ "$_thr" = 1 ] && grep -q 'TRACE-SCANNER: the look for .* could not run' "$_TH/out"; } \
6460
+ || _thbad="$_thbad [pre-commit, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
6461
+ _th_hook "$_TH/e" commit-msg "" "$_TH/bin2" msg.txt; { [ "$_thr" = 1 ] && grep -q 'could not run' "$_TH/out"; } \
6462
+ || _thbad="$_thbad [commit-msg, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
6463
+ _th_hook "$_TH/r" pre-commit "" "$_TH/bin3"; { [ "$_thr" = 1 ] && grep -q 'the staged diff could not be read' "$_TH/out"; } \
6464
+ || _thbad="$_thbad [pre-commit, the grep that collects the added lines aborting: rc=$_thr, want 1 and 'could not be read' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
6465
+ # a pattern that does not compile is a typo, not a failed look: it warns, in both git hooks, and the commit goes on
6466
+ _th_repo "$_TH/b"; printf 'unclosed[\n' >> "$_TH/b/trace-blocklist.txt"
6467
+ ( cd "$_TH/b" && printf 'an ordinary line\n' > a.txt && git add a.txt && printf 'docs: an ordinary message\n' > msg.txt ) >/dev/null 2>&1
6468
+ _th_hook "$_TH/b" pre-commit ""; { [ "$_thr" = 0 ] && grep -q 'is not a valid regular expression' "$_TH/out"; } \
6469
+ || _thbad="$_thbad [pre-commit with a pattern that does not compile: rc=$_thr, want 0 and the warning]"
6470
+ _th_hook "$_TH/b" commit-msg "" "" msg.txt; { [ "$_thr" = 0 ] && grep -q 'is not a valid regular expression' "$_TH/out"; } \
6471
+ || _thbad="$_thbad [commit-msg with a pattern that does not compile: rc=$_thr, want 0 and the warning]"
6472
+ [ -z "$_thbad" ] && pass "a scan that could not run stops the commit: with grep aborting for -iF (what Git Bash's grep does under a Turkish locale) or for -iE, or while the added lines are collected, pre-commit and commit-msg exit 1 and say so; a pattern that does not compile still only warns, in both" \
6473
+ || fail "a scan that could not run:$_thbad"
6474
+
6475
+ # guard-bash under a locale: $1 = locale or empty, $2 = tool, $3 = command as JSON text, $4 = mode -> _thr, stdout in $_TH/gb.out
6476
+ _th_gb(){ printf '{"tool_name":"%s","permission_mode":"%s","cwd":"%s","tool_input":{"command":"%s"}}' "$2" "${4:-auto}" "$_TH/w" "$3" > "$_TH/pl.json"
6477
+ ( cd "$_TH/w" && CREW_GATE_LOG=/dev/null env -u CLAUDE_GIT_OK ${1:+LC_ALL=$1} bash "$HOOKS/guard-bash.sh" < "$_TH/pl.json" > "$_TH/gb.out" 2>"$_TH/gb.err" ); _thr=$?; }
6478
+ # a repository with one change staged and a review record that matches it, so a plain commit reaches the prompt
6479
+ rm -rf "$_TH/w"; mkdir -p "$_TH/w"
6480
+ ( cd "$_TH/w" && git init -q && git config user.email t@example.com && git config user.name t && mkdir -p .claude \
6481
+ && echo one > a.txt && git add a.txt && git -c core.hooksPath=/dev/null commit -qm init && echo two >> a.txt && git add a.txt \
6482
+ && printf '{"diff_oid":"%s","head":"%s"}\n' "$(git diff --cached | git hash-object --stdin)" "$(git rev-parse HEAD)" > .claude/review-pass.json ) >/dev/null 2>&1
6483
+
6484
+ # The command shown in the approval prompt is cut to 300 bytes a line and goes into JSON. `cut -c` did it: GNU cut
6485
+ # counts bytes in every locale, so a two-byte letter across byte 300 left the JSON invalid UTF-8 (measured on Linux
6486
+ # under en_US.UTF-8). The cut steps back over an unfinished letter now, in any locale.
6487
+ _thutf=""; for _l in en_US.UTF-8 C.UTF-8 C.utf8; do
6488
+ [ "$(LC_ALL=$_l bash -c 'x=ş; printf %s "${#x}"' 2>/dev/null)" = 1 ] && { _thutf="$_l"; break; }
6489
+ done
6490
+ if ! command -v iconv >/dev/null 2>&1; then skip tool "the approval prompt's text is never cut inside a letter (no iconv to check UTF-8 with)"
6491
+ else
6492
+ _thbad=""
6493
+ for _l in "" ${_thutf:+"$_thutf"}; do
6494
+ # `git commit -m '` is 15 bytes; 284 a's bring it to 299, so byte 300 is the first half of the first ş
6495
+ _tha="$(printf '%*s' 284 '' | tr ' ' a)"; _th_gb "$_l" Bash "git commit -m ${_thq}${_tha}şşşşşşşşşş${_thq}" default
6496
+ { [ "$(gdec "$(cat "$_TH/gb.out")")" = ask ] && iconv -f UTF-8 -t UTF-8 < "$_TH/gb.out" >/dev/null 2>&1 && grep -q 'a…' "$_TH/gb.out"; } \
6497
+ || _thbad="$_thbad [locale '${_l:-C}', a cut that falls inside a letter: rc=$_thr, valid UTF-8: $(iconv -f UTF-8 -t UTF-8 < "$_TH/gb.out" >/dev/null 2>&1 && echo yes || echo NO)]"
6498
+ # a three-byte letter with two of its bytes before the cut: 283 a's, then € across bytes 299-301
6499
+ _tha="$(printf '%*s' 283 '' | tr ' ' a)"; _th_gb "$_l" Bash "git commit -m ${_thq}${_tha}€€€€${_thq}" default
6500
+ { [ "$(gdec "$(cat "$_TH/gb.out")")" = ask ] && iconv -f UTF-8 -t UTF-8 < "$_TH/gb.out" >/dev/null 2>&1 && grep -q 'a…' "$_TH/gb.out"; } \
6501
+ || _thbad="$_thbad [locale '${_l:-C}', a cut two bytes into a three-byte letter: rc=$_thr, valid UTF-8: $(iconv -f UTF-8 -t UTF-8 < "$_TH/gb.out" >/dev/null 2>&1 && echo yes || echo NO)]"
6502
+ # exactly 300 bytes: shown whole, no ellipsis
6503
+ _tha="$(printf '%*s' 264 '' | tr ' ' a)"; _th_gb "$_l" Bash "git commit -m ${_thq}şşşşşşşşşş${_tha}${_thq}" default
6504
+ { [ "$(gdec "$(cat "$_TH/gb.out")")" = ask ] && ! grep -q "a${_thq}…" "$_TH/gb.out" && grep -q "şa" "$_TH/gb.out"; } \
6505
+ || _thbad="$_thbad [locale '${_l:-C}', a command of exactly 300 bytes: rc=$_thr, want it whole and no ellipsis]"
6506
+ done
6507
+ [ -z "$_thbad" ] && pass "the command in the approval prompt is cut at 300 bytes a line and never inside a letter, under C${_thutf:+ and $_thutf}: the JSON stays UTF-8, and 300 bytes exactly are shown whole" \
6508
+ || fail "the approval prompt's text:$_thbad"
6509
+ fi
6510
+
6511
+ # The locale itself, where this machine has one.
6512
+ _th=""; for _l in tr_TR.UTF-8 tr_TR.utf8 az_AZ.UTF-8 az_AZ.utf8; do
6513
+ [ "$(LC_ALL=$_l bash -c 'x=ş; printf %s "${#x}"' 2>/dev/null)" = 1 ] && { _th="$_l"; break; }
6514
+ done
6515
+ if [ -z "$_th" ]; then
6516
+ skip platform "the gates under a Turkish locale (this machine has no tr_TR.UTF-8 or az_AZ.UTF-8)" 2
6517
+ else
6518
+ # What plain matching does under it here: this is what says whether the rows below can fail on this platform.
6519
+ _thc="$(LC_ALL=$_th bash -c 'shopt -s nocasematch; [[ GIT == git ]] && printf folds || printf "does not fold"' 2>/dev/null)"
6520
+ # in a subshell: where grep aborts, the shell that waits for it reports the job ("Aborted"), and that line is
6521
+ # not grep's stderr, so a redirection on the pipeline does not silence it
6522
+ _thx=0; ( printf 'INIT\n' | LC_ALL=$_th grep -qiF init ) >/dev/null 2>&1 || _thx=$?
6523
+ _thkind="here plain bash $_thc I to i and grep -iF exits $_thx for INIT/init"
6524
+ case "$_thc/$_thx" in folds/0) _thkind="$_thkind, so these rows cannot fail on this platform" ;; esac
6525
+ # (1) guard-bash: each of these gave another verdict under the locale before the block
6526
+ THT='2 @@ PowerShell @@ Remove-Item -Recurse -Force C:\\\\proj\\\\*
6527
+ 2 @@ Bash @@ git config --remove-section core
6528
+ 2 @@ Bash @@ git config --rename-section core x
6529
+ 2 @@ Bash @@ cat config/.env.production
6530
+ 2 @@ Bash @@ cat ~/.ssh/ID_RSA
6531
+ 0 @@ Bash @@ cat ~/.ssh/id_rsa.pub
6532
+ 0 @@ Bash @@ cat ~/.ssh/id_rsa.pub | ssh-keygen -lf -
6533
+ 0 @@ Bash @@ ls -la'
6534
+ _thbad=""; _thn=0
6535
+ while IFS= read -r _cl; do [ -z "$_cl" ] && continue
6536
+ _cw="${_cl%% @@ *}"; _cc="${_cl#* @@ }"; _ct="${_cc%% @@ *}"; _cc="${_cc#* @@ }"; _thn=$((_thn+1))
6537
+ _th_gb "$_th" "$_ct" "$_cc"; _thl="$_thr"; _th_gb "" "$_ct" "$_cc"
6538
+ { [ "$_thl" = "$_cw" ] && [ "$_thr" = "$_cw" ]; } || _thbad="$_thbad [$_ct: $_cc → $_thl under $_th, $_thr under C, want $_cw]"
6539
+ done <<< "$THT"
6540
+ if [ "$_thn" != 8 ]; then fail "FIXTURE: the gate table under $_th ran $_thn rows, not 8"
6541
+ elif [ -z "$_thbad" ]; then pass "under $_th guard-bash gives the verdicts it gives under C: 8 rows (a recursive Remove-Item, core.hooksPath removed or renamed away, a nested .env, ID_RSA by case refused; id_rsa.pub, alone and piped, and ls allowed) — $_thkind"
6542
+ else fail "verdicts that change under $_th:$_thbad"; fi
6543
+ # (2) the two git hooks
6544
+ _thbad=""
6545
+ _th_repo "$_TH/t"
6546
+ ( cd "$_TH/t" && printf 'notes generated by %s\n' 'COPI''LOT' > t.md && git add t.md ) >/dev/null 2>&1
6547
+ _th_hook "$_TH/t" pre-commit "$_th"; { [ "$_thr" = 1 ] && grep -q 'TRACE-SCANNER' "$_TH/out"; } || _thbad="$_thbad [pre-commit, a vendor name in capitals in an added line: rc=$_thr, want 1]"
6548
+ _th_repo "$_TH/s"
6549
+ ( cd "$_TH/s" && printf 'aws_key = AKIA%s\n' 'IOSFODNN7EXAMPLE' > leak.txt && git add leak.txt ) >/dev/null 2>&1
6550
+ _th_hook "$_TH/s" pre-commit "$_th"; { [ "$_thr" = 1 ] && grep -q 'SECRET-SCANNER' "$_TH/out"; } || _thbad="$_thbad [pre-commit, a staged key that holds an I: rc=$_thr, want 1]"
6551
+ _th_hook "$_TH/r" pre-commit "$_th"; { [ "$_thr" = 1 ] && grep -q 'PRIVATE-PATH SCANNER: a staged line contains' "$_TH/out"; } || _thbad="$_thbad [pre-commit, a private string in another case: rc=$_thr, want 1 — $(sed -n 1p "$_TH/out" | cut -c1-100)]"
6552
+ _th_hook "$_TH/e" pre-commit "$_th"; [ "$_thr" = 0 ] || _thbad="$_thbad [pre-commit, an ordinary line: rc=$_thr, want 0]"
6553
+ ( cd "$_TH/t" && printf 'docs: notes\n\nWritten with %s\n' 'COPI''LOT' > msg.txt ) >/dev/null 2>&1
6554
+ _th_hook "$_TH/t" commit-msg "$_th" "" msg.txt; [ "$_thr" = 1 ] || _thbad="$_thbad [commit-msg, a vendor name in capitals in the message: rc=$_thr, want 1]"
6555
+ _th_hook "$_TH/e" commit-msg "$_th" "" msg.txt; [ "$_thr" = 0 ] || _thbad="$_thbad [commit-msg, an ordinary message: rc=$_thr, want 0]"
6556
+ # a letter outside ASCII keeps its other case: the second look, under the session's locale
6557
+ _th_repo "$_TH/n"; printf 'şirket-gizli\n' > "$_TH/n/.private-terms.txt"
6558
+ ( cd "$_TH/n" && printf 'see ŞİRKET-GİZLİ\n' > n.md && git add n.md ) >/dev/null 2>&1
6559
+ _th_hook "$_TH/n" pre-commit "$_th"; _thna="$_thr"
6560
+ # required where this platform's grep folds such a letter under the locale at all (GNU grep does, BSD grep does not)
6561
+ if ( printf 'ŞİRKET\n' | LC_ALL=$_th grep -qiF 'şirket' ) >/dev/null 2>&1; then
6562
+ [ "$_thna" = 1 ] || _thbad="$_thbad [pre-commit, a private string of non-ASCII letters in another case: rc=$_thna, want 1 (this grep folds them under $_th)]"
6563
+ fi
6564
+ [ -z "$_thbad" ] && pass "under $_th the git hooks stop what they stop under C: a vendor name in another case in an added line and in the message, a staged key that holds an I, a private string in another case; an ordinary line and message pass (a private string of non-ASCII letters in another case: exit $_thna, 1 = caught by the second look)" \
6565
+ || fail "the git hooks under $_th:$_thbad"
6566
+ fi
6567
+ rm -rf "$_TH"
6568
+ fi # UNITS
6569
+
6409
6570
  sec "== 13) pre-commit cost — the gate people route around is the one that is slow =="
6410
6571
  # Measured on a 373-file merge: the old file loop spawned ~7 processes per file (three `printf | grep` pairs and
6411
6572
  # a `git cat-file`), 2,643 in total. At the 62-135 ms a Git Bash process was measured to cost on a Windows 11
@@ -4,6 +4,49 @@
4
4
  # (2) team board claim gate — a commit that names an item must be backed by a live claim held by YOU.
5
5
  # Skipping either requires an explicit request per §4.5 (git commit --no-verify).
6
6
  set -euo pipefail
7
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
8
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
9
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
10
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
11
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
12
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
13
+ # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
14
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
15
+ # staged key all passed.
16
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
17
+ # scan for private strings read that as "no match".
18
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
19
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
20
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
21
+ # Byte-identical in every gate; the suite pins it.
22
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
23
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
24
+ export LC_ALL=C
25
+ # ---- /CREW-LOCALE
26
+ # ---- CREW-SCAN -------------------------------------------------------------------------------------------
27
+ # One scan of one file for one pattern, with "could not look" kept apart from "nothing there". grep exits 1 for
28
+ # no match and 2 or more when it could not answer: a pattern that does not compile, a file it could not read, or
29
+ # grep itself dying (exit 134 for `grep -iF` under a Turkish locale in Git Bash, measured). Read as "no match",
30
+ # that is a scan that passes whatever it was shown.
31
+ # 0 a match 1 no match
32
+ # 2 the pattern does not compile (grep says exactly 2, and says it again for empty input) - the caller warns:
33
+ # a typo in a hand-edited list must not lock every commit, and the other patterns still ran
34
+ # 3 could not look - the caller stops the commit
35
+ # A pattern that holds a letter outside ASCII is looked for a second time under the session's own locale when
36
+ # the first look finds nothing and the match ignores case: in the C locale such a letter has no other case.
37
+ _crew_scan(){ # $1 = grep's flags after -q (iE, E, iF) · $2 = pattern · $3 = file
38
+ local r=0
39
+ grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$?
40
+ if [ "$r" = 1 ] && [ -n "$_CREW_LOCALE" ]; then
41
+ case "$1" in *i*) case "$2" in *[!$'\t'\ -~]*) r=0; LC_ALL="$_CREW_LOCALE" grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$? ;; esac ;; esac
42
+ fi
43
+ [ "$r" -ge 2 ] || return "$r"
44
+ case "$1" in *F*) return 3 ;; esac
45
+ r=0; grep -q"$1" -- "$2" /dev/null 2>/dev/null || r=$?
46
+ [ "$r" != 2 ] || return 2
47
+ return 3
48
+ }
49
+ # ---- /CREW-SCAN
7
50
  HERE="$(cd "$(dirname "$0")" && pwd)"
8
51
  BL="$HERE/trace-blocklist.txt"
9
52
  MSG_FILE="${1:-}"
@@ -36,9 +79,15 @@ while IFS= read -r pat || [ -n "$pat" ]; do
36
79
  # project, Git for Windows defaults core.autocrlf=true,
37
80
  # and this file is meant to be edited by hand.
38
81
  if [ -n "$AL" ] && grep -qxF -- "$pat" "$AL"; then continue; fi
39
- if grep -iqE -- "$pat" "$MSG_FILE"; then
82
+ _gr=0; _crew_scan iE "$pat" "$MSG_FILE" || _gr=$?
83
+ if [ "$_gr" = 0 ]; then
40
84
  echo "TRACE-SCANNER (message): forbidden expression -> '$pat' (§4.1)"
41
85
  HIT=1
86
+ elif [ "$_gr" = 2 ]; then
87
+ echo "TRACE-SCANNER (message): /$pat/ in $BL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
88
+ elif [ "$_gr" -ge 3 ]; then
89
+ echo "TRACE-SCANNER (message): the look for '$pat' could not run, so the message is not known to be clean and the commit is stopped (§4.1)."
90
+ HIT=1
42
91
  fi
43
92
  done < "$BL"
44
93
 
@@ -39,6 +39,25 @@
39
39
  # CLAUDE_GIT_OK=1, exported by the user before the session starts, pre-authorises the session. It exists for
40
40
  # headless/CI runs where no one is at the keyboard. It does NOT replace approval: present the message first.
41
41
  set -uo pipefail
42
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
43
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
44
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
45
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
46
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
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
49
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
50
+ # staged key all passed.
51
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
52
+ # scan for private strings read that as "no match".
53
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
54
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
55
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
56
+ # Byte-identical in every gate; the suite pins it.
57
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
58
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
59
+ export LC_ALL=C
60
+ # ---- /CREW-LOCALE
42
61
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
43
62
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
44
63
  [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
@@ -1221,6 +1240,25 @@ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(rm|
1221
1240
  # - fold a surviving tab to a space (display-only text; the command Claude runs is untouched);
1222
1241
  # - escape backslash and double quote;
1223
1242
  # - fold newlines to the two-character \n escape.
1243
+ # Each line of a text cut to at most N bytes, never inside a letter. This is the command as the approval prompt
1244
+ # shows it, and it goes into JSON. `cut -c1-300` did this job: GNU cut counts BYTES whatever the locale (measured on
1245
+ # Linux under en_US.UTF-8: a two-byte letter across byte 300 left the prompt's JSON invalid UTF-8), and BSD cut
1246
+ # counts characters only while the session's locale says so. Here the cut is by bytes and then steps back over an
1247
+ # unfinished letter: a UTF-8 letter is one lead byte (0xC0 and up) followed by continuation bytes (0x80-0xBF).
1248
+ _utf8_cut(){ # $1 = text, $2 = bytes per line -> _UC
1249
+ local LC_ALL=C
1250
+ local l out="" nl=""
1251
+ while IFS= read -r l || [ -n "$l" ]; do
1252
+ if [ "${#l}" -gt "$2" ]; then
1253
+ l="${l:0:$2}"
1254
+ while :; do case "$l" in *[$'\x80'-$'\xbf']) l="${l%?}" ;; *) break ;; esac; done
1255
+ case "$l" in *[$'\xc0'-$'\xff']) l="${l%?}" ;; esac
1256
+ fi
1257
+ out="$out$nl$l"; nl=$'\n'
1258
+ done <<< "$1"
1259
+ while :; do case "$out" in *$'\n') out="${out%?}" ;; *) break ;; esac; done
1260
+ _UC="$out"
1261
+ }
1224
1262
  json_escape(){
1225
1263
  printf '%s' "$1" \
1226
1264
  | tr -d '\000-\010\013-\037\177' \
@@ -1560,7 +1598,7 @@ if git_has "$CMD" 'commit|push'; then
1560
1598
  default|acceptEdits)
1561
1599
  # A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
1562
1600
  SHORT="$CMD"
1563
- [ "${#SHORT}" -gt 300 ] && SHORT="$(printf '%s' "$SHORT" | cut -c1-300)…"
1601
+ [ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
1564
1602
  # §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
1565
1603
  # lives on main), but it is surfaced in the approval prompt so the user can send it to a branch instead.
1566
1604
  BRANCH_WARN=""
@@ -19,6 +19,25 @@
19
19
  # Deliberately NOT covered: `--no-verify`. It is §4.5 and `guard-bash.sh` blocks it outright, so it never
20
20
  # reaches this hook.
21
21
  set -uo pipefail
22
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
23
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
24
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
25
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
26
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
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
29
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
30
+ # staged key all passed.
31
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
32
+ # scan for private strings read that as "no match".
33
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
34
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
35
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
36
+ # Byte-identical in every gate; the suite pins it.
37
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
38
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
39
+ export LC_ALL=C
40
+ # ---- /CREW-LOCALE
22
41
  DIR="$(cd "$(dirname "$0")" && pwd)"
23
42
 
24
43
  # ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
@@ -18,6 +18,25 @@
18
18
  # Wired ONLY on the PowerShell matcher (both editions; smoke pins it). On `Bash` it would block the very commands it
19
19
  # points to.
20
20
  set -uo pipefail
21
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
22
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
23
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
24
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
25
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
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
28
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
29
+ # staged key all passed.
30
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
31
+ # scan for private strings read that as "no match".
32
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
33
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
34
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
35
+ # Byte-identical in every gate; the suite pins it.
36
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
37
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
38
+ export LC_ALL=C
39
+ # ---- /CREW-LOCALE
21
40
  # Read with the builtin: `INPUT="$(cat)"` is a subshell plus a process on EVERY PowerShell call, and on Git Bash a
22
41
  # process costs 62-135 ms. With this, a command that cannot match (no `bash` in the payload) opens no process at all
23
42
  # (measured with the xtrace counter: `cat` 1 -> 0). `read` returns 1 at end of input; the text is read regardless.
@@ -30,6 +30,25 @@
30
30
  # Normalise first, match second — and normalise with parameter expansion only, because this hook runs before
31
31
  # EVERY Write/Edit and a fork per call is a freeze on Windows (Git Bash charges 62-135 ms per process, measured).
32
32
  set -uo pipefail
33
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
34
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
35
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
36
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
37
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
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
40
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
41
+ # staged key all passed.
42
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
43
+ # scan for private strings read that as "no match".
44
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
45
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
46
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
47
+ # Byte-identical in every gate; the suite pins it.
48
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
49
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
50
+ export LC_ALL=C
51
+ # ---- /CREW-LOCALE
33
52
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
34
53
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
35
54
  [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
@@ -5,6 +5,59 @@
5
5
  # (3) private-path scanner — this machine's $HOME + .private-terms.txt (§4.3)
6
6
  # Skipping requires an explicit request per §4.5 (git commit --no-verify).
7
7
  set -euo pipefail
8
+ # ---- CREW-LOCALE -----------------------------------------------------------------------------------------
9
+ # Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
10
+ # the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
11
+ # match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
12
+ # GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
13
+ # bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
14
+ # turns GIT into gıt. The suite, run whole under that locale, went from 2 failures to 23: a recursive
15
+ # Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
16
+ # staged key all passed.
17
+ # GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
18
+ # scan for private strings read that as "no match".
19
+ # macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
20
+ # The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
21
+ # words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
22
+ # Byte-identical in every gate; the suite pins it.
23
+ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
24
+ case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
25
+ export LC_ALL=C
26
+ # ---- /CREW-LOCALE
27
+ # ---- CREW-SCAN -------------------------------------------------------------------------------------------
28
+ # One scan of one file for one pattern, with "could not look" kept apart from "nothing there". grep exits 1 for
29
+ # no match and 2 or more when it could not answer: a pattern that does not compile, a file it could not read, or
30
+ # grep itself dying (exit 134 for `grep -iF` under a Turkish locale in Git Bash, measured). Read as "no match",
31
+ # that is a scan that passes whatever it was shown.
32
+ # 0 a match 1 no match
33
+ # 2 the pattern does not compile (grep says exactly 2, and says it again for empty input) - the caller warns:
34
+ # a typo in a hand-edited list must not lock every commit, and the other patterns still ran
35
+ # 3 could not look - the caller stops the commit
36
+ # A pattern that holds a letter outside ASCII is looked for a second time under the session's own locale when
37
+ # the first look finds nothing and the match ignores case: in the C locale such a letter has no other case.
38
+ _crew_scan(){ # $1 = grep's flags after -q (iE, E, iF) · $2 = pattern · $3 = file
39
+ local r=0
40
+ grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$?
41
+ if [ "$r" = 1 ] && [ -n "$_CREW_LOCALE" ]; then
42
+ case "$1" in *i*) case "$2" in *[!$'\t'\ -~]*) r=0; LC_ALL="$_CREW_LOCALE" grep -q"$1" -- "$2" "$3" 2>/dev/null || r=$? ;; esac ;; esac
43
+ fi
44
+ [ "$r" -ge 2 ] || return "$r"
45
+ case "$1" in *F*) return 3 ;; esac
46
+ r=0; grep -q"$1" -- "$2" /dev/null 2>/dev/null || r=$?
47
+ [ "$r" != 2 ] || return 2
48
+ return 3
49
+ }
50
+ # ---- /CREW-SCAN
51
+ _crew_looked(){ # $1 = what a pipeline collects for the scans · the rest = the exit status of each of its commands
52
+ # Every status, not the pipeline's own: with pipefail that is the LAST command that failed, and the last grep
53
+ # exits 1 whenever nothing reaches it - which is exactly what an abort further up leaves behind.
54
+ local what="$1" r; shift
55
+ for r in "$@"; do
56
+ [ "$r" -lt 2 ] && continue
57
+ echo "PRE-COMMIT: $what could not be read (exit $r), so nothing was scanned and the commit is stopped."
58
+ exit 1
59
+ done
60
+ }
8
61
  # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
9
62
  _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
10
63
  [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
@@ -47,7 +100,7 @@ trap '_crew_cleanup; exit 143' INT TERM
47
100
  git diff --cached --unified=0 -- . \
48
101
  ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
49
102
  ':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
50
- | grep -E '^\+' | grep -Ev '^\+\+\+' > "$SECRET_F" || true
103
+ | grep -E '^\+' | grep -Ev '^\+\+\+' > "$SECRET_F" || _crew_looked "the staged diff" "${PIPESTATUS[@]}"
51
104
 
52
105
  # Traces (§4.1/§4.2) are scanned in the PROJECT's own files only. `.claude/` is Crewforth's tree: it configures the
53
106
  # assistant, it legitimately names the tool it configures, and an update overwrites it — it is not an artifact the
@@ -56,7 +109,7 @@ git diff --cached --unified=0 -- . \
56
109
  # falls under .claude/, but in Crewforth's own repo it sits at kit/hooks/ where that exclusion misses it.
57
110
  git diff --cached --unified=0 -- . ':(top,exclude).claude' \
58
111
  ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
59
- | grep -E '^\+' | grep -Ev '^\+\+\+' > "$TRACE_F" || true
112
+ | grep -E '^\+' | grep -Ev '^\+\+\+' > "$TRACE_F" || _crew_looked "the staged diff" "${PIPESTATUS[@]}"
60
113
 
61
114
  # `git commit -a` stages tracked changes as part of the commit. As a git hook that is invisible — git stages
62
115
  # first, so --cached already contains them by the time this runs. Called AHEAD of the commit (the plugin
@@ -67,10 +120,10 @@ if [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ]; then
67
120
  git diff --unified=0 -- . \
68
121
  ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
69
122
  ':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
70
- | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$SECRET_F" || true
123
+ | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$SECRET_F" || _crew_looked "the unstaged diff" "${PIPESTATUS[@]}"
71
124
  git diff --unified=0 -- . ':(top,exclude).claude' \
72
125
  ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
73
- | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$TRACE_F" || true
126
+ | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$TRACE_F" || _crew_looked "the unstaged diff" "${PIPESTATUS[@]}"
74
127
  fi
75
128
 
76
129
  HIT=0
@@ -300,16 +353,21 @@ done < <({ _floor_diff --cached; [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ] && _floor_di
300
353
  # secrets newly passed. Also checked structurally: no pattern in either shipped blocklist carries a bare
301
354
  # top-level `|` (every one is inside a group) and none uses a back-reference, so joining cannot reshape them.
302
355
  _crew_any(){ # $1 = pattern list $2 = corpus $3 = grep flags $4 = 1 for newline-joined fixed strings
303
- local pat alt="" sep="|"
356
+ local pat alt="" sep="|" na=0 r=0
304
357
  [ "$4" = 1 ] && sep="
305
358
  "
306
359
  while IFS= read -r pat || [ -n "$pat" ]; do
307
360
  pat="${pat%$'\r'}"
308
361
  case "$pat" in ''|\#*) continue;; esac
309
362
  alt="${alt:+$alt$sep}$pat"
363
+ case "$pat" in *[!$'\t'\ -~]*) na=1 ;; esac
310
364
  done < "$1"
311
365
  [ -n "$alt" ] || return 1
312
- grep -q$3 -- "$alt" "$2" 2>/dev/null
366
+ grep -q$3 -- "$alt" "$2" 2>/dev/null || r=$?
367
+ # Nothing found, but a pattern holds a letter outside ASCII and the match ignores case: the per-pattern loop looks
368
+ # again under the session's own locale (_crew_scan), so send the caller there.
369
+ if [ "$r" = 1 ] && [ "$na" = 1 ] && [ -n "$_CREW_LOCALE" ]; then case "$3" in *i*) return 4 ;; esac; fi
370
+ return "$r"
313
371
  }
314
372
  # (1) trace scan — case-INsensitive (vendor / AI-authorship strings), project files only
315
373
  # `!= 1` AND NOT `= 0`, AND THE DIFFERENCE IS THE WHOLE GATE. _crew_any joins every pattern into ONE
@@ -345,12 +403,15 @@ if [ -f "$BL" ] && [ -s "$TRACE_F" ] && [ "$_dec" != 1 ]; then
345
403
  # `|| _gr=$?` and not a bare call: `set -e` is in force at the top of this file, and a grep that simply
346
404
  # does not match exits 1, which would kill the hook on the first clean pattern. Measured the wrong way once
347
405
  # already -- the bare form made every commit exit 1 with no output at all.
348
- _gr=0; grep -iqE -- "$pat" "$TRACE_F" 2>/dev/null || _gr=$?
406
+ _gr=0; _crew_scan iE "$pat" "$TRACE_F" || _gr=$?
349
407
  if [ "$_gr" = 0 ]; then
350
408
  echo "TRACE-SCANNER: forbidden expression in added code -> '$pat' (§4.1/§4.2)"
351
409
  HIT=1
352
- elif [ "$_gr" -ge 2 ]; then
410
+ elif [ "$_gr" = 2 ]; then
353
411
  echo "TRACE-SCANNER: /$pat/ in $BL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
412
+ elif [ "$_gr" -ge 3 ]; then
413
+ echo "TRACE-SCANNER: the look for '$pat' could not run, so the added lines are not known to be clean and the commit is stopped (§4.1/§4.2)."
414
+ HIT=1
354
415
  fi
355
416
  done < "$BL"
356
417
  fi
@@ -361,11 +422,14 @@ if [ -f "$SL" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
361
422
  pat="${pat%$'\r'}" # tolerate a CRLF blocklist (Windows/autocrlf checkout)
362
423
  case "$pat" in ''|\#*) continue;; esac
363
424
  if [ -n "$SAL" ] && grep -qxF -- "$pat" "$SAL"; then continue; fi
364
- _gr=0; grep -qE -- "$pat" "$SECRET_F" 2>/dev/null || _gr=$?
425
+ _gr=0; _crew_scan E "$pat" "$SECRET_F" || _gr=$?
365
426
  if [ "$_gr" = 0 ]; then
366
427
  echo "SECRET-SCANNER: a staged line matches a secret pattern -> /$pat/"
367
428
  HIT=1
368
- elif [ "$_gr" -ge 2 ]; then
429
+ elif [ "$_gr" -ge 3 ]; then
430
+ echo "SECRET-SCANNER: the look for /$pat/ could not run, so the staged lines are not known to be clean and the commit is stopped."
431
+ HIT=1
432
+ elif [ "$_gr" = 2 ]; then
369
433
  # Same reasoning as the trace loop above; the comment there is the long form.
370
434
  echo "SECRET-SCANNER: /$pat/ in $SL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
371
435
  fi
@@ -411,11 +475,17 @@ if [ -s "$PTERM_F" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
411
475
  term="${term%$'\r'}"
412
476
  [ -n "$term" ] || continue
413
477
  if [ -n "$PAL" ] && grep -qxF -- "$term" "$PAL"; then continue; fi
414
- if grep -qiF -- "$term" "$SECRET_F"; then
478
+ _gr=0; _crew_scan iF "$term" "$SECRET_F" || _gr=$?
479
+ if [ "$_gr" = 0 ]; then
415
480
  # The term is echoed back deliberately: unlike a secret, the author has to see WHICH private string to
416
481
  # replace, and it is already sitting in their own working tree.
417
482
  echo "PRIVATE-PATH SCANNER: a staged line contains a machine-private string -> '$term' (§4.3)"
418
483
  HIT=1
484
+ elif [ "$_gr" -ge 2 ]; then
485
+ # This loop used to read every exit status but 0 as "no match": under a Turkish locale in Git Bash grep -iF
486
+ # aborted (134) for each term and the commit went through with both private strings in it (measured).
487
+ echo "PRIVATE-PATH SCANNER: the look for a machine-private string could not run, so the staged lines are not known to be clean and the commit is stopped (§4.3)."
488
+ HIT=1
419
489
  fi
420
490
  done < "$PTERM_F"
421
491
  fi
@@ -535,7 +605,14 @@ EOF_FLOOR_CAND2
535
605
  esac
536
606
  _gr=0; hits="$(grep -nE -- "$pat" "$FLOOR_F" 2>/dev/null)" || _gr=$?
537
607
  if [ "$_gr" -ge 2 ]; then
538
- echo "FLOOR-GUARD: /$pat/ in $FL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
608
+ # Does the pattern compile? Tried against empty input: 1 = it does, so it was the look that failed.
609
+ _gv=0; grep -qE -- "$pat" /dev/null 2>/dev/null || _gv=$?
610
+ if [ "$_gv" = 2 ]; then
611
+ echo "FLOOR-GUARD: /$pat/ in $FL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
612
+ else
613
+ echo "FLOOR-GUARD: the look for /$pat/ could not run, so the staged lines are not known to be clean and the commit is stopped."
614
+ HIT=1
615
+ fi
539
616
  continue
540
617
  fi
541
618
  [ -n "$hits" ] || continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Crewforth \u2014 your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"