crewforth 3.0.0-rc.3 → 3.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/VERSION +1 -1
  3. package/adopt.sh +186 -19
  4. package/kit/CLAUDE.md +5 -4
  5. package/kit/README.md +1 -1
  6. package/kit/agents/crew-frontend-expert.md +6 -6
  7. package/kit/agents/crew-session-manager.md +1 -1
  8. package/kit/eval/doctor.sh +72 -18
  9. package/kit/eval/golden-routing.txt +15 -0
  10. package/kit/eval/preflight.sh +2 -2
  11. package/kit/eval/smoke-test.sh +625 -29
  12. package/kit/hooks/board-sync.sh +1 -1
  13. package/kit/hooks/board.sh +4 -3
  14. package/kit/hooks/guard-bash.sh +195 -21
  15. package/kit/hooks/guard-commit-scan.sh +2 -2
  16. package/kit/hooks/guard-powershell.sh +328 -0
  17. package/kit/hooks/guard-write.sh +28 -3
  18. package/kit/hooks/route-hint.sh +5 -1
  19. package/kit/hooks/session-update-check.sh +2 -2
  20. package/kit/hooks/skill-trust.sh +22 -11
  21. package/kit/legacy-blobs.tsv +208 -0
  22. package/kit/settings.json +10 -0
  23. package/kit/skills/automode-policy/SKILL.md +1 -1
  24. package/kit/skills/automode-policy/scripts/check.sh +2 -2
  25. package/kit/skills/crew-board/SKILL.md +1 -1
  26. package/kit/skills/crew-doctor/SKILL.md +3 -3
  27. package/kit/skills/crew-gates/SKILL.md +2 -1
  28. package/kit/skills/crew-handoff/SKILL.md +1 -1
  29. package/kit/skills/crew-skill/SKILL.md +2 -2
  30. package/kit/skills/crew-studio/SKILL.md +3 -2
  31. package/kit/skills/crew-update/SKILL.md +3 -3
  32. package/kit/skills/frontend/SKILL.md +26 -2
  33. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  34. package/kit/skills/handoff/SKILL.md +2 -2
  35. package/kit/skills/reflect/SKILL.md +2 -2
  36. package/kit/skills/teamboard/SKILL.md +1 -1
  37. package/kit/skills/token-budget/SKILL.md +2 -2
  38. package/kit/skills/trace-scan/SKILL.md +4 -3
  39. package/kit/studio/README.md +1 -1
  40. package/package.json +1 -1
  41. package/start.sh +15 -8
@@ -76,7 +76,7 @@ fi
76
76
  # /crew-board is user-only, so the command Claude can run itself comes first (the plugin's copy names its path).
77
77
  if [ -f "$HERE/../.claude-plugin/plugin.json" ]; then _BS="bash \"$HERE/board.sh\" sync"; else _BS='bash .claude/hooks/board.sh sync'; fi
78
78
  MSG="$(cat "$CACHE")
79
- Board state above is a cached snapshot; $_BS (or the user can type /crew-board sync) refreshes it."
79
+ Board state above is a cached snapshot; $_BS (Bash tool, not PowerShell — or the user can type /crew-board sync) refreshes it."
80
80
 
81
81
  # ONE PATH, no jq. The JSON is built here on every machine, so a Mac with jq and a Windows box without it emit the
82
82
  # same bytes. The escaper is jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL,
@@ -40,9 +40,10 @@ _git_dir(){ git rev-parse --git-common-dir 2>/dev/null; }
40
40
  # Claude reads must give it the command it can run itself — this script — and name the slash form for the user.
41
41
  # The plugin edition's copy lives outside the project, so it names its own absolute path. Only called when a message
42
42
  # is printed, never on the hot path.
