crewforth 3.0.3 → 3.1.0-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 +211 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/routing-eval.sh +7 -3
- package/kit/eval/smoke-test.sh +2545 -214
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1608 -103
- package/kit/hooks/guard-commit-scan.sh +138 -19
- package/kit/hooks/guard-powershell.sh +89 -15
- package/kit/hooks/guard-write.sh +98 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
|
@@ -25,7 +25,7 @@ set -uo pipefail
|
|
|
25
25
|
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
26
26
|
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
27
27
|
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
28
|
-
# turns GIT into gıt. The suite, run whole under that locale, went from
|
|
28
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
29
29
|
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
30
30
|
# staged key all passed.
|
|
31
31
|
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
@@ -38,6 +38,27 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
|
38
38
|
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
39
39
|
export LC_ALL=C
|
|
40
40
|
# ---- /CREW-LOCALE
|
|
41
|
+
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
42
|
+
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
43
|
+
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
44
|
+
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
45
|
+
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
46
|
+
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
47
|
+
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
48
|
+
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
49
|
+
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
50
|
+
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
51
|
+
# that is not found (status 127, the script continues inside the same command).
|
|
52
|
+
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
53
|
+
_crew_stop(){ # $1 = what happened
|
|
54
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
55
|
+
echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
|
|
56
|
+
echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
|
|
57
|
+
exit 2
|
|
58
|
+
}
|
|
59
|
+
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
60
|
+
# ---- /CREW-FAILCLOSED
|
|
61
|
+
_gate_main(){
|
|
41
62
|
# ---- CREW-MATCH -------------------------------------------------------------------------------------------
|
|
42
63
|
# grep on the command, without a pipe and with its status read. Both halves were measured failing open:
|
|
43
64
|
# * `echo "$CMD" | grep -q …` under pipefail. grep -q leaves at its first match; when the command is larger than
|
|
@@ -67,9 +88,74 @@ _grep_out(){ # the same for a caller that wants what grep printed -> _GO (empty
|
|
|
67
88
|
_grep_stop "$rc"
|
|
68
89
|
}
|
|
69
90
|
# ---- /CREW-MATCH
|
|
70
|
-
|
|
91
|
+
# ---- CREW-JOIN --------------------------------------------------------------------------------------------
|
|
92
|
+
# A line that ends in a backslash continues on the next one: the shell takes `rm -f \<newline>.claude/hooks/x` as
|
|
93
|
+
# ONE command. The rules read the command line by line and word by word, so each half looked harmless (measured,
|
|
94
|
+
# rc 0: a gate file deleted, copied over, moved, edited in place, redirected over; `rm \<newline>-rf`;
|
|
95
|
+
# `git push \<newline>--force`; and `git \<newline>push`, `git \<newline>commit`, which reached neither the approval
|
|
96
|
+
# prompt nor the review gate). _join_cmd joins such lines in CMD before anything reads it, the way the shell does:
|
|
97
|
+
# * an ODD number of backslashes before the newline continues the line; an even number is backslashes, and the
|
|
98
|
+
# newline still ends the command.
|
|
99
|
+
# * PowerShell continues a line with a backtick, so a PowerShell call is joined on that.
|
|
100
|
+
# * a `#` at the start of a word MAY open a comment, and in a comment the backslash continues nothing: the next
|
|
101
|
+
# line is a command of its own. Telling a comment from a quoted `#` needs the whole quoting, so that line is
|
|
102
|
+
# read BOTH ways: joined, and the lines it would have swallowed once more as a command after it.
|
|
103
|
+
# Inside single quotes and in a quoted here-document the shell keeps the pair; joining it there changes only data.
|
|
104
|
+
# Byte-identical in every gate that reads a command; the suite pins it.
|
|
105
|
+
_join_lines(){ # $1 = command, $2 = the character that continues a line -> _JL
|
|
106
|
+
local s="$1" e="$2" nl=$'\n' line t body c out="" cur="" extra="" amb=0
|
|
107
|
+
_JL="$s"
|
|
108
|
+
case "$s" in *"$e$nl"*) ;; *) return 0 ;; esac
|
|
109
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
110
|
+
t="${line##*[!"$e"]}"; body="$line"; c=0
|
|
111
|
+
if [ $(( ${#t} % 2 )) = 1 ]; then body="${line%"$e"}"; c=1; fi
|
|
112
|
+
[ "$amb" = 1 ] && extra="$extra$body"
|
|
113
|
+
cur="$cur$body"
|
|
114
|
+
if [ "$c" = 1 ]; then
|
|
115
|
+
[ "$amb" = 1 ] || case "$cur" in '#'*|*[[:space:]\;\&\|\(]'#'*) amb=1 ;; esac
|
|
116
|
+
continue
|
|
117
|
+
fi
|
|
118
|
+
out="$out$cur$nl"; [ "$amb" = 1 ] && out="$out$extra$nl"
|
|
119
|
+
cur=""; extra=""; amb=0
|
|
120
|
+
done <<< "$s"
|
|
121
|
+
[ -n "$cur" ] && { out="$out$cur$e$nl"; [ "$amb" = 1 ] && out="$out$extra$nl"; } # the last line ended in one: nothing follows it
|
|
122
|
+
_JL="${out%"$nl"}"
|
|
123
|
+
}
|
|
124
|
+
_join_cmd(){ # CMD -> CMD with its continued lines joined
|
|
125
|
+
case "$INPUT" in *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*) _join_lines "$CMD" '`' ;; *) _join_lines "$CMD" '\' ;; esac
|
|
126
|
+
CMD="$_JL"
|
|
127
|
+
}
|
|
128
|
+
# ---- /CREW-JOIN
|
|
129
|
+
# DIR is resolved only once a `git commit` is in front of this hook (below): `$(cd "$(dirname "$0")" && pwd)` is two
|
|
130
|
+
# subshells and a process, and this hook runs before EVERY Bash call.
|
|
71
131
|
|
|
72
132
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
133
|
+
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
134
|
+
# `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
|
|
135
|
+
# text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
|
|
136
|
+
# square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
|
|
137
|
+
# look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
|
|
138
|
+
# counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
|
|
139
|
+
# timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
|
|
140
|
+
# occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
|
|
141
|
+
# Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
|
|
142
|
+
# taken from the whole text, and the small pieces from the block.
|
|
143
|
+
local LC_ALL=C
|
|
144
|
+
local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
|
|
145
|
+
_JF=-1
|
|
146
|
+
while [ "$i" -lt "$n" ]; do
|
|
147
|
+
b="${1:i:B+kl-1}"
|
|
148
|
+
case "$b" in *"$2"*)
|
|
149
|
+
j=0; bn=${#b}
|
|
150
|
+
while [ "$j" -lt "$bn" ]; do
|
|
151
|
+
c="${b:j:C+kl-1}"
|
|
152
|
+
case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
|
|
153
|
+
j=$((j+C))
|
|
154
|
+
done ;;
|
|
155
|
+
esac
|
|
156
|
+
i=$((i+B))
|
|
157
|
+
done
|
|
158
|
+
}
|
|
73
159
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
74
160
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
75
161
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -93,8 +179,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
93
179
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
94
180
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
95
181
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
96
|
-
#
|
|
97
|
-
# earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
182
|
+
# _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
98
183
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
99
184
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
100
185
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -118,7 +203,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
118
203
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
119
204
|
# the battery sees both.
|
|
120
205
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
121
|
-
# a PreToolUse hook is a gate with an off switch, because a hook killed at its
|
|
206
|
+
# a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
|
|
122
207
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
123
208
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
124
209
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -134,14 +219,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
134
219
|
# raising or removing it later is not a tidy-up.
|
|
135
220
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
136
221
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
137
|
-
# both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
222
|
+
# both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
138
223
|
# predates this change and belongs to the walk, not to the cap.
|
|
139
224
|
local _cap=64 _seen=0
|
|
140
225
|
while :; do
|
|
141
|
-
|
|
142
|
-
[ "$
|
|
226
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
227
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
143
228
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
144
|
-
rest="${hay
|
|
229
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
145
230
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
146
231
|
case "$rest" in
|
|
147
232
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -181,7 +266,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
181
266
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
182
267
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
183
268
|
#
|
|
184
|
-
# That was not a comfort question. This hook's timeout
|
|
269
|
+
# That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
|
|
185
270
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
186
271
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
187
272
|
# the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
|
|
@@ -233,7 +318,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
233
318
|
# the gate cases.
|
|
234
319
|
local LC_ALL=C
|
|
235
320
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
236
|
-
case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
321
|
+
case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
237
322
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
238
323
|
while :; do
|
|
239
324
|
lim=$((cl - j))
|
|
@@ -263,12 +348,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
263
348
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
264
349
|
case "$h" in
|
|
265
350
|
00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
|
|
351
|
+
# The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
|
|
352
|
+
# `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
|
|
353
|
+
2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
|
|
266
354
|
*) acc+=("?") ;;
|
|
267
355
|
esac ;;
|
|
268
356
|
*) acc+=("$c") ;;
|
|
269
357
|
esac
|
|
270
358
|
done
|
|
271
|
-
|
|
359
|
+
# ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
|
|
360
|
+
# and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
|
|
361
|
+
local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
|
|
272
362
|
}
|
|
273
363
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
274
364
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -301,16 +391,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
301
391
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
302
392
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
303
393
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
304
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
394
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
305
395
|
_KC=0; _KC_CAPPED=0
|
|
306
396
|
while :; do
|
|
307
|
-
|
|
308
|
-
[ "$
|
|
397
|
+
_json_find "$hay" "$k"
|
|
398
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
309
399
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
310
400
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
311
401
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
312
402
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
313
|
-
rest="${hay
|
|
403
|
+
rest="${hay:_JF+${#k}}"
|
|
314
404
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
315
405
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
316
406
|
hay="$rest"
|
|
@@ -406,7 +496,29 @@ crew_opt_values() {
|
|
|
406
496
|
}
|
|
407
497
|
crew_msg_values() { crew_opt_values "$1" -m --message; }
|
|
408
498
|
|
|
409
|
-
INPUT="$(cat)"
|
|
499
|
+
# Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
|
|
500
|
+
# of this hook. `read` returns 1 at end of input; the text is read regardless.
|
|
501
|
+
IFS= read -r -d '' INPUT || true
|
|
502
|
+
# ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
|
|
503
|
+
# A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
|
|
504
|
+
# its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
|
|
505
|
+
# on macOS for an escape-dense command: 256 KB 49 s, 512 KB 200 s, so about 900 KB is where the timeout is, and at
|
|
506
|
+
# that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
|
|
507
|
+
# The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
|
|
508
|
+
# large file is ordinary there — which is why the refusal points at them.
|
|
509
|
+
# Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
|
|
510
|
+
_PAYLOAD_MAX=262144
|
|
511
|
+
_payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
|
|
512
|
+
local LC_ALL=C
|
|
513
|
+
[ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
|
|
514
|
+
echo "GUARD (§4.5): this tool call is ${#INPUT} bytes long; the gates read a Bash or PowerShell call of up to $_PAYLOAD_MAX bytes." >&2
|
|
515
|
+
echo "A larger one could take longer to read than a hook is given, and a hook that runs out of time stops nothing," >&2
|
|
516
|
+
echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
|
|
517
|
+
echo "the path of that file." >&2
|
|
518
|
+
return 0
|
|
519
|
+
}
|
|
520
|
+
# ---- /CREW-PAYLOAD-MAX
|
|
521
|
+
_payload_over && exit 2
|
|
410
522
|
# Same ladder as guard-bash.sh, and for the same reason: the raw-text fallback leaves JSON escapes in place,
|
|
411
523
|
# so `-m \"…\"` never matches a quote-based extraction and the message silently goes unscanned. That is
|
|
412
524
|
# precisely how the first version of this hook passed a commit carrying a co-author trailer.
|
|
@@ -444,7 +556,8 @@ fi
|
|
|
444
556
|
# The shared reader also replaced a `sed | head | sed` pipeline: four processes on EVERY Bash tool call, to
|
|
445
557
|
# re-derive a string guard-bash.sh had already parsed one hook earlier. Measured on `ls -la`: guard-bash.sh 2
|
|
446
558
|
# processes, this hook 7. With the ladder gone neither hook spawns a process to SELECT a reader.
|
|
447
|
-
|
|
559
|
+
_json_slice "$INPUT" command >/dev/null; _json_unescape "$_JS" >/dev/null; CMD="$_JU" # via _JS / _JU: no fork
|
|
560
|
+
_join_cmd # continued lines joined, as the shell runs them (CREW-JOIN)
|
|
448
561
|
[ -z "$CMD" ] && exit 0
|
|
449
562
|
|
|
450
563
|
# A command that does not contain `git` at all cannot match the pattern below, and finding that out
|
|
@@ -455,7 +568,10 @@ case "$CMD" in *[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
|
|
|
455
568
|
|
|
456
569
|
# Only git commit. Matching mirrors guard-bash.sh's tolerance for `git -C dir commit`, TAB separators and a
|
|
457
570
|
# quoted binary, because a gate that a whitespace change walks past is not a gate.
|
|
458
|
-
|
|
571
|
+
# A builtin match: `printf | grep` was two processes on every command that merely mentions git (`git status`).
|
|
572
|
+
_GC_RE='(^|[;&|[:space:]])["'"'"'`]?git["'"'"'`]?([[:space:]]+-[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)'
|
|
573
|
+
[[ $CMD =~ $_GC_RE ]] || exit 0
|
|
574
|
+
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
459
575
|
|
|
460
576
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
461
577
|
|
|
@@ -557,3 +673,6 @@ if [ "$FAILED" = 1 ]; then
|
|
|
557
673
|
exit 2
|
|
558
674
|
fi
|
|
559
675
|
exit 0
|
|
676
|
+
}
|
|
677
|
+
_gate_main "$@"
|
|
678
|
+
_crew_stop "a command of the gate was abandoned"
|
|
@@ -24,7 +24,7 @@ set -uo pipefail
|
|
|
24
24
|
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
25
25
|
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
26
26
|
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
27
|
-
# turns GIT into gıt. The suite, run whole under that locale, went from
|
|
27
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
28
28
|
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
29
29
|
# staged key all passed.
|
|
30
30
|
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
@@ -37,13 +37,80 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
|
37
37
|
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
38
38
|
export LC_ALL=C
|
|
39
39
|
# ---- /CREW-LOCALE
|
|
40
|
+
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
41
|
+
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
42
|
+
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
43
|
+
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
44
|
+
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
45
|
+
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
46
|
+
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
47
|
+
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
48
|
+
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
49
|
+
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
50
|
+
# that is not found (status 127, the script continues inside the same command).
|
|
51
|
+
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
52
|
+
_crew_stop(){ # $1 = what happened
|
|
53
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
54
|
+
echo "GUARD (§4.5): this gate stopped on an error of its own ($1) before it finished judging the call, so the call is refused." >&2
|
|
55
|
+
echo "Nothing about the call itself was found. The error is the line above these two; it is a defect in Crewforth, not in what you ran. Run the doctor (bash .claude/eval/doctor.sh, Bash tool, not PowerShell) and report it." >&2
|
|
56
|
+
exit 2
|
|
57
|
+
}
|
|
58
|
+
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
59
|
+
# ---- /CREW-FAILCLOSED
|
|
60
|
+
_gate_main(){
|
|
40
61
|
# Read with the builtin: `INPUT="$(cat)"` is a subshell plus a process on EVERY PowerShell call, and on Git Bash a
|
|
41
62
|
# process costs 62-135 ms. With this, a command that cannot match (no `bash` in the payload) opens no process at all
|
|
42
63
|
# (measured with the xtrace counter: `cat` 1 -> 0). `read` returns 1 at end of input; the text is read regardless.
|
|
43
64
|
IFS= read -r -d '' INPUT || true
|
|
65
|
+
# ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
|
|
66
|
+
# A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
|
|
67
|
+
# its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
|
|
68
|
+
# on macOS for an escape-dense command: 256 KB 49 s, 512 KB 200 s, so about 900 KB is where the timeout is, and at
|
|
69
|
+
# that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
|
|
70
|
+
# The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
|
|
71
|
+
# large file is ordinary there — which is why the refusal points at them.
|
|
72
|
+
# Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
|
|
73
|
+
_PAYLOAD_MAX=262144
|
|
74
|
+
_payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
|
|
75
|
+
local LC_ALL=C
|
|
76
|
+
[ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
|
|
77
|
+
echo "GUARD (§4.5): this tool call is ${#INPUT} bytes long; the gates read a Bash or PowerShell call of up to $_PAYLOAD_MAX bytes." >&2
|
|
78
|
+
echo "A larger one could take longer to read than a hook is given, and a hook that runs out of time stops nothing," >&2
|
|
79
|
+
echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
|
|
80
|
+
echo "the path of that file." >&2
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
# ---- /CREW-PAYLOAD-MAX
|
|
84
|
+
_payload_over && exit 2
|
|
44
85
|
case "$INPUT" in *[Bb][Aa][Ss][Hh]*) ;; *) exit 0 ;; esac
|
|
45
86
|
|
|
46
87
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
88
|
+
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
89
|
+
# `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
|
|
90
|
+
# text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
|
|
91
|
+
# square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
|
|
92
|
+
# look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
|
|
93
|
+
# counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
|
|
94
|
+
# timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
|
|
95
|
+
# occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
|
|
96
|
+
# Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
|
|
97
|
+
# taken from the whole text, and the small pieces from the block.
|
|
98
|
+
local LC_ALL=C
|
|
99
|
+
local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
|
|
100
|
+
_JF=-1
|
|
101
|
+
while [ "$i" -lt "$n" ]; do
|
|
102
|
+
b="${1:i:B+kl-1}"
|
|
103
|
+
case "$b" in *"$2"*)
|
|
104
|
+
j=0; bn=${#b}
|
|
105
|
+
while [ "$j" -lt "$bn" ]; do
|
|
106
|
+
c="${b:j:C+kl-1}"
|
|
107
|
+
case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
|
|
108
|
+
j=$((j+C))
|
|
109
|
+
done ;;
|
|
110
|
+
esac
|
|
111
|
+
i=$((i+B))
|
|
112
|
+
done
|
|
113
|
+
}
|
|
47
114
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
48
115
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
49
116
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -67,8 +134,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
67
134
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
68
135
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
69
136
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
70
|
-
#
|
|
71
|
-
# earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
137
|
+
# _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
72
138
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
73
139
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
74
140
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -92,7 +158,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
92
158
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
93
159
|
# the battery sees both.
|
|
94
160
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
95
|
-
# a PreToolUse hook is a gate with an off switch, because a hook killed at its
|
|
161
|
+
# a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
|
|
96
162
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
97
163
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
98
164
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -108,14 +174,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
108
174
|
# raising or removing it later is not a tidy-up.
|
|
109
175
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
110
176
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
111
|
-
# both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
177
|
+
# both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
112
178
|
# predates this change and belongs to the walk, not to the cap.
|
|
113
179
|
local _cap=64 _seen=0
|
|
114
180
|
while :; do
|
|
115
|
-
|
|
116
|
-
[ "$
|
|
181
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
182
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
117
183
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
118
|
-
rest="${hay
|
|
184
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
119
185
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
120
186
|
case "$rest" in
|
|
121
187
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -155,7 +221,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
155
221
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
156
222
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
157
223
|
#
|
|
158
|
-
# That was not a comfort question. This hook's timeout
|
|
224
|
+
# That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
|
|
159
225
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
160
226
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
161
227
|
# the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
|
|
@@ -207,7 +273,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
207
273
|
# the gate cases.
|
|
208
274
|
local LC_ALL=C
|
|
209
275
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
210
|
-
case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
276
|
+
case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
211
277
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
212
278
|
while :; do
|
|
213
279
|
lim=$((cl - j))
|
|
@@ -237,12 +303,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
237
303
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
238
304
|
case "$h" in
|
|
239
305
|
00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
|
|
306
|
+
# The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
|
|
307
|
+
# `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
|
|
308
|
+
2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
|
|
240
309
|
*) acc+=("?") ;;
|
|
241
310
|
esac ;;
|
|
242
311
|
*) acc+=("$c") ;;
|
|
243
312
|
esac
|
|
244
313
|
done
|
|
245
|
-
|
|
314
|
+
# ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
|
|
315
|
+
# and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
|
|
316
|
+
local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
|
|
246
317
|
}
|
|
247
318
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
248
319
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -275,16 +346,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
275
346
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
276
347
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
277
348
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
278
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
349
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
279
350
|
_KC=0; _KC_CAPPED=0
|
|
280
351
|
while :; do
|
|
281
|
-
|
|
282
|
-
[ "$
|
|
352
|
+
_json_find "$hay" "$k"
|
|
353
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
283
354
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
284
355
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
285
356
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
286
357
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
287
|
-
rest="${hay
|
|
358
|
+
rest="${hay:_JF+${#k}}"
|
|
288
359
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
289
360
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
290
361
|
hay="$rest"
|
|
@@ -345,3 +416,6 @@ while [ "$_i" -lt 20 ] && [[ $_c =~ $_re ]]; do _i=$((_i+1))
|
|
|
345
416
|
exit 2
|
|
346
417
|
done
|
|
347
418
|
exit 0
|
|
419
|
+
}
|
|
420
|
+
_gate_main "$@"
|
|
421
|
+
_crew_stop "a command of the gate was abandoned"
|