crewforth 3.0.0 → 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.
- package/CHANGELOG.md +93 -0
- package/VERSION +1 -1
- package/adopt.sh +186 -19
- package/kit/CLAUDE.md +5 -4
- package/kit/README.md +1 -1
- package/kit/agents/crew-frontend-expert.md +6 -6
- package/kit/agents/crew-session-manager.md +1 -1
- package/kit/eval/doctor.sh +72 -18
- package/kit/eval/golden-routing.txt +15 -0
- package/kit/eval/preflight.sh +2 -2
- package/kit/eval/smoke-test.sh +625 -29
- package/kit/hooks/board-sync.sh +1 -1
- package/kit/hooks/board.sh +4 -3
- package/kit/hooks/guard-bash.sh +195 -21
- package/kit/hooks/guard-commit-scan.sh +2 -2
- package/kit/hooks/guard-powershell.sh +328 -0
- package/kit/hooks/guard-write.sh +28 -3
- package/kit/hooks/route-hint.sh +5 -1
- package/kit/hooks/session-update-check.sh +2 -2
- package/kit/hooks/skill-trust.sh +22 -11
- package/kit/legacy-blobs.tsv +208 -0
- package/kit/settings.json +10 -0
- package/kit/skills/automode-policy/SKILL.md +1 -1
- package/kit/skills/automode-policy/scripts/check.sh +2 -2
- package/kit/skills/crew-board/SKILL.md +1 -1
- package/kit/skills/crew-doctor/SKILL.md +3 -3
- package/kit/skills/crew-gates/SKILL.md +2 -1
- package/kit/skills/crew-handoff/SKILL.md +1 -1
- package/kit/skills/crew-skill/SKILL.md +2 -2
- package/kit/skills/crew-studio/SKILL.md +3 -2
- package/kit/skills/crew-update/SKILL.md +3 -3
- package/kit/skills/frontend/SKILL.md +26 -2
- package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
- package/kit/skills/handoff/SKILL.md +2 -2
- package/kit/skills/reflect/SKILL.md +2 -2
- package/kit/skills/teamboard/SKILL.md +1 -1
- package/kit/skills/token-budget/SKILL.md +2 -2
- package/kit/skills/trace-scan/SKILL.md +4 -3
- package/kit/studio/README.md +1 -1
- package/package.json +5 -4
- package/start.sh +15 -8
package/kit/hooks/board-sync.sh
CHANGED
|
@@ -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,
|
package/kit/hooks/board.sh
CHANGED
|
@@ -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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -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
|
-
|
|
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 ] &&
|
|
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
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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 ] &&
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
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 ] &&
|
|
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.
|
|
8
|
-
# them quietly weaker than the other
|
|
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
|