43
- _selfcmd(){ local d; d="$(cd "${BASH_SOURCE%/*}" 2>/dev/null && pwd)"
44
- if [ -f "$d/../.claude-plugin/plugin.json" ]; then printf 'bash "%s/board.sh"' "$d"; else printf 'bash .claude/hooks/board.sh'; fi; }
45
- _act(){ printf '%s %s (or the user can type /crew-board %s)' "$(_selfcmd)" "$1" "${2:-$1}"; }
43
+ _selfcmd(){ local d="${BASH_SOURCE%/*}"; [ "$d" = "$BASH_SOURCE" ] && d=. # run by bare name: the name has no slash
44
+ d="$(cd "$d" 2>/dev/null && pwd)"
45
+ if [ -f "$d/../.claude-plugin/plugin.json" ]; then _SELF="bash \"$d/board.sh\""; else _SELF='bash .claude/hooks/board.sh'; fi; }
46
+ _act(){ _selfcmd; printf '%s %s (Bash tool, not PowerShell — or the user can type /crew-board %s)' "$_SELF" "$1" "${2:-$1}"; }
46
47
 
47
48
  # Ref namespace. Default is a custom namespace so the board never shows up in `git branch` and never enters the
48
49
  # code history. Some servers refuse refs outside refs/heads|refs/tags; `probe` detects that and records the
@@ -546,6 +546,73 @@ block(){
546
546
  exit 2
547
547
  }
548
548
 
549
+ # `printf '%s' "$2" | grep -q[i]E "$3"` without the process. Measured on a Windows 11 machine under load: one bare
550
+ # `bash` cost 1.3-3.9 s there, and `git status` walked this hook through 14 processes (13 greps + the stdin cat),
551
+ # 37-66 s per call; on macOS the same walk is ~38 ms. The rules below keep their regex TEXT unchanged and only
552
+ # change who runs it: the shell's own ERE matcher, fed one line at a time because grep matches per line — `^`/`$`
553
+ # anchor at each line and no class ever spans a newline, which a whole-string [[ =~ ]] would get wrong. Case
554
+ # folding is nocasematch, restored to what it was, so it cannot leak into a later `case` (see git_has).
555
+ _ere() { # $1 = i (fold case) | s ; $2 = text ; $3 = ERE ; $4 = glob a matching line must contain (a superset of a literal the ERE requires)
556
+ local rc=1 nc=0 line n=0 g="${4:-*}"
557
+ case "$2" in
558
+ *$'\n'*)
559
+ # Split once (`read -a`), keep only lines carrying the literal, and let the shell match those. bash compiles the
560
+ # regex on every =~, so a regex per line is a regcomp per line: a 2,000-line heredoc took 70 s that way (measured).
561
+ # Past 64 candidate lines one grep — compiled once, one process — is cheaper; fed from a here-string, not a pipe,
562
+ # so an early `grep -q` exit cannot SIGPIPE the writer and flip the verdict under pipefail.
563
+ [[ $2 == $g ]] || return 1 # the literal is nowhere in the text: no line can match
564
+ # git_has alone asks ~10 questions of the same command; the split-and-filter is cached for the last (text, glob).
565
+ if [ "$2" != "${_ERE_T-}" ] || [ "$g" != "${_ERE_G-}" ]; then
566
+ local -a _el; _ERE_C=(); _ERE_T="$2"; _ERE_G="$g"
567
+ IFS=$'\n' read -r -d '' -a _el <<< "$2"
568
+ for line in ${_el[@]+"${_el[@]}"}; do [[ $line == $g ]] && _ERE_C[${#_ERE_C[@]}]="$line"; done
569
+ fi
570
+ n=${#_ERE_C[@]}
571
+ if [ "$n" -gt 64 ]; then
572
+ if [ "$1" = i ]; then grep -qiE -- "$3" <<< "$2"; else grep -qE -- "$3" <<< "$2"; fi
573
+ return $?
574
+ fi
575
+ [ "$1" = i ] && { shopt -q nocasematch && nc=1; shopt -s nocasematch; }
576
+ for line in ${_ERE_C[@]+"${_ERE_C[@]}"}; do [[ $line =~ $3 ]] && { rc=0; break; }; done ;;
577
+ *)
578
+ [ "$1" = i ] && { shopt -q nocasematch && nc=1; shopt -s nocasematch; }
579
+ [[ $2 =~ $3 ]] && rc=0 ;;
580
+ esac
581
+ [ "$1" = i ] && [ "$nc" = 0 ] && shopt -u nocasematch
582
+ return $rc
583
+ }
584
+
585
+ # An exemption belongs to the command it sits in, not to the whole line. Four rules used to ask "does the SAFE
586
+ # marker appear ANYWHERE?" — so `terraform --help && terraform destroy -auto-approve`, `cat .env.example; cat .env`
587
+ # and `cat ~/.ssh/id_rsa.pub; cat ~/.ssh/id_rsa` went through while each forbidden half alone was blocked (72
588
+ # chained shapes measured open across &&, ;, ||, |, ( ) and $( )). _seg_any asks the rule of each command segment
589
+ # instead, splitting on ; & | ( ) ` and newline — so `cat .env.example $(cat .env)` is two segments too.
590
+ # Each rule keeps its old whole-line test as well and blocks when EITHER fires, so nothing blocked before passes now:
591
+ # a hit that spans a split point (`terraform -chdir=$(pwd) destroy`, a `.*` reaching across a `;`) is still the old
592
+ # test's to find. The segment test only ever adds blocks.
593
+ # Split into segments. bash 3.2's ${var//[set]/x} slows down faster than linearly with size and ~7x more in a UTF-8
594
+ # locale (review: 21 s for an ordinary 200-line heredoc), so a long command is split by one `tr` — a process, but only
595
+ # above 2 KB, where the hook was already paying for the payload's size.
596
+ _split_segs() { # $1 = text -> array _SPL
597
+ _SPL=()
598
+ if [ "${#1}" -gt 2048 ]; then IFS=$'\n' read -r -d '' -a _SPL < <(printf '%s' "$1" | LC_ALL=C tr ';&|()`' '\n\n\n\n\n\n')
599
+ else IFS=$'\n' read -r -d '' -a _SPL <<< "${1//[;&|()\`]/$'\n'}"; fi
600
+ }
601
+ _seg_any() { # $1 = forbidden ERE, $2 = exempting ERE, $3 = glob the forbidden segment must match -> 0 when one segment has $1 and not $2
602
+ local -a _sc; local sg n=0 nc=0 rc=1
603
+ [[ $CMD == $3 ]] || return 1
604
+ _split_segs "$CMD"
605
+ for sg in ${_SPL[@]+"${_SPL[@]}"}; do [[ $sg == $3 ]] && { _sc[n]="$sg"; n=$((n+1)); }; done
606
+ [ "$n" = 0 ] && return 1
607
+ if [ "$n" -gt 64 ]; then # same bound as _ere: one pipeline instead of a regcomp per segment; the output decides, not the status
608
+ [ -n "$(printf '%s\n' "${_sc[@]}" | grep -aiE -- "$1" | grep -aivE -m1 -- "$2")" ]; return $?
609
+ fi
610
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
611
+ for sg in "${_sc[@]}"; do [[ $sg =~ $1 ]] && ! [[ $sg =~ $2 ]] && { rc=0; break; }; done
612
+ [ "$nc" = 0 ] && shopt -u nocasematch
613
+ return $rc
614
+ }
615
+
549
616
  # One matcher for "git invoked with subcommand X", tolerant of the forms that used to slip past the old
550
617
  # 'git +subcmd' rules: interposed options (git -C <path> …, git -c k=v …), TAB separators, and a
551
618
  # quote/backtick/paren/pipe right before `git` (eval "git …", bash -c 'git …', `git …`, and the raw-JSON
@@ -572,7 +639,7 @@ git_has() { # $1 = command text, $2 = subcommand alternation (e.g. 'commit|push
572
639
  # and it works on the bash 3.2 macOS still ships. Strictly a superset of the regex — anything the grep could
573
640
  # match contains `git` case-insensitively — so no verdict can change.
574
641
  case "$1" in *[Gg][Ii][Tt]*) ;; *) return 1 ;; esac
575
- printf '%s' "$1" | grep -qiE "(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*($2)([[:space:]]|[;&|\"'\`\\\\]|\$)"
642
+ _ere i "$1" "(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*($2)([[:space:]]|[;&|\"'\`\\\\]|\$)" '*[Gg][Ii][Tt]*'
576
643
  }
577
644
  # Same precondition, hoisted once for the rules that inline the `git …` pattern instead of calling git_has.
578
645
  case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; *) HAS_GIT=0 ;; esac
@@ -642,7 +709,7 @@ case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE 'rm +
642
709
  # named file is an everyday, recoverable act and gating it would make the rule noise. The option-skipping
643
710
  # prefix is git_has's, so `git -C <path>` and `git -c k=v` cannot walk around it and a commit MESSAGE
644
711
  # containing the word "checkout" does not trip it; both are pinned as cases.
645
- [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qE '(^|[^A-Za-z0-9_-])git[[:space:]]+((-[Cc][[:space:]]+[^[:space:];&|]+|--(git-dir|work-tree|namespace|config-env|super-prefix|exec-path)[[:space:]=]+[^[:space:];&|]+|-[^[:space:];&|]+)[[:space:]]+)*(checkout|restore)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+(\.|\*|\./|:/)([[:space:]]|[;&|]|$)' && block "whole-tree revert (git checkout/restore over everything)" "4.5" history
712
+ [ "$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
646
713
  case "$CMD" in *[Mm][Kk][Ff][Ss]*|*[Dd][Dd]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(^|[^a-zA-Z])(mkfs|dd +if=)' && block "disk-level destructive command" "4.5" loss
647
714
 
648
715
  # §4.5 remote-code-execution & permission-nuke -> HARD BLOCK. A downloaded script piped straight into a shell
@@ -700,14 +767,19 @@ _IAC_DESTROY="(terraform|tofu|pulumi)([[:space:]]+-[^;&|]*)?[[:space:]]+(destroy
700
767
  _IAC_UNATT_TF="(terraform|tofu)[^;&|]*[[:space:]](apply|destroy)([^;&|]*[[:space:]])?-{1,2}auto-approve([^a-zA-Z0-9_=-]|$)"
701
768
  _IAC_UNATT_PU="pulumi[^;&|]*[[:space:]]up([^;&|]*[[:space:]])?(-y|--yes|-f|--skip-preview)([^a-zA-Z0-9_-]|$)"
702
769
  _IAC_CLUSTER="(kubectl[^;&|]*[[:space:]]delete([^a-zA-Z0-9_-]|$)|helm[^;&|]*[[:space:]](uninstall|delete|del|un)([^a-zA-Z0-9_-]|$))"
770
+ # The SAFE marker (--help, -h, --dry-run, …) exempts only the segment it is in — see _seg_any.
771
+ # A verb at a command position OR behind a shell executor — the two arms of _iac, as one ERE per rule.
772
+ _IAC_P_DESTROY="(${_IAC_AT}${_IAC_DESTROY})|(${_IAC_EXEC}${_IAC_DESTROY})"
773
+ _IAC_P_UNATT="(${_IAC_AT}${_IAC_UNATT_TF})|(${_IAC_EXEC}${_IAC_UNATT_TF})|(${_IAC_AT}${_IAC_UNATT_PU})|(${_IAC_EXEC}${_IAC_UNATT_PU})"
774
+ _IAC_P_CLUSTER="(${_IAC_AT}${_IAC_CLUSTER})|(${_IAC_EXEC}${_IAC_CLUSTER})"
703
775
  case "$CMD" in *[Tt][Ee][Rr][Rr][Aa][Ff][Oo][Rr][Mm]*|*[Tt][Oo][Ff][Uu]*|*[Pp][Uu][Ll][Uu][Mm][Ii]*) : ;; *) false ;; esac \
704
- && ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && _iac "$_IAC_DESTROY" \
776
+ && { { ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && _iac "$_IAC_DESTROY"; } || _seg_any "$_IAC_P_DESTROY" "$_IAC_SAFE" '*'; } \
705
777
  && block "infrastructure destroy (removes every managed resource)" "4.5" loss
706
778
  case "$CMD" in *[Tt][Ee][Rr][Rr][Aa][Ff][Oo][Rr][Mm]*|*[Tt][Oo][Ff][Uu]*|*[Pp][Uu][Ll][Uu][Mm][Ii]*) : ;; *) false ;; esac \
707
- && ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && { _iac "$_IAC_UNATT_TF" || _iac "$_IAC_UNATT_PU"; } \
779
+ && { { ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && { _iac "$_IAC_UNATT_TF" || _iac "$_IAC_UNATT_PU"; }; } || _seg_any "$_IAC_P_UNATT" "$_IAC_SAFE" '*'; } \
708
780
  && block "unattended infrastructure apply (skips the tool's only confirmation)" "4.5" loss
709
781
  case "$CMD" in *[Kk][Uu][Bb][Ee][Cc][Tt][Ll]*|*[Hh][Ee][Ll][Mm]*) : ;; *) false ;; esac \
710
- && ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && _iac "$_IAC_CLUSTER" \
782
+ && { { ! echo "$CMD" | grep -qiE "$_IAC_SAFE" && _iac "$_IAC_CLUSTER"; } || _seg_any "$_IAC_P_CLUSTER" "$_IAC_SAFE" '*'; } \
711
783
  && block "cluster teardown (kubectl delete / helm uninstall)" "4.5" loss
712
784
  # The rule is WORLD-WRITABLE, so the pattern matches the resulting permission and not one spelling of it. It
713
785
  # used to match `777`, `0777`, `a+rwx` and `+rwx` only, which let `1777`, `2777`, `666` and `o+w` reach exactly
@@ -751,20 +823,109 @@ case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && echo "$CMD"
751
823
  # or deleting/overwriting/patching the hook scripts, would disarm the trace/secret/approval gates in one line.
752
824
  # READING the setting is not disarming it. `git config --get core.hooksPath` is how a person (or the doctor)
753
825
  # CHECKS that the gate is armed, and blocking it told them Crewforth was tampering-proof by refusing to let them
754
- # verify it. Only the write forms disarm: a bare `git config core.hooksPath <value>`, `--unset`, `--replace-all`.
755
- [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qE 'git[[:space:]]+config\b[^|]*core\.hooksPath' \
756
- && ! echo "$CMD" | grep -qE 'git[[:space:]]+config\b[^|]*(--get(-all|-regexp|-urlmatch)?|--list)([[:space:]]|$)' \
757
- && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
826
+ # verify it. Only the write forms disarm: a value, `--unset`, `--unset-all`, `--add`, `--replace-all`, `--edit`, or
827
+ # the `set` / `unset` subcommands of newer git. A read needs no `--get`: `git config core.hooksPath` with no value,
828
+ # scoped or not (`--local` / `--global` / `--system`), prints the setting and changes nothing — it used to be
829
+ # refused as tampering.
830
+ # EACH `git config … core.hooksPath` in the command is judged on its own, and a read has to PROVE it is one: the
831
+ # segment is exactly `git [-C dir] config [scope/format flags] [read verb …] core.hooksPath`, git first, nothing
832
+ # after the key but spaces or tabs. Proven reads are cut out of the command; what is left goes to the old rule —
833
+ # `git … config … core.hooksPath` anywhere is a write — widened, not narrowed. Nothing unproven is ever skipped.
834
+ # Why each piece: the old rule exempted the WHOLE command when `--get`/`--list` appeared anywhere, so `git config
835
+ # --get core.hooksPath && git config core.hooksPath x` disarmed the hooks. Two review rounds then broke the reader
836
+ # itself — a read verb as another flag's value (`--comment get`), a value from xargs or an alias, a CR / form-feed
837
+ # the payload reader drops (`[[:space:]]` calls them blank; the shell does not), and segments the reader could not
838
+ # parse being skipped (`git config"" core.hooksPath x`, `config${IFS}core.hooksPath`, a quoted `;` inside a flag).
839
+ # Quotes and backslashes are removed before the old rule looks (`core.hooks"P"ath` is core.hooksPath to git); the
840
+ # key is case-insensitive to git; a backslash-newline joins; removing the [core] section takes hooksPath with it.
841
+ _HP_B=$' \t' # blank = space or TAB only; a literal `\t` inside [ ] is a backslash and a t to ERE
842
+ _HP_OPT="(--local|--global|--system|--worktree|--show-origin|--show-scope|--includes|--no-includes|-z|--null|--name-only|--bool|--int|--path|--bool-or-int|--expiry-date|--all|--type=[A-Za-z-]+|--file=[^${_HP_B}]+|--blob=[^${_HP_B}]+|(--file|-f|--blob|--type)[${_HP_B}]+[^${_HP_B}]+)"
843
+ _HP_PRE="^[${_HP_B}]*git([${_HP_B}]+(-C[${_HP_B}]+[^${_HP_B}]+|--git-dir=[^${_HP_B}]+|--work-tree=[^${_HP_B}]+))*[${_HP_B}]+config([${_HP_B}]+${_HP_OPT})*[${_HP_B}]+"
844
+ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core]
845
+ local bsnl=$'\\\n' c="$1" t seg r n=0 nc=0
846
+ local bare="${_HP_PRE}core\\.hooksPath[${_HP_B}]*\$"
847
+ local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
848
+ local wr="[${_HP_B}](--unset|--unset-all|--add|--replace-all|--edit|-e|set|unset|--rename-section|--remove-section|rename-section|remove-section)([${_HP_B}]|\$)"
849
+ local -a reads=()
850
+ case "$c" in *"$bsnl"*) c="${c//"$bsnl"/ }" ;; esac # quoted: in a ${//} pattern a bare backslash escapes the newline
851
+ t=${c//\"/}; t=${t//\'/}; t=${t//\\/} # unquoted, one class each: inside "…" a [\"\'\\] class never closes
852
+ case "$t" in *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*|*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*) ;; *) return 1 ;; esac # after unquoting: hooks"P"ath
853
+ shopt -q nocasematch && nc=1; shopt -s nocasematch
854
+ # The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
855
+ # would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
856
+ # the raw payload the decoded text is not the command that will run, and no read is proven.
857
+ case "$INPUT" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
858
+ _split_segs "$c"
859
+ for seg in ${_SPL[@]+"${_SPL[@]}"}; do
860
+ [[ $seg == *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]* ]] || continue
861
+ n=$((n+1)); [ "$n" -gt 64 ] && { reads=(); break; } # past 64, no read is argued: the old rule judges it all
862
+ # A write flag anywhere in the segment disproves the read (`--list --unset core.hooksPath`): git 2.54 refuses
863
+ # those pairs, but the gate does not lean on another tool's option parser.
864
+ [[ $seg =~ $wr ]] && continue
865
+ { [[ $seg =~ $bare ]] || [[ $seg =~ $verb ]]; } && reads[${#reads[@]}]="$seg"
866
+ done ;;
867
+ esac
868
+ [ "$nc" = 0 ] && shopt -u nocasematch
869
+ # Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
870
+ for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
871
+ t=${c//\"/}; t=${t//\'/}; t=${t//\\/} # unquoted, one class each: inside "…" a [\"\'\\] class never closes
872
+ _ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && return 0
873
+ _ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?core([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0
874
+ return 1
875
+ }
758
876
  # Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
759
877
  # that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
760
- [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qiE 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
878
+ [ "$HAS_GIT" = 1 ] && _ere i "$CMD" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
879
+ [ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
761
880
  # A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
762
881
  # in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
763
882
  # indirected redirect (VAR=.claude/hooks; … > $VAR). Reading a gate file stays allowed, and `chmod +x` is NOT
764
883
  # blocked so doctor's re-arm fix still works (a chmod -x disable is caught by doctor, not here). Honest scope:
765
884
  # the shell is Turing-complete, so this is defence-in-depth — guard-write.sh covers the Write/Edit tools (the
766
885
  # model's natural path to a file), and install-time read-only hook files would be the airtight layer.
767
- GATE='\.(claude/(hooks|settings\.json|DISCIPLINE\.md)|git/hooks)'
886
+ GATE='\.(claude/(hooks|settings\.json|DISCIPLINE\.md|eval/lib/crew-env\.sh)|git/hooks)'
887
+ # eval/lib/crew-env.sh is on the list because the gates SOURCE it on every call (guard-bash, guard-write, the board
888
+ # hooks): a file a gate executes is part of the gate. Measured before it was added: overwrite it with `exit 0` and
889
+ # `rm -rf /` passed guard-bash with rc 0, in both editions (3.0.1 review).
890
+ #
891
+ # THE PLUGIN EDITION'S GATE FILES. There the gate scripts and their wiring live under the plugin root, not under
892
+ # .claude/, and none of the rules below matched them: `rm <plugin>/hooks/guard-bash.sh` returned rc 0 while
893
+ # `rm .claude/hooks/guard-bash.sh` returned rc 2 (measured, 3.0.1 review). Same rule for both: anything under
894
+ # <root>/hooks/ (the scripts, hooks.json, the blocklists, the git hooks) and <root>/.claude-plugin/ (the manifest), and
895
+ # the sourced crew-env.sh. The root comes from CLAUDE_PLUGIN_ROOT, which the harness exports to plugin hooks; the file
896
+ # install has none, and its gates are the .claude/ ones above.
897
+ # MATCHED ON THE PART EVERY SPELLING KEEPS. A first version matched the whole absolute root, and review walked straight
898
+ # past it: `~/…`, `$HOME/…`, `/Users/*/…`, `//`, `/./`, `R/../3.0.1/…` and a quoted version folder all spell the same
899
+ # file without spelling that prefix (each measured: rc 0, file gone). In Claude Code's plugin cache
900
+ # (…/plugins/cache/<marketplace>/<plugin>/<version>) the root is recognised by its <marketplace>/<plugin> tail, with
901
+ # any version after it — so every cached version of this plugin is covered as well. A root outside the cache (a
902
+ # `--plugin-dir` checkout) has no such tail and is matched as a whole path, with either slash and with or without its
903
+ # drive (`C:\…`, `C:/…`, `/c/…`).
904
+ _PGATE=""; _PLNK=""
905
+ if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
906
+ _pgesc(){ local o="$1" m # $1 = text -> _PGE: ERE, metacharacters escaped, `/` as either slash
907
+ for m in '.' '[' ']' '(' ')' '*' '+' '?' '{' '}' '|' '^' '$'; do o="${o//"$m"/\\$m}"; done
908
+ _PGE="${o//\//[/\\\\]}"; }
909
+ _pr="${CLAUDE_PLUGIN_ROOT//\\//}"; _pr="${_pr%/}"
910
+ case "$_pr" in [A-Za-z]:/*) _pr="${_pr:2}" ;; esac
911
+ _S='[/\\]+'; _Q="[\"']?"; _GF="(hooks|\\.claude-plugin|eval${_S}lib${_S}crew-env\\.sh)"; _END="([/\\\\\"'[:space:];&|)]|\$)"
912
+ case "$_pr" in
913
+ */[Pp][Ll][Uu][Gg][Ii][Nn][Ss]/[Cc][Aa][Cc][Hh][Ee]/*/*/*)
914
+ _pn="${_pr%/*}"; _pmk="${_pn%/*}"; _pmk="${_pmk##*/}"; _pn="${_pn##*/}"
915
+ _pgesc "$_pmk"; _pmk="$_PGE"; _pgesc "$_pn"; _pn="$_PGE"
916
+ _PT="${_Q}${_pmk}${_Q}${_S}(\\.${_S})*${_Q}${_pn}${_Q}" # <marketplace>/<plugin>
917
+ _PGATE="(^|[^A-Za-z0-9_.-])${_PT}${_S}[^;&|[:space:]]*[/\\\\]${_Q}${_GF}${_END}"
918
+ # A link whose target ENDS at the tail, the version or a gate folder puts the rest of the path out of sight.
919
+ _PLNK="[^;&|[:space:]]*(^|[^A-Za-z0-9_.-])${_Q}${_pmk}${_Q}(${_S}(\\.${_S})*${_Q}${_pn}${_Q}(${_S}[^;&|[:space:]/\\\\]+(${_S}${_Q}${_GF})?)?)?[/\\\\]*${_Q}" ;;
920
+ *)
921
+ if [ "${#_pr}" -gt 1 ]; then
922
+ _pgesc "$_pr"
923
+ _PGATE="(^|[[:space:]\"'=]|[A-Za-z]:|(^|[[:space:]\"'=])[/\\\\][A-Za-z])${_PGE}[/\\\\]+${_Q}${_GF}${_END}" # at a token start, after a drive, or after Git Bash's /c
924
+ _PLNK="[^;&|[:space:]]*${_PGE}([/\\\\]+${_Q}${_GF})?[/\\\\]*${_Q}"
925
+ fi ;;
926
+ esac
927
+ [ -n "$_PGATE" ] && GATE="(${GATE}|${_PGATE})"
928
+ fi
768
929
  # DISCIPLINE.md joined the list because it IS the text of §4.1-§4.5: the gates enforce those rules, so a
