crewforth 3.0.4 → 3.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +200 -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/smoke-test.sh +2572 -373
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1545 -104
- package/kit/hooks/guard-commit-scan.sh +75 -19
- package/kit/hooks/guard-powershell.sh +65 -15
- package/kit/hooks/guard-write.sh +74 -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 +124 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +81 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +100 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -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
|
|
@@ -126,9 +126,36 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
|
|
|
126
126
|
CMD="$_JL"
|
|
127
127
|
}
|
|
128
128
|
# ---- /CREW-JOIN
|
|
129
|
-
DIR
|
|
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.
|
|
130
131
|
|
|
131
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
|
+
}
|
|
132
159
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
133
160
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
134
161
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -152,8 +179,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
152
179
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
153
180
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
154
181
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
155
|
-
#
|
|
156
|
-
# 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
|
|
157
183
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
158
184
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
159
185
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -177,7 +203,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
177
203
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
178
204
|
# the battery sees both.
|
|
179
205
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
180
|
-
# 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.
|
|
181
207
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
182
208
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
183
209
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -193,14 +219,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
193
219
|
# raising or removing it later is not a tidy-up.
|
|
194
220
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
195
221
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
196
|
-
# 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
|
|
197
223
|
# predates this change and belongs to the walk, not to the cap.
|
|
198
224
|
local _cap=64 _seen=0
|
|
199
225
|
while :; do
|
|
200
|
-
|
|
201
|
-
[ "$
|
|
226
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
227
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
202
228
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
203
|
-
rest="${hay
|
|
229
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
204
230
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
205
231
|
case "$rest" in
|
|
206
232
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -240,7 +266,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
240
266
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
241
267
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
242
268
|
#
|
|
243
|
-
# 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
|
|
244
270
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
245
271
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
246
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
|
|
@@ -292,7 +318,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
292
318
|
# the gate cases.
|
|
293
319
|
local LC_ALL=C
|
|
294
320
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
295
|
-
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
|
|
296
322
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
297
323
|
while :; do
|
|
298
324
|
lim=$((cl - j))
|
|
@@ -322,12 +348,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
322
348
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
323
349
|
case "$h" in
|
|
324
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+=("―") ;;
|
|
325
354
|
*) acc+=("?") ;;
|
|
326
355
|
esac ;;
|
|
327
356
|
*) acc+=("$c") ;;
|
|
328
357
|
esac
|
|
329
358
|
done
|
|
330
|
-
|
|
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"
|
|
331
362
|
}
|
|
332
363
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
333
364
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -360,16 +391,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
360
391
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
361
392
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
362
393
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
363
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
394
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
364
395
|
_KC=0; _KC_CAPPED=0
|
|
365
396
|
while :; do
|
|
366
|
-
|
|
367
|
-
[ "$
|
|
397
|
+
_json_find "$hay" "$k"
|
|
398
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
368
399
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
369
400
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
370
401
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
371
402
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
372
|
-
rest="${hay
|
|
403
|
+
rest="${hay:_JF+${#k}}"
|
|
373
404
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
374
405
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
375
406
|
hay="$rest"
|
|
@@ -465,7 +496,29 @@ crew_opt_values() {
|
|
|
465
496
|
}
|
|
466
497
|
crew_msg_values() { crew_opt_values "$1" -m --message; }
|
|
467
498
|
|
|
468
|
-
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
|
|
469
522
|
# Same ladder as guard-bash.sh, and for the same reason: the raw-text fallback leaves JSON escapes in place,
|
|
470
523
|
# so `-m \"…\"` never matches a quote-based extraction and the message silently goes unscanned. That is
|
|
471
524
|
# precisely how the first version of this hook passed a commit carrying a co-author trailer.
|
|
@@ -503,7 +556,7 @@ fi
|
|
|
503
556
|
# The shared reader also replaced a `sed | head | sed` pipeline: four processes on EVERY Bash tool call, to
|
|
504
557
|
# re-derive a string guard-bash.sh had already parsed one hook earlier. Measured on `ls -la`: guard-bash.sh 2
|
|
505
558
|
# processes, this hook 7. With the ladder gone neither hook spawns a process to SELECT a reader.
|
|
506
|
-
|
|
559
|
+
_json_slice "$INPUT" command >/dev/null; _json_unescape "$_JS" >/dev/null; CMD="$_JU" # via _JS / _JU: no fork
|
|
507
560
|
_join_cmd # continued lines joined, as the shell runs them (CREW-JOIN)
|
|
508
561
|
[ -z "$CMD" ] && exit 0
|
|
509
562
|
|
|
@@ -515,7 +568,10 @@ case "$CMD" in *[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
|
|
|
515
568
|
|
|
516
569
|
# Only git commit. Matching mirrors guard-bash.sh's tolerance for `git -C dir commit`, TAB separators and a
|
|
517
570
|
# quoted binary, because a gate that a whitespace change walks past is not a gate.
|
|
518
|
-
|
|
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)"
|
|
519
575
|
|
|
520
576
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
521
577
|
|
|
@@ -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
|
|
@@ -62,9 +62,55 @@ _gate_main(){
|
|
|
62
62
|
# process costs 62-135 ms. With this, a command that cannot match (no `bash` in the payload) opens no process at all
|
|
63
63
|
# (measured with the xtrace counter: `cat` 1 -> 0). `read` returns 1 at end of input; the text is read regardless.
|
|
64
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
|
|
65
85
|
case "$INPUT" in *[Bb][Aa][Ss][Hh]*) ;; *) exit 0 ;; esac
|
|
66
86
|
|
|
67
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
|
+
}
|
|
68
114
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
69
115
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
70
116
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -88,8 +134,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
88
134
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
89
135
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
90
136
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
91
|
-
#
|
|
92
|
-
# 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
|
|
93
138
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
94
139
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
95
140
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -113,7 +158,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
113
158
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
114
159
|
# the battery sees both.
|
|
115
160
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
116
|
-
# 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.
|
|
117
162
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
118
163
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
119
164
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -129,14 +174,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
129
174
|
# raising or removing it later is not a tidy-up.
|
|
130
175
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
131
176
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
132
|
-
# 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
|
|
133
178
|
# predates this change and belongs to the walk, not to the cap.
|
|
134
179
|
local _cap=64 _seen=0
|
|
135
180
|
while :; do
|
|
136
|
-
|
|
137
|
-
[ "$
|
|
181
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
182
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
138
183
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
139
|
-
rest="${hay
|
|
184
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
140
185
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
141
186
|
case "$rest" in
|
|
142
187
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -176,7 +221,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
176
221
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
177
222
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
178
223
|
#
|
|
179
|
-
# 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
|
|
180
225
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
181
226
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
182
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
|
|
@@ -228,7 +273,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
228
273
|
# the gate cases.
|
|
229
274
|
local LC_ALL=C
|
|
230
275
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
231
|
-
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
|
|
232
277
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
233
278
|
while :; do
|
|
234
279
|
lim=$((cl - j))
|
|
@@ -258,12 +303,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
258
303
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
259
304
|
case "$h" in
|
|
260
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+=("―") ;;
|
|
261
309
|
*) acc+=("?") ;;
|
|
262
310
|
esac ;;
|
|
263
311
|
*) acc+=("$c") ;;
|
|
264
312
|
esac
|
|
265
313
|
done
|
|
266
|
-
|
|
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"
|
|
267
317
|
}
|
|
268
318
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
269
319
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -296,16 +346,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
296
346
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
297
347
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
298
348
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
299
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
349
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
300
350
|
_KC=0; _KC_CAPPED=0
|
|
301
351
|
while :; do
|
|
302
|
-
|
|
303
|
-
[ "$
|
|
352
|
+
_json_find "$hay" "$k"
|
|
353
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
304
354
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
305
355
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
306
356
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
307
357
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
308
|
-
rest="${hay
|
|
358
|
+
rest="${hay:_JF+${#k}}"
|
|
309
359
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
310
360
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
311
361
|
hay="$rest"
|
package/kit/hooks/guard-write.sh
CHANGED
|
@@ -36,7 +36,7 @@ set -uo pipefail
|
|
|
36
36
|
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
37
37
|
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
38
38
|
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
39
|
-
# turns GIT into gıt. The suite, run whole under that locale, went from
|
|
39
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
40
40
|
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
41
41
|
# staged key all passed.
|
|
42
42
|
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
@@ -73,13 +73,41 @@ _gate_main(){
|
|
|
73
73
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
74
74
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
75
75
|
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
|
|
76
|
-
INPUT="$(cat)"
|
|
76
|
+
# Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
|
|
77
|
+
# of this hook. `read` returns 1 at end of input; the text is read regardless.
|
|
78
|
+
IFS= read -r -d '' INPUT || true
|
|
77
79
|
|
|
78
80
|
# The two helpers below are a byte-identical copy of guard-bash.sh's block. A shared file would have to be
|
|
79
81
|
# added to build-plugin.sh's explicit copy list and a miss there breaks the plugin channel silently — the same
|
|
80
82
|
# reasoning as the CREW-TRANSCRIPT-DIR resolver, which is duplicated for the same reason. Two copies are only
|
|
81
83
|
# safe while they cannot drift, so smoke-test pins these markers byte-identical rather than trusting it.
|
|
82
84
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
85
|
+
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
86
|
+
# `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
|
|
87
|
+
# text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
|
|
88
|
+
# square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
|
|
89
|
+
# look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
|
|
90
|
+
# 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
|
|
91
|
+
# timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
|
|
92
|
+
# occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
|
|
93
|
+
# Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
|
|
94
|
+
# taken from the whole text, and the small pieces from the block.
|
|
95
|
+
local LC_ALL=C
|
|
96
|
+
local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
|
|
97
|
+
_JF=-1
|
|
98
|
+
while [ "$i" -lt "$n" ]; do
|
|
99
|
+
b="${1:i:B+kl-1}"
|
|
100
|
+
case "$b" in *"$2"*)
|
|
101
|
+
j=0; bn=${#b}
|
|
102
|
+
while [ "$j" -lt "$bn" ]; do
|
|
103
|
+
c="${b:j:C+kl-1}"
|
|
104
|
+
case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
|
|
105
|
+
j=$((j+C))
|
|
106
|
+
done ;;
|
|
107
|
+
esac
|
|
108
|
+
i=$((i+B))
|
|
109
|
+
done
|
|
110
|
+
}
|
|
83
111
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
84
112
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
85
113
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -103,8 +131,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
103
131
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
104
132
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
105
133
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
106
|
-
#
|
|
107
|
-
# earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
134
|
+
# _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
|
|
108
135
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
109
136
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
110
137
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -128,7 +155,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
128
155
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
129
156
|
# the battery sees both.
|
|
130
157
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
131
|
-
# a PreToolUse hook is a gate with an off switch, because a hook killed at its
|
|
158
|
+
# a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
|
|
132
159
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
133
160
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
134
161
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -144,14 +171,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
144
171
|
# raising or removing it later is not a tidy-up.
|
|
145
172
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
146
173
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
147
|
-
# both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
174
|
+
# 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
|
|
148
175
|
# predates this change and belongs to the walk, not to the cap.
|
|
149
176
|
local _cap=64 _seen=0
|
|
150
177
|
while :; do
|
|
151
|
-
|
|
152
|
-
[ "$
|
|
178
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
179
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
153
180
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
154
|
-
rest="${hay
|
|
181
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
155
182
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
156
183
|
case "$rest" in
|
|
157
184
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -191,7 +218,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
191
218
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
192
219
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
193
220
|
#
|
|
194
|
-
# That was not a comfort question. This hook's timeout
|
|
221
|
+
# 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
|
|
195
222
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
196
223
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
197
224
|
# the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
|
|
@@ -243,7 +270,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
243
270
|
# the gate cases.
|
|
244
271
|
local LC_ALL=C
|
|
245
272
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
246
|
-
case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
273
|
+
case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
247
274
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
248
275
|
while :; do
|
|
249
276
|
lim=$((cl - j))
|
|
@@ -273,12 +300,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
273
300
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
274
301
|
case "$h" in
|
|
275
302
|
00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
|
|
303
|
+
# The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
|
|
304
|
+
# `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
|
|
305
|
+
2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
|
|
276
306
|
*) acc+=("?") ;;
|
|
277
307
|
esac ;;
|
|
278
308
|
*) acc+=("$c") ;;
|
|
279
309
|
esac
|
|
280
310
|
done
|
|
281
|
-
|
|
311
|
+
# ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
|
|
312
|
+
# and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
|
|
313
|
+
local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
|
|
282
314
|
}
|
|
283
315
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
284
316
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -311,16 +343,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
311
343
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
312
344
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
313
345
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
314
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
346
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
315
347
|
_KC=0; _KC_CAPPED=0
|
|
316
348
|
while :; do
|
|
317
|
-
|
|
318
|
-
[ "$
|
|
349
|
+
_json_find "$hay" "$k"
|
|
350
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
319
351
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
320
352
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
321
353
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
322
354
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
323
|
-
rest="${hay
|
|
355
|
+
rest="${hay:_JF+${#k}}"
|
|
324
356
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
325
357
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
326
358
|
hay="$rest"
|
|
@@ -355,13 +387,15 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
|
|
|
355
387
|
WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
|
|
356
388
|
WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
|
|
357
389
|
WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
|
|
390
|
+
WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
|
|
391
|
+
WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
|
|
358
392
|
WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
|
|
359
393
|
# Sized from the cost curve, not from PATH_MAX. Tier 3 walks the value character by character and bash string
|
|
360
394
|
# append is O(n) each time, so the walk is quadratic: measured 0.09s at 512 raw bytes, 0.52s at 1,024, 3.7s at
|
|
361
395
|
# 2,048, ~30s at 4,096. Real paths are nowhere near: measured on a Windows install, actual `file_path` values
|
|
362
396
|
# average ~60 bytes and Windows itself stops at 260 without the long-path opt-in (macOS PATH_MAX is 1,024).
|
|
363
397
|
# 2,048 therefore keeps every path anyone can really produce and bounds the worst case at a few seconds,
|
|
364
|
-
# well inside this hook
|
|
398
|
+
# well inside the 60s timeout this hook had then — the number that mattered, because a hook killed at its timeout emits
|
|
365
399
|
# no exit 2 and the write proceeds.
|
|
366
400
|
MAXPATH=2048
|
|
367
401
|
|
|
@@ -399,14 +433,15 @@ fi
|
|
|
399
433
|
# Microsoft Store redirector stub named python3 on PATH by default, `command -v` finds it, it exits 49 with an
|
|
400
434
|
# empty stdout, FP came back "" and `[ -z "$FP" ] && exit 0` let the model rewrite .claude/hooks/guard-bash.sh
|
|
401
435
|
# with its Write tool. Pure parameter expansion cannot fail that way.
|
|
402
|
-
|
|
403
|
-
|
|
436
|
+
# Through `_JS` / `_JU`, not `$( )`: no fork on the path every Write and Edit takes (3.1.0).
|
|
437
|
+
_json_slice "$INPUT" file_path >/dev/null; _raw="$_JS"
|
|
438
|
+
[ -n "$_raw" ] || { _json_slice "$INPUT" notebook_path >/dev/null; _raw="$_JS"; }
|
|
404
439
|
# THE CAP GOES BEFORE THE UNESCAPER, and the cost it bounds is one the shared reader INTRODUCED — worth saying
|
|
405
440
|
# plainly rather than dressing up as a pre-existing bug. What it replaced was a single `sed`, which is linear
|
|
406
441
|
# and was never slow; it was replaced because it truncated the value at the first escaped quote and never
|
|
407
442
|
# looked at `notebook_path`. The parser that fixes those walks character by character, which is quadratic in
|
|
408
443
|
# bash, and on Windows every separator is a backslash, i.e. an escape, so the "no escapes" fast path never
|
|
409
|
-
# fires. Uncapped, that is a gate with an off switch: a hook killed at its
|
|
444
|
+
# fires. Uncapped, that is a gate with an off switch: a hook killed at its timeout emits no exit 2 and the
|
|
410
445
|
# write proceeds. Refusing above the cap is safe in the direction that matters, and the cap sits far above any
|
|
411
446
|
# path a filesystem will accept.
|
|
412
447
|
# ONE cap, not two. The second check used to follow the unescaper because the jq tier reached the fold with no
|
|
@@ -414,7 +449,7 @@ _raw="$(_json_slice "$INPUT" file_path)"
|
|
|
414
449
|
# measured over 23 escape forms including a surrogate pair and a 4900-byte run of `€` across the chunk
|
|
415
450
|
# edge, with a deliberately-growing stand-in as the calibration, so the check could be seen to fail.
|
|
416
451
|
[ "${#_raw}" -le "$MAXPATH" ] || { FP="(oversized path: ${#_raw} bytes)"; block "gate-file edit (oversized path)" "$WHY_LONG"; }
|
|
417
|
-
|
|
452
|
+
_json_unescape "$_raw" >/dev/null; FP="$_JU"
|
|
418
453
|
|
|
419
454
|
# Nothing extractable. Exiting 0 unconditionally is what a future field rename turns into a silent bypass, so
|
|
420
455
|
# look at the RAW payload instead: refuse only when the text itself names a gate tree. A payload that mentions
|
|
@@ -422,7 +457,7 @@ FP="$(_json_unescape "$_raw")"
|
|
|
422
457
|
# block on a file whose own path says `.claude/…hooks`, which is the trade this gate exists to make.
|
|
423
458
|
if [ -z "$FP" ]; then
|
|
424
459
|
case "$INPUT" in
|
|
425
|
-
*.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
|
|
460
|
+
*.claude*hooks*|*.git*hooks*|*DISCIPLINE.md*|*crewforth-approval*|*.git/config*|*.git\\config*|*git-shim*|*.gitconfig*|*.config/git/config*) FP="(unparsed payload naming a gate path)"; block "gate-file edit (unparsed payload)" "$WHY_SCRIPT" ;;
|
|
426
461
|
esac
|
|
427
462
|
exit 0
|
|
428
463
|
fi
|
|
@@ -489,6 +524,9 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
|
|
|
489
524
|
GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
|
|
490
525
|
*/.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*|.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*)
|
|
491
526
|
GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
|
|
527
|
+
# .claude/git-shim is where core.hooksPath points when Crewforth shares the hooks with a project's own chain.
|
|
528
|
+
*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]/*|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]/*|*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm])
|
|
529
|
+
GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
|
|
492
530
|
# The gates SOURCE eval/lib/crew-env.sh on every call, so it is part of them: overwritten with `exit 0`, every
|
|
493
531
|
# rule stopped firing (3.0.1 review). Same file in the plugin edition, below.
|
|
494
532
|
*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ee][Vv][Aa][Ll]/[Ll][Ii][Bb]/[Cc][Rr][Ee][Ww]-[Ee][Nn][Vv].[Ss][Hh]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ee][Vv][Aa][Ll]/[Ll][Ii][Bb]/[Cc][Rr][Ee][Ww]-[Ee][Nn][Vv].[Ss][Hh])
|
|
@@ -501,6 +539,20 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
|
|
|
501
539
|
# `hooks/` directory is untouched.
|
|
502
540
|
*/[Hh][Oo][Oo][Kk][Ss]/[Gg][Uu][Aa][Rr][Dd]-*.[Ss][Hh]|*/[Hh][Oo][Oo][Kk][Ss]/[Ss][Ee][Ss][Ss][Ii][Oo][Nn]-[Gg][Uu][Aa][Rr][Dd].[Ss][Hh])
|
|
503
541
|
GATE_RULE="gate-file edit (Crewforth gate script)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
|
|
542
|
+
# The record of the user's approval for a commit or a push (§4.4, hooks/prompt-approval.sh). It sits in the git
|
|
543
|
+
# directory and is written from the user's own message only; a session that writes it approves its own commit.
|
|
544
|
+
# Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
|
|
545
|
+
*/[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
|
|
546
|
+
GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
|
|
547
|
+
esac
|
|
548
|
+
# core.hooksPath lives in git's configuration files: the repository's (.git/config, with a linked worktree's and a
|
|
549
|
+
# submodule's own) and the user's (~/.gitconfig, ~/.config/git/config). Writing one switches the git hooks off without
|
|
550
|
+
# touching a hook. guard-bash.sh carries the same list for the shell. The path gets a slash in front, so one pattern
|
|
551
|
+
# serves both `<dir>/.git/config` and a bare `.git/config`; matched by name, not against $HOME — on Windows the
|
|
552
|
+
# payload says C:\Users\… where the shell says /c/Users/…, and two spellings of one path never compare equal.
|
|
553
|
+
case "/$1" in
|
|
554
|
+
*/.[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg].[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee]|*/.[Gg][Ii][Tt]/[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt]/[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg].[Ww][Oo][Rr][Kk][Tt][Rr][Ee][Ee]|*/.[Gg][Ii][Tt]/[Mm][Oo][Dd][Uu][Ll][Ee][Ss]/*/[Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Gg][Ii][Tt][Cc][Oo][Nn][Ff][Ii][Gg]|*/.[Cc][Oo][Nn][Ff][Ii][Gg]/[Gg][Ii][Tt]/[Cc][Oo][Nn][Ff][Ii][Gg])
|
|
555
|
+
GATE_RULE="git-config edit (Write/Edit tools)"; GATE_WHY="$WHY_GITCFG"; return 0 ;;
|
|
504
556
|
esac
|
|
505
557
|
# ...and not only the gate scripts it knew by name: everything under the plugin root's hooks/ (hooks.json, the
|
|
506
558
|
# blocklists, the git hooks, every hook), .claude-plugin/ and the sourced crew-env.sh is the plugin's equivalent of
|