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
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
|
|
@@ -49,16 +49,65 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
|
49
49
|
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
50
50
|
export LC_ALL=C
|
|
51
51
|
# ---- /CREW-LOCALE
|
|
52
|
+
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
53
|
+
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
54
|
+
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
55
|
+
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
56
|
+
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
57
|
+
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
58
|
+
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
59
|
+
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
60
|
+
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
61
|
+
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
62
|
+
# that is not found (status 127, the script continues inside the same command).
|
|
63
|
+
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
64
|
+
_crew_stop(){ # $1 = what happened
|
|
65
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
66
|
+
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
|
|
67
|
+
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
|
|
68
|
+
exit 2
|
|
69
|
+
}
|
|
70
|
+
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
71
|
+
# ---- /CREW-FAILCLOSED
|
|
72
|
+
_gate_main(){
|
|
52
73
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
53
74
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
54
75
|
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
|
|
55
|
-
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
|
|
56
79
|
|
|
57
80
|
# The two helpers below are a byte-identical copy of guard-bash.sh's block. A shared file would have to be
|
|
58
81
|
# added to build-plugin.sh's explicit copy list and a miss there breaks the plugin channel silently — the same
|
|
59
82
|
# reasoning as the CREW-TRANSCRIPT-DIR resolver, which is duplicated for the same reason. Two copies are only
|
|
60
83
|
# safe while they cannot drift, so smoke-test pins these markers byte-identical rather than trusting it.
|
|
61
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
|
+
}
|
|
62
111
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
63
112
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
64
113
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -82,8 +131,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
82
131
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
83
132
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
84
133
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
85
|
-
#
|
|
86
|
-
# 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
|
|
87
135
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
88
136
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
89
137
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -107,7 +155,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
107
155
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
108
156
|
# the battery sees both.
|
|
109
157
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
110
|
-
# 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.
|
|
111
159
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
112
160
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
113
161
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -123,14 +171,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
123
171
|
# raising or removing it later is not a tidy-up.
|
|
124
172
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
125
173
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
126
|
-
# 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
|
|
127
175
|
# predates this change and belongs to the walk, not to the cap.
|
|
128
176
|
local _cap=64 _seen=0
|
|
129
177
|
while :; do
|
|
130
|
-
|
|
131
|
-
[ "$
|
|
178
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
179
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
132
180
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
133
|
-
rest="${hay
|
|
181
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
134
182
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
135
183
|
case "$rest" in
|
|
136
184
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -170,7 +218,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
170
218
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
171
219
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
172
220
|
#
|
|
173
|
-
# 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
|
|
174
222
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
175
223
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
176
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
|
|
@@ -222,7 +270,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
222
270
|
# the gate cases.
|
|
223
271
|
local LC_ALL=C
|
|
224
272
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
225
|
-
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
|
|
226
274
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
227
275
|
while :; do
|
|
228
276
|
lim=$((cl - j))
|
|
@@ -252,12 +300,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
252
300
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
253
301
|
case "$h" in
|
|
254
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+=("―") ;;
|
|
255
306
|
*) acc+=("?") ;;
|
|
256
307
|
esac ;;
|
|
257
308
|
*) acc+=("$c") ;;
|
|
258
309
|
esac
|
|
259
310
|
done
|
|
260
|
-
|
|
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"
|
|
261
314
|
}
|
|
262
315
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
263
316
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -290,16 +343,16 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
290
343
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
291
344
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
292
345
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
293
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
346
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
294
347
|
_KC=0; _KC_CAPPED=0
|
|
295
348
|
while :; do
|
|
296
|
-
|
|
297
|
-
[ "$
|
|
349
|
+
_json_find "$hay" "$k"
|
|
350
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
298
351
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
299
352
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
300
353
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
301
354
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
302
|
-
rest="${hay
|
|
355
|
+
rest="${hay:_JF+${#k}}"
|
|
303
356
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
304
357
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
305
358
|
hay="$rest"
|
|
@@ -334,13 +387,15 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
|
|
|
334
387
|
WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
|
|
335
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."
|
|
336
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."
|
|
337
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."
|
|
338
393
|
# Sized from the cost curve, not from PATH_MAX. Tier 3 walks the value character by character and bash string
|
|
339
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
|
|
340
395
|
# 2,048, ~30s at 4,096. Real paths are nowhere near: measured on a Windows install, actual `file_path` values
|
|
341
396
|
# average ~60 bytes and Windows itself stops at 260 without the long-path opt-in (macOS PATH_MAX is 1,024).
|
|
342
397
|
# 2,048 therefore keeps every path anyone can really produce and bounds the worst case at a few seconds,
|
|
343
|
-
# 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
|
|
344
399
|
# no exit 2 and the write proceeds.
|
|
345
400
|
MAXPATH=2048
|
|
346
401
|
|
|
@@ -378,14 +433,15 @@ fi
|
|
|
378
433
|
# Microsoft Store redirector stub named python3 on PATH by default, `command -v` finds it, it exits 49 with an
|
|
379
434
|
# empty stdout, FP came back "" and `[ -z "$FP" ] && exit 0` let the model rewrite .claude/hooks/guard-bash.sh
|
|
380
435
|
# with its Write tool. Pure parameter expansion cannot fail that way.
|
|
381
|
-
|
|
382
|
-
|
|
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"; }
|
|
383
439
|
# THE CAP GOES BEFORE THE UNESCAPER, and the cost it bounds is one the shared reader INTRODUCED — worth saying
|
|
384
440
|
# plainly rather than dressing up as a pre-existing bug. What it replaced was a single `sed`, which is linear
|
|
385
441
|
# and was never slow; it was replaced because it truncated the value at the first escaped quote and never
|
|
386
442
|
# looked at `notebook_path`. The parser that fixes those walks character by character, which is quadratic in
|
|
387
443
|
# bash, and on Windows every separator is a backslash, i.e. an escape, so the "no escapes" fast path never
|
|
388
|
-
# 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
|
|
389
445
|
# write proceeds. Refusing above the cap is safe in the direction that matters, and the cap sits far above any
|
|
390
446
|
# path a filesystem will accept.
|
|
391
447
|
# ONE cap, not two. The second check used to follow the unescaper because the jq tier reached the fold with no
|
|
@@ -393,7 +449,7 @@ _raw="$(_json_slice "$INPUT" file_path)"
|
|
|
393
449
|
# measured over 23 escape forms including a surrogate pair and a 4900-byte run of `€` across the chunk
|
|
394
450
|
# edge, with a deliberately-growing stand-in as the calibration, so the check could be seen to fail.
|
|
395
451
|
[ "${#_raw}" -le "$MAXPATH" ] || { FP="(oversized path: ${#_raw} bytes)"; block "gate-file edit (oversized path)" "$WHY_LONG"; }
|
|
396
|
-
|
|
452
|
+
_json_unescape "$_raw" >/dev/null; FP="$_JU"
|
|
397
453
|
|
|
398
454
|
# Nothing extractable. Exiting 0 unconditionally is what a future field rename turns into a silent bypass, so
|
|
399
455
|
# look at the RAW payload instead: refuse only when the text itself names a gate tree. A payload that mentions
|
|
@@ -401,7 +457,7 @@ FP="$(_json_unescape "$_raw")"
|
|
|
401
457
|
# block on a file whose own path says `.claude/…hooks`, which is the trade this gate exists to make.
|
|
402
458
|
if [ -z "$FP" ]; then
|
|
403
459
|
case "$INPUT" in
|
|
404
|
-
*.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" ;;
|
|
405
461
|
esac
|
|
406
462
|
exit 0
|
|
407
463
|
fi
|
|
@@ -468,6 +524,9 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
|
|
|
468
524
|
GATE_RULE="gate-file edit (Write/Edit tools)"; GATE_WHY="$WHY_SCRIPT"; return 0 ;;
|
|
469
525
|
*/.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*|.[Gg][Ii][Tt]/[Hh][Oo][Oo][Kk][Ss]/*)
|
|
470
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 ;;
|
|
471
530
|
# The gates SOURCE eval/lib/crew-env.sh on every call, so it is part of them: overwritten with `exit 0`, every
|
|
472
531
|
# rule stopped firing (3.0.1 review). Same file in the plugin edition, below.
|
|
473
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])
|
|
@@ -480,6 +539,20 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
|
|
|
480
539
|
# `hooks/` directory is untouched.
|
|
481
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])
|
|
482
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 ;;
|
|
483
556
|
esac
|
|
484
557
|
# ...and not only the gate scripts it knew by name: everything under the plugin root's hooks/ (hooks.json, the
|
|
485
558
|
# blocklists, the git hooks, every hook), .claude-plugin/ and the sourced crew-env.sh is the plugin's equivalent of
|
|
@@ -555,3 +628,6 @@ if [ -n "$GD" ] && [ -f "$GD/crew-board-guard" ]; then
|
|
|
555
628
|
esac
|
|
556
629
|
fi
|
|
557
630
|
exit 0
|
|
631
|
+
}
|
|
632
|
+
_gate_main "$@"
|
|
633
|
+
_crew_stop "a command of the gate was abandoned"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# no-bash-guard.ps1 - the gate that runs when no other gate can.
|
|
2
|
+
#
|
|
3
|
+
# Crewforth's gates are bash scripts. On Windows, when Claude Code finds no Git Bash, a hook that names bash fails
|
|
4
|
+
# with "requires bash but Git Bash was not found" and exit 1 - which does not block - and the Bash tool does not
|
|
5
|
+
# exist at all: PowerShell is the shell, and nothing guards it (measured in the field, 3.0.1-rc.2).
|
|
6
|
+
# One hook in settings.json names no shell. Claude Code runs it through bash where it finds one, and that branch
|
|
7
|
+
# exits 0 without starting a process; where it finds none it runs it through PowerShell, and that branch loads this
|
|
8
|
+
# file. So this code runs only in the state it exists for.
|
|
9
|
+
#
|
|
10
|
+
# It is read with Get-Content and run with Invoke-Expression, never as a script file: the default execution policy
|
|
11
|
+
# on a Windows client refuses to run .ps1 files, and a gate that the policy switches off is not a gate.
|
|
12
|
+
#
|
|
13
|
+
# The lookup is Claude Code's own, in its order (read from its 2.1.284 binary; eval/lib/git-bash.sh holds the same
|
|
14
|
+
# order for the doctor): CLAUDE_CODE_GIT_BASH_PATH when its name is bash/sh and the file exists, the two default
|
|
15
|
+
# Git folders, then <git on PATH>\..\..\bin\bash.exe. A bash found here means Claude Code ran this hook through
|
|
16
|
+
# PowerShell for another reason, and the bash gates work: exit 0.
|
|
17
|
+
#
|
|
18
|
+
# Windows PowerShell 5.1 and PowerShell 7. ASCII only: 5.1 reads a file without a BOM in the ANSI code page.
|
|
19
|
+
# Paths are joined with `/` and passed through GetFullPath: Windows prints them with `\`, and the same code can be
|
|
20
|
+
# exercised by pwsh on Linux and macOS, where the smoke test runs it.
|
|
21
|
+
$ErrorActionPreference = 'Stop'
|
|
22
|
+
|
|
23
|
+
function Test-CrewBash([string]$p) {
|
|
24
|
+
if (-not $p) { return $false }
|
|
25
|
+
$n = [IO.Path]::GetFileName($p).ToLowerInvariant()
|
|
26
|
+
if (@('bash.exe', 'sh.exe', 'bash', 'sh') -notcontains $n) { return $false }
|
|
27
|
+
return [IO.File]::Exists($p)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$found = $null
|
|
31
|
+
if (Test-CrewBash $env:CLAUDE_CODE_GIT_BASH_PATH) { $found = $env:CLAUDE_CODE_GIT_BASH_PATH }
|
|
32
|
+
if (-not $found) {
|
|
33
|
+
foreach ($c in @('C:\Program Files\Git\bin\bash.exe', 'C:\Program Files (x86)\Git\bin\bash.exe')) {
|
|
34
|
+
if ([IO.File]::Exists($c)) { $found = $c; break }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (-not $found) {
|
|
38
|
+
$g = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
39
|
+
if ($g) {
|
|
40
|
+
$d = Split-Path -Parent (Split-Path -Parent $g.Source)
|
|
41
|
+
if ($d) { $c = [IO.Path]::GetFullPath((Join-Path $d 'bin/bash.exe')); if ([IO.File]::Exists($c)) { $found = $c } }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if ($found) { exit 0 }
|
|
45
|
+
|
|
46
|
+
# Not found. Name the path to set when one exists on this machine - checked here, never guessed.
|
|
47
|
+
$fix = $null
|
|
48
|
+
$v = $env:CLAUDE_CODE_GIT_BASH_PATH
|
|
49
|
+
if ($v -and ([IO.Path]::GetFileName($v).ToLowerInvariant() -eq 'git-bash.exe')) {
|
|
50
|
+
$c = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $v) 'bin/bash.exe'))
|
|
51
|
+
if ([IO.File]::Exists($c)) { $fix = $c }
|
|
52
|
+
}
|
|
53
|
+
if (-not $fix -and $env:LOCALAPPDATA) {
|
|
54
|
+
$c = [IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'Programs/Git/bin/bash.exe'))
|
|
55
|
+
if ([IO.File]::Exists($c)) { $fix = $c }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
$m = "GUARD: Crewforth's gates cannot run here, so this tool call is stopped." + [Environment]::NewLine
|
|
59
|
+
$m += "Claude Code found no Git Bash. Every Crewforth gate is a bash script: none of them started, and the rules they enforce (commit and push approval, destructive commands, secrets, the gate files) are not being checked." + [Environment]::NewLine
|
|
60
|
+
if ($v -and -not (Test-CrewBash $v)) {
|
|
61
|
+
$m += "CLAUDE_CODE_GIT_BASH_PATH is set to '$v', which Claude Code does not accept: it must name bash.exe and exist." + [Environment]::NewLine
|
|
62
|
+
}
|
|
63
|
+
if ($fix) {
|
|
64
|
+
$m += "Tell the user to set the user environment variable CLAUDE_CODE_GIT_BASH_PATH to `"$fix`"" + [Environment]::NewLine
|
|
65
|
+
} else {
|
|
66
|
+
$m += "Tell the user to install Git for Windows in its default folder, or to set the user environment variable CLAUDE_CODE_GIT_BASH_PATH to <Git>\bin\bash.exe" + [Environment]::NewLine
|
|
67
|
+
}
|
|
68
|
+
$m += "and then to close the terminal and Claude Code and open them again (an open terminal keeps the old value). /crew-doctor confirms it." + [Environment]::NewLine
|
|
69
|
+
$m += "Reading and searching still work. Do not look for another way to write or run commands: there is no gate behind it."
|
|
70
|
+
[Console]::Error.WriteLine($m)
|
|
71
|
+
exit 2
|
package/kit/hooks/pre-commit
CHANGED
|
@@ -11,7 +11,7 @@ set -euo pipefail
|
|
|
11
11
|
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
12
12
|
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
13
13
|
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
14
|
-
# turns GIT into gıt. The suite, run whole under that locale, went from
|
|
14
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
15
15
|
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
16
16
|
# staged key all passed.
|
|
17
17
|
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
@@ -463,7 +463,10 @@ PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$
|
|
|
463
463
|
# Git Bash reports /c/Users/x for what Windows itself calls C:\Users\x — both spellings reach a file.
|
|
464
464
|
case "$HOME" in
|
|
465
465
|
/[a-zA-Z]/?*)
|
|
466
|
-
|
|
466
|
+
# The drive letter, upper-cased with a builtin: `$(printf … | tr a-z A-Z)` was a subshell and a process on
|
|
467
|
+
# every commit, on the platform where a process costs most (bash 3.2 has no ${x^^}).
|
|
468
|
+
d="${HOME:1:1}"; r="${HOME:3}"
|
|
469
|
+
case "$d" in [a-z]) _lc=abcdefghijklmnopqrstuvwxyz; _uc=ABCDEFGHIJKLMNOPQRSTUVWXYZ; _i="${_lc%%"$d"*}"; d="${_uc:${#_i}:1}" ;; esac
|
|
467
470
|
printf '%s\n' "$d:/$r" "$d:\\${r//\//\\}" ;;
|
|
468
471
|
esac
|
|
469
472
|
fi
|