769
930
  # writable rulebook means the rules can be emptied without touching a single gate. It needs no change to
770
931
  # the prefilter below — the file only ever lives under `.claude/`, which already carries the word `claude`.
@@ -798,7 +959,9 @@ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false
798
959
  # argument (`.claude`, `../.claude`, `/p/.git`), so linking to something inside the tree — `ln -s
799
960
  # .claude/skills c` — is untouched here and handled at write time instead. `.git` needs its own prefilter:
800
961
  # the one above only knows `claude` and `hooks`.
801
- case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE '(^|[;&|[:space:]])(ln|mklink)[^;&|]*[[:space:]]([^;&|[:space:]]*/)?\.(claude|git)([[:space:]]|$)' && block "symlink pointing at the config directory (a gate path in two steps)" "4.5" tamper
962
+ # The plugin edition's root is the same kind of target: `ln -s <root> cfg`, then `cfg/hooks/guard-bash.sh`.
963
+ _LNT='([^;&|[:space:]]*/)?\.(claude|git)'; [ -n "$_PLNK" ] && _LNT="(${_LNT}|${_PLNK})"
964
+ case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac && echo "$CMD" | grep -qiE "(^|[;&|[:space:]])(ln|mklink)[^;&|]*[[:space:]]${_LNT}([[:space:]]|\$)" && block "symlink pointing at the config directory (a gate path in two steps)" "4.5" tamper
802
965
 
803
966
  # §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
804
967
  # `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
@@ -813,12 +976,14 @@ case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;
813
976
  # The three patterns live in variables because the SAME rule is applied twice: once to the command below, and
814
977
  # once to each line of a script the command runs (the two-step rule further down). Written out twice they drift
815
978
  # -- the direct one gains a reader verb, the indirect one silently keeps letting it through.
816
- ENV_READ_RE='(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|sort|uniq|cp|scp|rsync|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*([^;&|[:space:]]*/)?\.env(\.[A-Za-z0-9_-]+)?([[:space:]]|$|[;&|>])'
817
- ENV_REDIR_RE='<[[:space:]]*([^;&|[:space:]]*/)?\.env(\.[A-Za-z0-9_-]+)?([[:space:]]|$|[;&|])'
979
+ ENV_READ_RE='(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|sort|uniq|cp|scp|rsync|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*([^;&|[:space:]]*/)?\.env(\.[A-Za-z0-9_-]+)?([[:space:]]|$|[;&|>)`])'
980
+ ENV_REDIR_RE='<[[:space:]]*([^;&|[:space:]]*/)?\.env(\.[A-Za-z0-9_-]+)?([[:space:]]|$|[;&|)`])'
818
981
  ENV_TEMPLATE_RE='\.env\.(example|sample|template|dist)([^A-Za-z0-9_-]|$)'
819
- { case "$CMD" in *[Ee][Nn][Vv]*) : ;; *) false ;; esac && { has "$ENV_READ_RE" \
820
- || has "$ENV_REDIR_RE"; } \
821
- && ! has "$ENV_TEMPLATE_RE"; } \
982
+ # `)` and a backtick end a path too: `(cat .env)`, `$(cat .env)` and `` `cat .env` `` read the file, and all three
983
+ # passed while the bare `cat .env` was blocked (found by the chained-exemption scan).
984
+ # A template name exempts only its own segment: `cat .env.example; cat .env` reads the secret.
985
+ { case "$CMD" in *[Ee][Nn][Vv]*) : ;; *) false ;; esac \
986
+ && { { { has "$ENV_READ_RE" || has "$ENV_REDIR_RE"; } && ! has "$ENV_TEMPLATE_RE"; } || _seg_any "($ENV_READ_RE)|($ENV_REDIR_RE)" "$ENV_TEMPLATE_RE" '*[Ee][Nn][Vv]*'; }; } \
822
987
  && block "reading a .env secret via the Bash tool" "4.5" secret
823
988
 
824
989
  # The same reasoning, one scope wider. `.env` was the only credential file either gate covered, which left the
@@ -833,14 +998,18 @@ CRED='(\.ssh/(id_[A-Za-z0-9_]+|identity)|(^|/)id_(rsa|dsa|ecdsa|ed25519)|\.aws/c
833
998
  # it mirrors. Add a branch to CRED, add a glob here; §7's own credential cases (id_rsa, .aws/credentials,
834
999
  # .netrc, .kube/config, .git-credentials, server.pem, and the .pub/.example must-not-block twins) are what
835
1000
  # would catch a forgotten one.
1001
+ # A .pub/.example name exempts only its own segment: `cat ~/.ssh/id_rsa.pub; cat ~/.ssh/id_rsa` reads the key.
1002
+ _CRED_READ="(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|cp|scp|rsync|curl|wget|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*[^;&|[:space:]]*$CRED"
1003
+ _CRED_REDIR="<[[:space:]]*[^;&|[:space:]]*$CRED"
1004
+ _CRED_SAFE='(\.pub|\.example|\.sample|\.template)([^A-Za-z0-9_-]|$)'
836
1005
  { case "$CMD" in \
837
1006
  *[Ii][Dd]_*|*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy]*|*[Ss][Ss][Hh]*|*[Cc][Rr][Ee][Dd][Ee][Nn][Tt][Ii][Aa][Ll]*\
838
1007
  |*[Nn][Ee][Tt][Rr][Cc]*|*[Dd][Oo][Cc][Kk][Ee][Rr]*|*[Nn][Pp][Mm][Rr][Cc]*|*[Pp][Yy][Pp][Ii][Rr][Cc]*\
839
1008
  |*[Kk][Uu][Bb][Ee]*|*[Pp][Ee][Mm]*|*[Pp]12*|*[Pp][Ff][Xx]*|*[Kk][Ee][Yy][Ss][Tt][Oo][Rr][Ee]*|*[Jj][Kk][Ss]*\
840
1009
  |*[Ss][Ee][Rr][Vv][Ii][Cc][Ee]-[Aa][Cc][Cc][Oo][Uu][Nn][Tt]*) : ;; *) false ;; esac \
841
- && { has "(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|cp|scp|rsync|curl|wget|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*[^;&|[:space:]]*$CRED" \
1010
+ && { { { has "(^|[^A-Za-z0-9_/.-])(cat|less|more|head|tail|tac|nl|xxd|od|strings|hexdump|base64|cp|scp|rsync|curl|wget|get-content|gc|type|get-item|gi)[[:space:]]+(-[^;&|[:space:]]*[[:space:]]+)*[^;&|[:space:]]*$CRED" \
842
1011
  || has "<[[:space:]]*[^;&|[:space:]]*$CRED"; } \
843
- && ! has '(\.pub|\.example|\.sample|\.template)([^A-Za-z0-9_-]|$)'; } \
1012
+ && ! has '(\.pub|\.example|\.sample|\.template)([^A-Za-z0-9_-]|$)'; } || _seg_any "($_CRED_READ)|($_CRED_REDIR)" "$_CRED_SAFE" '*'; }; } \
844
1013
  && block "reading a private key / credential file via the Bash tool" "4.5" secret
845
1014
 
846
1015
  # §4.5-adjacent, THE SECOND STEP. Everything above scans the COMMAND; none of it sees what a script FILE does.
@@ -981,7 +1150,12 @@ if [ "$_looks_exec" = 1 ]; then
981
1150
  fi
982
1151
  [ -n "$_path" ] || continue
983
1152
  _interp=0
984
- if grep -iE -- "$ENV_READ_RE|$ENV_REDIR_RE" "$_path" 2>/dev/null | grep -qivE -- "$ENV_TEMPLATE_RE"; then
1153
+ # Per segment, not per line: a script line `cat .env.example; cat .env` reads the secret (see _seg_any).
1154
+ # 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
+ # is read from the output, not the pipeline status: `grep -q` closing early SIGPIPEs the stage before it, and a
1156
+ # 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 | grep -aiE -- "$ENV_READ_RE|$ENV_REDIR_RE" | grep -aivE -m1 -- "$ENV_TEMPLATE_RE")"
1158
+ if [ -n "$_envhit" ]; then
985
1159
  set +f
986
1160
  block "running a script that reads a .env secret (the two-step read)" "4.5" secret
987
1161
  fi
@@ -1036,7 +1210,7 @@ fi
1036
1210
  # Windows machine, `git update-index --add --chmod=+x deploy/rolling-update.sh` staged the file with nothing
1037
1211
  # said. (Staging itself is deliberately NOT gated here — only commit and push ask — so this rule is about the
1038
1212
  # gitignore bypass alone, not about stopping people from staging files.)
1039
- [ "$HAS_GIT" = 1 ] && echo "$CMD" | grep -qE 'git[[:space:]]+([^;&|]*[[:space:]])?update-index\b[^;&|]*(--add|--force-remove)' \
1213
+ [ "$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]*' \
1040
1214
  && block "git update-index --add (bypasses .gitignore, same as git add -f)" "4.5" bypass
1041
1215
  case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && echo "$CMD" | grep -qE '(rm|git[[:space:]]+rm)\b[^|]*(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Gemfile\.lock|poetry\.lock|Pipfile\.lock|Cargo\.lock|composer\.lock|go\.sum|packages\.lock\.json)' && block "lockfile deletion" "4.5" loss
1042
1216
 
@@ -4,8 +4,8 @@
4
4
  # §4.1/§4.2 (no AI trace, no vendor name) and the secret scan are enforced by `pre-commit` and `commit-msg`,
5
5
  # which git runs via `core.hooksPath`. The PLUGIN edition cannot set that — a plugin ships Claude Code hooks,
6
6
  # not git hooks — so a plugin-only install had the approval gate but none of the CONTENT gates: the model could
7
- # commit a credential or an authorship trailer and nothing would look at it. Four distribution channels, one of
8
- # them quietly weaker than the other three.
7
+ # commit a credential or an authorship trailer and nothing would look at it. Three distribution channels, one of
8
+ # them quietly weaker than the other two.
9
9
  #
10
10
  # This closes that by running the REAL scanners from PreToolUse, before the commit command executes. It does
11
11
  # not re-implement them. A second matcher is how a gate passes while the thing it guards is broken — the same