crewforth 3.1.0-rc.4 → 3.1.0-rc.6
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 +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# guard-agent-model.sh - a crew agent is called with a task card and a model, and the model fits the card's risk.
|
|
3
|
+
#
|
|
4
|
+
# WHY. The model for a piece of work is chosen per call, by the risk of the work (the table in CLAUDE.md). 3.1.0's
|
|
5
|
+
# first form of this gate looked at the AGENT only, so database work under payments/ ran on Sonnet and passed. The
|
|
6
|
+
# risk is in the work: which files, what kind of change, and whether a command can say it is right. So a call to a
|
|
7
|
+
# crew-* agent opens with a card,
|
|
8
|
+
# files: the files or globs it will touch
|
|
9
|
+
# change: text | feature | fix-known | fix-unknown | refactor | migration | security | architecture |
|
|
10
|
+
# test-run | test-write | audit
|
|
11
|
+
# verify: the command that says the work is right, or `none`
|
|
12
|
+
# and this gate reads the card. What it holds, all of it by refusing and never by rewriting a call:
|
|
13
|
+
# * the call names a model, and carries a card;
|
|
14
|
+
# * RISK CLASS. `critical` when the change is migration, security, architecture or fix-unknown, or a file is on a
|
|
15
|
+
# critical path (auth, payments, billing, migrations, security, crypto, secrets, *.sql, schema.prisma;
|
|
16
|
+
# .claude/crew-model-rules adds and removes paths). Otherwise `normal`.
|
|
17
|
+
# * critical work runs on opus, whatever the agent. The one exception is test-run: running the tests is objective.
|
|
18
|
+
# * the referee is as strong as the risk: test-write and audit on sonnet or above (opus when critical, by the rule
|
|
19
|
+
# above); review, privacy, planner and database agents on sonnet or above; the security agent on opus.
|
|
20
|
+
# * work with no verify command runs on sonnet or above: haiku goes only to work a command can check.
|
|
21
|
+
# * a class that failed too often is held one model up (crew-model-floors.auto, written by hooks/agent-outcome.sh),
|
|
22
|
+
# until the user lowers it again with /crew-loosen (one model, once for each raise).
|
|
23
|
+
# * a card whose verify failed is not run again on the same or a lower model, and not a third time at all.
|
|
24
|
+
# * critical work with a verify command runs in the foreground, where its result reaches the session.
|
|
25
|
+
# THE WORDING IS READ BY ANOTHER PROGRAM (the Studio panel): a refusal is one line that begins
|
|
26
|
+
# `GUARD (agent model):`, and a floor is said as ` runs on <model> or above`. The suite pins both.
|
|
27
|
+
#
|
|
28
|
+
# ALSO A LIBRARY. hooks/guard-write.sh sources this file for the check at write time (an agent that is not on opus
|
|
29
|
+
# does not write to a critical path, whatever its card said), and hooks/agent-outcome.sh for the card, the class and
|
|
30
|
+
# the state. Sourced, the file defines its functions and returns before it reads anything.
|
|
31
|
+
#
|
|
32
|
+
# NOT TOUCHED: an agent that is not crew-* (Explore, general-purpose, a project's own), and any tool but Agent.
|
|
33
|
+
# SWITCHES, read from the session's environment:
|
|
34
|
+
# CREW_MODEL_ROUTING=off nothing here does anything, which is how it was before 3.1.0.
|
|
35
|
+
# CREW_ALLOW_FABLE=1 `fable` may be named; without it a call that names it is refused.
|
|
36
|
+
# HONEST SCOPE: the card is written by the caller and can be wrong. The write-time check and the verify command are
|
|
37
|
+
# what bound that; neither reads intent.
|
|
38
|
+
set -uo pipefail
|
|
39
|
+
# ---- CREW-LOCALE -----------------------------------------------------------------------------------------
|
|
40
|
+
# Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
|
|
41
|
+
# the letters i and I are not each other's other case (their partners are İ and ı), and every case-insensitive
|
|
42
|
+
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
43
|
+
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
44
|
+
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
45
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
46
|
+
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
47
|
+
# staged key all passed.
|
|
48
|
+
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
49
|
+
# scan for private strings read that as "no match".
|
|
50
|
+
# macOS (BSD grep, bash 3.2): none of it; tr_TR.UTF-8 folds i and I the ASCII way there.
|
|
51
|
+
# The cost: a letter outside ASCII has no other case in the C locale. The two git hooks that scan a user's own
|
|
52
|
+
# words look a second time under the session's locale for a pattern that holds such a letter (_CREW_LOCALE).
|
|
53
|
+
# Byte-identical in every gate; the suite pins it.
|
|
54
|
+
_CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
55
|
+
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
56
|
+
export LC_ALL=C
|
|
57
|
+
# ---- /CREW-LOCALE
|
|
58
|
+
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
59
|
+
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
60
|
+
# `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
|
|
61
|
+
# text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
|
|
62
|
+
# square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
|
|
63
|
+
# look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
|
|
64
|
+
# 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
|
|
65
|
+
# timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
|
|
66
|
+
# occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
|
|
67
|
+
# Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
|
|
68
|
+
# taken from the whole text, and the small pieces from the block.
|
|
69
|
+
local LC_ALL=C
|
|
70
|
+
local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
|
|
71
|
+
_JF=-1
|
|
72
|
+
while [ "$i" -lt "$n" ]; do
|
|
73
|
+
b="${1:i:B+kl-1}"
|
|
74
|
+
case "$b" in *"$2"*)
|
|
75
|
+
j=0; bn=${#b}
|
|
76
|
+
while [ "$j" -lt "$bn" ]; do
|
|
77
|
+
c="${b:j:C+kl-1}"
|
|
78
|
+
case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
|
|
79
|
+
j=$((j+C))
|
|
80
|
+
done ;;
|
|
81
|
+
esac
|
|
82
|
+
i=$((i+B))
|
|
83
|
+
done
|
|
84
|
+
}
|
|
85
|
+
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
86
|
+
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
87
|
+
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
88
|
+
# them, the two never disagree about a unit. Walking bytes is safe because no UTF-8
|
|
89
|
+
# continuation byte can be 0x5C or 0x22, so a cut cannot land inside a character at a
|
|
90
|
+
# quote or backslash edge. What this line is worth in SPEED depends on the platform --
|
|
91
|
+
# read the table in _json_unescape below before quoting a number for it.
|
|
92
|
+
local pre rest seg w r n base=0 j=0 cl lim run=0 chunk C=4096 W=256 hay="$1" k="\"$2\""; local -a acc=("")
|
|
93
|
+
# It ALSO sets `_JS`, so a caller on the hot path can read the value without `$( )`, which is a fork. The
|
|
94
|
+
# printf stays because the existing callers compose it (`_json_unescape "$(_json_slice …)"`), but the one
|
|
95
|
+
# caller that used to do its own fork-free extraction — `_CWD` — reads `_JS` and keeps costing nothing.
|
|
96
|
+
_JS=""
|
|
97
|
+
# Every "step past X" here is arithmetic on a length, never `${s#"$literal"}`. That shape reads like a
|
|
98
|
+
# constant-time strip and is not one: bash retries the pattern at every prefix length, so stripping an
|
|
99
|
+
# n-byte literal costs O(n^2). It was in this function twice, and both were measured:
|
|
100
|
+
# * `${1#*"$2"}` found the key -- cheap when the key sits near the front, quadratic in the distance to it
|
|
101
|
+
# otherwise. `permission_mode` behind a 100 KB command took 7.94s on Git Bash, 0.27s in front of it.
|
|
102
|
+
# Captured from Claude Code 2.1.267, the real order puts `permission_mode` BEFORE `tool_input`, so on
|
|
103
|
+
# that version this cost was not reachable -- this file's header had shown the opposite order, and was
|
|
104
|
+
# wrong. The parse stays order-independent regardless: the order is not a documented contract, and the
|
|
105
|
+
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
106
|
+
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
107
|
+
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
108
|
+
# _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
|
|
109
|
+
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
110
|
+
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
111
|
+
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
112
|
+
# {"tool_name":"Bash","a":"command","ls":1,"tool_input":{"command":"rm -rf /"}} -> read `ls`
|
|
113
|
+
# {"a":"permission_mode","default":1,"permission_mode":"bypassPermissions",…} -> read `default`
|
|
114
|
+
# {"a":"file_path","/tmp/ok":1,"tool_input":{"file_path":".claude/hooks/x.sh"}} -> read `/tmp/ok`
|
|
115
|
+
# In each case the gate judged a harmless string while a dangerous one was the actual argument, and jq read
|
|
116
|
+
# the real value -- so on a stock Windows desktop, where this was already the only parser, the gate could be
|
|
117
|
+
# aimed with one extra key. The loop below therefore accepts an occurrence only when optional JSON whitespace
|
|
118
|
+
# and then `:` follow it, which is the only place a key can appear; everything else keeps searching.
|
|
119
|
+
# Stepping by length removed the quadratic strip, but each escaped quote still sliced and re-assigned the
|
|
120
|
+
# whole remainder, so an escape-dense command stayed k*n here as well (2.2s of the 6.8s described in
|
|
121
|
+
# _json_unescape below, on Git Bash). The walk is chunked the same way: the payload is read 4096 bytes at a
|
|
122
|
+
# time and every per-quote operation stays inside the chunk. One thing is carried across edges on purpose
|
|
123
|
+
# -- the length of the backslash run in front of a quote -- because that run can straddle a window or a
|
|
124
|
+
# chunk, and its parity is what decides whether the quote ends the value. Isolated, escape-dense 44030 B:
|
|
125
|
+
# 2.61s -> 0.41s on Git Bash 5.3.15, 0.93s -> 0.09s on macOS/bash 3.2.
|
|
126
|
+
# Output is byte-identical to the previous shape across 378 cases at four chunk/window sizes down to 7 and 1
|
|
127
|
+
# bytes, and that shape to the one before it across a 27-case battery (escaped quotes, backslash runs before
|
|
128
|
+
# a quote, the key twice, the key appearing first as a value, glob metacharacters, UTF-8, no closing quote,
|
|
129
|
+
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
130
|
+
# the battery sees both.
|
|
131
|
+
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
132
|
+
# a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
|
|
133
|
+
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
134
|
+
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
135
|
+
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
136
|
+
# 200 decoys 3344 B 19 ms 3200 decoys 56544 B 1839 ms
|
|
137
|
+
# Git Bash's parameter expansion is several times slower again, so the tail is where the timeout lives. A
|
|
138
|
+
# real payload carries ZERO decoys -- 64 is far above anything a producer can legitimately emit -- and going
|
|
139
|
+
# over the cap is reported SEPARATELY (`_KC_CAPPED`), not as a large count, so the caller can refuse with a
|
|
140
|
+
# reason the reader can act on instead of a sentinel dressed up as a measurement. Fail-closed either way.
|
|
141
|
+
# THE COST IS ONE THIS CHANGE INTRODUCES, and it is worth saying so plainly rather than implying the cap
|
|
142
|
+
# protects something pre-existing: before the colon requirement the scan STOPPED at the first occurrence, so
|
|
143
|
+
# decoys cost essentially nothing (measured on stock Windows: 0 decoys 0.18 ms, 3200 decoys 1.60 ms per call,
|
|
144
|
+
# roughly linear) -- and it read the decoy, which is the hole. The cap is load-bearing for the new cost, so
|
|
145
|
+
# raising or removing it later is not a tidy-up.
|
|
146
|
+
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
147
|
+
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
148
|
+
# 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
|
|
149
|
+
# predates this change and belongs to the walk, not to the cap.
|
|
150
|
+
local _cap=64 _seen=0
|
|
151
|
+
while :; do
|
|
152
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
153
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
154
|
+
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
155
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
156
|
+
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
157
|
+
case "$rest" in
|
|
158
|
+
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
159
|
+
*) hay="$rest" ;; # a value, or another token: keep looking
|
|
160
|
+
esac
|
|
161
|
+
done
|
|
162
|
+
# THE VALUE MUST BE A STRING. `"command":123}}` used to come back as the literal text `:123}}` -- the old
|
|
163
|
+
# shape skipped forward to the next quote wherever it was, so a non-string value handed the matchers the
|
|
164
|
+
# payload's own punctuation to judge. Emitting nothing instead lets the caller's "no readable command"
|
|
165
|
+
# refusal fire, which is the honest answer for a value these gates cannot read.
|
|
166
|
+
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
167
|
+
case "$rest" in '"'*) rest="${rest:1}" ;; *) return 0 ;; esac
|
|
168
|
+
# Walk to the closing quote that is NOT escaped. A `"` preceded by an odd number of backslashes is content.
|
|
169
|
+
n=${#rest}; chunk="${rest:0:C}"; cl=${#chunk}
|
|
170
|
+
while :; do
|
|
171
|
+
lim=$((cl - j))
|
|
172
|
+
if [ "$lim" -le 0 ]; then
|
|
173
|
+
[ $((base + cl)) -lt "$n" ] || break # no closing quote at all: everything is already taken
|
|
174
|
+
base=$((base + j)); chunk="${rest:base:C}"; cl=${#chunk}; j=0; continue
|
|
175
|
+
fi
|
|
176
|
+
[ "$lim" -le "$W" ] || lim=$W
|
|
177
|
+
w="${chunk:j:lim}"
|
|
178
|
+
seg="${w%%\"*}"
|
|
179
|
+
if [ "$seg" = "$w" ]; then # no quote in view: take all of it, carry the run
|
|
180
|
+
acc+=("$w"); j=$((j+lim))
|
|
181
|
+
r="${w##*[!\\]}"; case "$w" in *[!\\]*) run=${#r} ;; *) run=$((run+${#w})) ;; esac
|
|
182
|
+
continue
|
|
183
|
+
fi
|
|
184
|
+
acc+=("$seg"); j=$((j+${#seg}+1))
|
|
185
|
+
r="${seg##*[!\\]}"; case "$seg" in *[!\\]*) run=${#r} ;; *) run=$((run+${#seg})) ;; esac
|
|
186
|
+
if [ $((run % 2)) -eq 1 ]; then acc+=("\""); run=0; else break; fi
|
|
187
|
+
done
|
|
188
|
+
local IFS=''; _JS="${acc[*]}"; printf '%s' "$_JS"
|
|
189
|
+
}
|
|
190
|
+
_json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corrupt `\\"` (escaped backslash + quote)
|
|
191
|
+
# This is the tier-3 path below -- the one a stock Windows install actually runs on. It used to walk ONE
|
|
192
|
+
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
193
|
+
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
194
|
+
#
|
|
195
|
+
# 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
|
|
196
|
+
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
197
|
+
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
198
|
+
# the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
|
|
199
|
+
# not exotic: across 6791 Bash calls in 280 real transcripts, 2.49% of commands are bigger, and the largest
|
|
200
|
+
# is 46815 B, which the old shape needed roughly 39 minutes to decode. One Bash call in forty walked past
|
|
201
|
+
# §4.4 and §4.5 entirely, silently, on every stock Windows desktop.
|
|
202
|
+
#
|
|
203
|
+
# Two changes, and their wins are NOT the same shape -- writing them down as one number was the mistake
|
|
204
|
+
# this comment exists to avoid repeating:
|
|
205
|
+
# * Taking the whole run up to the next backslash in ONE expansion, and indexing with `${s:0:1}` instead
|
|
206
|
+
# of matching a pattern, is 47x on a 4.4 KB payload. This one holds on every machine.
|
|
207
|
+
# * `local LC_ALL=C` makes these expansions byte-oriented instead of re-decoding the string on every
|
|
208
|
+
# substring operation. What that is WORTH depends on the platform, and putting a single number here
|
|
209
|
+
# would have been wrong three separate ways -- it was written as "another 5x" twice before this:
|
|
210
|
+
# macOS / bash 3.2, a locale set .................. 5x
|
|
211
|
+
# Git Bash 5.3.15, a locale set ................... 1.23x (measured on the OLD shape; the new one
|
|
212
|
+
# touches the locale once per escape
|
|
213
|
+
# rather than once per character, so its
|
|
214
|
+
# ratio is smaller and unmeasured)
|
|
215
|
+
# Git Bash, LANG empty -- what Claude Code starts .. nothing at all
|
|
216
|
+
# So speed is not what keeps this line; CORRECTNESS is, and that half holds everywhere. `[0-9a-fA-F]`
|
|
217
|
+
# below is a collation-defined range outside the C locale: on a tr_TR desktop it is not 0-9a-f. That
|
|
218
|
+
# alone would justify the line, and it costs nothing. `local` restores the previous locale on return
|
|
219
|
+
# -- verified on bash 3.2 and on Git Bash 5.3.15, not assumed.
|
|
220
|
+
#
|
|
221
|
+
# Then the cost moved rather than went away. With the per-character walk gone, each escape still sliced the
|
|
222
|
+
# whole REMAINDER -- `s="${s:${#pre}+1}"` -- so an escape-dense command stayed k*n: on Git Bash a 44080 B
|
|
223
|
+
# command with 2755 escapes took 6.8s, 11% of the timeout. The input is now touched only a chunk at a time
|
|
224
|
+
# (`${s:base:C}`, once per 4096 bytes) and every per-escape operation stays inside that chunk, so no escape
|
|
225
|
+
# pays for the length of the whole command. That is the change that matters: bounding only the lookahead,
|
|
226
|
+
# while still indexing the full string, was measured too and gave about 4x on both machines, against 6-12x
|
|
227
|
+
# for the chunked walk. Five bytes are held back at a chunk's end whenever more input follows, so a
|
|
228
|
+
# `\uXXXX` that starts in a chunk ends in it.
|
|
229
|
+
# The output goes into an array joined once, not a string recopied at every append, and `\n`/`\t` are
|
|
230
|
+
# written `$'\n'`/`$'\t'`, so this file carries no raw TAB for an editor or a copy to turn into spaces.
|
|
231
|
+
#
|
|
232
|
+
# Measured, isolated, previous shape -> this one:
|
|
233
|
+
# macOS/bash 3.2 escape-dense 44030 B, 2590 escapes .. 1.44s -> 0.12s
|
|
234
|
+
# sparse 100014 B, 4 escapes ........... 0.09s -> 0.02s
|
|
235
|
+
# Git Bash 5.3.15 escape-dense 44030 B, 2590 escapes .. 3.92s -> 0.54s
|
|
236
|
+
# (LANG empty) sparse 99997 B, 4 escapes ............ 0.49s -> 0.07s
|
|
237
|
+
# These numbers do not travel, which is why each one names its machine.
|
|
238
|
+
#
|
|
239
|
+
# Output is byte-identical to the previous shape across 478 cases, each run at four chunk/window sizes
|
|
240
|
+
# down to 7 and 1 bytes so that an edge falls every few bytes; that shape was itself byte-identical to
|
|
241
|
+
# the original character walk across a 31-case battery (escaped quotes, `\\`, a lone trailing backslash, a
|
|
242
|
+
# truncated `\u`, Turkish, emoji). Deliberately broken twins -- the 5-byte margin cut to 4, the backslash
|
|
243
|
+
# not stepped over -- prove the battery sees both edges, and tier 1 and tier 3 return the same verdict on
|
|
244
|
+
# the gate cases.
|
|
245
|
+
local LC_ALL=C
|
|
246
|
+
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
247
|
+
case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
248
|
+
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
249
|
+
while :; do
|
|
250
|
+
lim=$((cl - j))
|
|
251
|
+
if [ $((base + cl)) -lt "$n" ]; then # more input after this chunk: hold 5 bytes back, so
|
|
252
|
+
lim=$((lim - 5)) # a `\uXXXX` that starts in a chunk also ends in it
|
|
253
|
+
if [ "$lim" -le 0 ]; then base=$((base + j)); chunk="${s:base:C}"; cl=${#chunk}; j=0; continue; fi
|
|
254
|
+
else
|
|
255
|
+
[ "$lim" -gt 0 ] || break
|
|
256
|
+
fi
|
|
257
|
+
[ "$lim" -le "$W" ] || lim=$W
|
|
258
|
+
w="${chunk:j:lim}"
|
|
259
|
+
pre="${w%%\\*}" # the literal run before the next backslash in view
|
|
260
|
+
if [ "$pre" = "$w" ]; then acc+=("$w"); j=$((j+lim)); continue; fi
|
|
261
|
+
acc+=("$pre"); j=$((j+${#pre}+1))
|
|
262
|
+
if [ $((base + j)) -ge "$n" ]; then acc+=("\\"); break; fi # a lone trailing backslash stays literal, as before
|
|
263
|
+
c="${chunk:j:1}"; j=$((j+1))
|
|
264
|
+
case "$c" in
|
|
265
|
+
n) acc+=($'\n') ;;
|
|
266
|
+
t) acc+=($'\t') ;;
|
|
267
|
+
r) ;;
|
|
268
|
+
b|f) acc+=(" ") ;;
|
|
269
|
+
u) if [ $((n - base - j)) -ge 4 ]; then h="${chunk:j:4}"; j=$((j+4)); else h=""; fi # a short tail is left alone, as before
|
|
270
|
+
# A `\uXXXX` used to become a literal `?`. That is not a lossy nicety, it is a hole: `\u002e` is `.`,
|
|
271
|
+
# so `\u002eclaude/hooks/guard-bash.sh` decoded to `?claude/…` and matched no gate pattern, while jq
|
|
272
|
+
# decoded the same bytes to the real path — the two tiers disagreed on whether a payload was an
|
|
273
|
+
# attack. Printable ASCII is decoded properly (builtin printf, no fork); anything else still becomes
|
|
274
|
+
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
275
|
+
case "$h" in
|
|
276
|
+
00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
|
|
277
|
+
# The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
|
|
278
|
+
# `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
|
|
279
|
+
2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
|
|
280
|
+
*) acc+=("?") ;;
|
|
281
|
+
esac ;;
|
|
282
|
+
*) acc+=("$c") ;;
|
|
283
|
+
esac
|
|
284
|
+
done
|
|
285
|
+
# ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
|
|
286
|
+
# and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
|
|
287
|
+
local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
|
|
288
|
+
}
|
|
289
|
+
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
290
|
+
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
291
|
+
# …)"` first, which is a command substitution, which is a FORK -- on a hook that runs before every Bash and
|
|
292
|
+
# every Write call. Measured on macOS/bash, typical payload, 200 reps: the substitution form cost 0.790 ms
|
|
293
|
+
# per call against 0.090 ms for the raw byte test it replaces, and the whole hook went 15.00 -> 16.58 ms.
|
|
294
|
+
# This project's own recorded Git Bash process cost is 62-135 ms, rising to ~400 ms under load, so three
|
|
295
|
+
# added forks per call would have been a freeze on the platform the change is meant to protect. Setting _KC
|
|
296
|
+
# keeps the count in the caller's own shell: expansion only, zero forks.
|
|
297
|
+
# The counter the ambiguity refusals need, and it exists because the hand-written byte test they used to do
|
|
298
|
+
# looked for a DIFFERENT token than the parser above: `"key":` compact, while the parser searches `"key"` and
|
|
299
|
+
# tolerates whitespace before the colon. Two consequences, both measured on the shipped hook:
|
|
300
|
+
# * `{…"meta":{"command":"ls"},"tool_input":{"command" : "rm -rf /"}}` -- ONE SPACE and the duplicate-key
|
|
301
|
+
# refusal went blind while the parser happily read the first value.
|
|
302
|
+
# * a key name appearing as a VALUE was counted as an occurrence by neither, which is the hole the parser
|
|
303
|
+
# above now closes; counting the same form here is what keeps the two from drifting apart again.
|
|
304
|
+
# Sharing ONE definition with the parser is the point: a guard that searches for something else than the
|
|
305
|
+
# thing it guards is the defect, not an implementation detail.
|
|
306
|
+
# WHAT THE TOKEN CAN BE, stated correctly. An earlier version of this comment said the bytes `"command"`
|
|
307
|
+
# with both quotes unescaped "can only be a key or a value equal to the key name". That is wrong, and the
|
|
308
|
+
# counter-example was found by review, not by reasoning: a KEY whose own name ends with a quote spells the
|
|
309
|
+
# token out of its escaped quote plus the string's terminator --
|
|
310
|
+
# {"tool_input":{"x\"command":"DECOY","command":"rm -rf /"}} slice -> DECOY, count -> 2
|
|
311
|
+
# which is refused BECAUSE the count sees two, not because the invariant held. The same shape as a VALUE is
|
|
312
|
+
# harmless (a string's terminator is followed by `,` `}` `]`, never `:`, so it is skipped and not counted:
|
|
313
|
+
# {"description":"ends with \"command","command":"rm -rf /"} slice -> rm -rf /, count -> 1 ).
|
|
314
|
+
# What IS true, and is what keeps ordinary work from being refused: content can contribute at most one
|
|
315
|
+
# occurrence per string and only as that string's tail, where the next byte is never a colon. Measured on 19
|
|
316
|
+
# payloads built by a real JSON encoder -- `grep -rn '"command":' .`, a heredoc writing a hooks.json, `sed`
|
|
317
|
+
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
318
|
+
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
319
|
+
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
320
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
321
|
+
_KC=0; _KC_CAPPED=0
|
|
322
|
+
while :; do
|
|
323
|
+
_json_find "$hay" "$k"
|
|
324
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
325
|
+
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
326
|
+
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
327
|
+
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
328
|
+
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
329
|
+
rest="${hay:_JF+${#k}}"
|
|
330
|
+
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
331
|
+
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
332
|
+
hay="$rest"
|
|
333
|
+
done
|
|
334
|
+
}
|
|
335
|
+
# ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
|
|
336
|
+
_AM_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _AM_LIB=1
|
|
337
|
+
# ---- state: where the records of this gate and of agent-outcome.sh live ----------------------------------------
|
|
338
|
+
_am_state(){ # -> _AMP (the project), _AMS (.claude/state), _AMD (the records of calls, agents and results)
|
|
339
|
+
_AMP="${CLAUDE_PROJECT_DIR:-$PWD}"; _AMP="${_AMP//\\//}"; _AMP="${_AMP%/}"
|
|
340
|
+
_AMS="$_AMP/.claude/state"; _AMD="$_AMS/crew-model"
|
|
341
|
+
}
|
|
342
|
+
_am_rank(){ # $1 = haiku|sonnet|opus|fable -> _AMR 1..4, 0 for anything else
|
|
343
|
+
case "$1" in haiku) _AMR=1 ;; sonnet) _AMR=2 ;; opus) _AMR=3 ;; fable) _AMR=4 ;; *) _AMR=0 ;; esac; }
|
|
344
|
+
_am_name(){ # $1 = 1..4 -> _AMN
|
|
345
|
+
case "$1" in 1) _AMN=haiku ;; 2) _AMN=sonnet ;; 3) _AMN=opus ;; 4) _AMN=fable ;; *) _AMN="" ;; esac; }
|
|
346
|
+
_am_tier(){ # $1 = a model as a call or a transcript names it -> _AMT haiku|sonnet|opus|fable, "" unknown
|
|
347
|
+
local nc=0; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
348
|
+
case "$1" in *haiku*) _AMT=haiku ;; *sonnet*) _AMT=sonnet ;; *opus*) _AMT=opus ;; *fable*) _AMT=fable ;; *) _AMT="" ;; esac
|
|
349
|
+
[ "$nc" = 0 ] && shopt -u nocasematch; return 0; }
|
|
350
|
+
|
|
351
|
+
# ---- CRITICAL PATHS ---------------------------------------------------------------------------------------------
|
|
352
|
+
# One definition, asked of a card's files (this gate), of the file an agent is about to write (guard-write.sh) and
|
|
353
|
+
# of every file of the project (the scan in agent-outcome.sh). A path is critical when one of its parts is one of
|
|
354
|
+
# these WORDS, standing as a word: after a separator or at a capital (`auth/`, `user_auth.py`, `AuthController.cs`,
|
|
355
|
+
# `userAuth.ts`, `AUTH_KEYS`) and not running on in lower case (`author.md`, `cryptography/` are not). And by name:
|
|
356
|
+
# a .sql file, schema.prisma. The path is read from the project's root down, so the name of the folder the project
|
|
357
|
+
# sits in decides nothing. .claude/crew-model-rules, which only the user writes, adds (`+ glob`) and removes
|
|
358
|
+
# (`- glob`); the last line that matches wins.
|
|
359
|
+
_AM_W_L='auth|authn|authz|authentication|authorization|oauth|payments?|billing|migrations?|security|crypto|secrets?'
|
|
360
|
+
_AM_W_C='Auth|Authn|Authz|Authentication|Authorization|OAuth|Payments?|Billing|Migrations?|Security|Crypto|Secrets?'
|
|
361
|
+
_AM_W_U='AUTH|AUTHN|AUTHZ|OAUTH|PAYMENTS?|BILLING|MIGRATIONS?|SECURITY|CRYPTO|SECRETS?'
|
|
362
|
+
_AM_RE_L="(^|[^A-Za-z])($_AM_W_L)([^a-z]|\$)"
|
|
363
|
+
_AM_RE_C="(^|[^A-Z])($_AM_W_C)([^a-z]|\$)"
|
|
364
|
+
_AM_RE_U="(^|[^A-Za-z])($_AM_W_U)([^A-Za-z]|\$)"
|
|
365
|
+
_AM_RULES_READ=0; _AM_RULES=()
|
|
366
|
+
_am_rules(){ # reads .claude/crew-model-rules once -> _AM_RULES: "+<TAB>glob", "-<TAB>glob", "f<TAB>agent change risk tier", "v<TAB>command"
|
|
367
|
+
local l a b c d
|
|
368
|
+
[ "$_AM_RULES_READ" = 1 ] && return 0
|
|
369
|
+
_AM_RULES_READ=1; _AM_RULES=()
|
|
370
|
+
[ -n "${_AMP:-}" ] || _am_state
|
|
371
|
+
[ -f "$_AMP/.claude/crew-model-rules" ] || return 0
|
|
372
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
373
|
+
l="${l%$'\r'}"; l="${l%%#*}"
|
|
374
|
+
while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
|
|
375
|
+
while :; do case "$l" in *[$' \t']) l="${l%?}" ;; *) break ;; esac; done
|
|
376
|
+
case "$l" in
|
|
377
|
+
"+ "*|"+"$'\t'*) l="${l:1}"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done; _AM_RULES+=("+"$'\t'"$l") ;;
|
|
378
|
+
"- "*|"-"$'\t'*) l="${l:1}"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done; _AM_RULES+=("-"$'\t'"$l") ;;
|
|
379
|
+
"verify "*) l="${l#verify }"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
|
|
380
|
+
[ -n "$l" ] && case "$l" in *[\;\&\|\<\>\`\$]*) ;; *) _AM_RULES+=("v"$'\t'"$l") ;; esac ;;
|
|
381
|
+
"floor "*) IFS=$' \t' read -r a b c d _ <<< "${l#floor }"; _am_tier "${d:-}"
|
|
382
|
+
[ -n "$_AMT" ] && [ -n "${c:-}" ] && _AM_RULES+=("f"$'\t'"$a $b $c $_AMT") ;;
|
|
383
|
+
esac
|
|
384
|
+
done < "$_AMP/.claude/crew-model-rules"
|
|
385
|
+
}
|
|
386
|
+
_am_rel(){ # $1 = a path as a card or a tool names it -> _AMRP, from the project's root where it is under it
|
|
387
|
+
local p="${1//\\//}"
|
|
388
|
+
[ -n "${_AMP:-}" ] || _am_state
|
|
389
|
+
case "$p" in "$_AMP"/*) p="${p#"$_AMP"/}" ;; ./*) p="${p#./}" ;; esac
|
|
390
|
+
_AMRP="$p"
|
|
391
|
+
}
|
|
392
|
+
_am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 not
|
|
393
|
+
local p r k g hit=1
|
|
394
|
+
_am_rel "$1"; p="$_AMRP"; _AMW=""
|
|
395
|
+
if [[ "$p" =~ $_AM_RE_L ]] || [[ "$p" =~ $_AM_RE_C ]] || [[ "$p" =~ $_AM_RE_U ]]; then hit=0; _AMW="${BASH_REMATCH[2]}"
|
|
396
|
+
else case "$p" in *.sql|*.SQL) hit=0; _AMW="a .sql file" ;; schema.prisma|*/schema.prisma) hit=0; _AMW="schema.prisma" ;; esac; fi
|
|
397
|
+
_am_rules
|
|
398
|
+
for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
|
|
399
|
+
k="${r%%$'\t'*}"; g="${r#*$'\t'}"
|
|
400
|
+
case "$k" in +|-) ;; *) continue ;; esac
|
|
401
|
+
# A rule's glob matches the path, anything under it, and the same at any depth.
|
|
402
|
+
case "$p" in $g|$g/*|*/$g|*/$g/*) if [ "$k" = + ]; then hit=0; _AMW="crew-model-rules: + $g"; else hit=1; _AMW=""; fi ;; esac
|
|
403
|
+
done
|
|
404
|
+
return "$hit"
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# ---- THE CARD ---------------------------------------------------------------------------------------------------
|
|
408
|
+
_AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
|
|
409
|
+
_am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1 and CARD_WHY
|
|
410
|
+
local s="$1" l k v n=0 seen=""
|
|
411
|
+
CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""
|
|
412
|
+
s="${s//$'\r'/}"
|
|
413
|
+
while [ "$n" -lt 3 ] && [ -n "$s" ]; do
|
|
414
|
+
l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
|
|
415
|
+
while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
|
|
416
|
+
[ -n "$l" ] || continue
|
|
417
|
+
n=$((n+1))
|
|
418
|
+
case "$l" in *:*) ;; *) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;; esac
|
|
419
|
+
k="${l%%:*}"; v="${l#*:}"
|
|
420
|
+
while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
|
|
421
|
+
while :; do case "$v" in *[$' \t']) v="${v%?}" ;; *) break ;; esac; done
|
|
422
|
+
case "$k" in
|
|
423
|
+
files) CARD_FILES="$v" ;;
|
|
424
|
+
change) CARD_CHANGE="$v" ;;
|
|
425
|
+
verify) CARD_VERIFY="$v" ;;
|
|
426
|
+
*) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;;
|
|
427
|
+
esac
|
|
428
|
+
case "$seen" in *" $k "*) CARD_WHY="the card names $k twice"; return 1 ;; esac
|
|
429
|
+
seen="$seen $k "
|
|
430
|
+
done
|
|
431
|
+
[ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
|
|
432
|
+
[ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
|
|
433
|
+
case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
|
|
434
|
+
[ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
|
|
435
|
+
case "$CARD_VERIFY" in none|None|NONE|-) CARD_VERIFY=none ;; esac
|
|
436
|
+
return 0
|
|
437
|
+
}
|
|
438
|
+
_am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
|
|
439
|
+
local f unglob=0
|
|
440
|
+
CARD_RISK=normal; CARD_RISKWHY=""
|
|
441
|
+
case " migration security architecture fix-unknown " in *" $CARD_CHANGE "*) CARD_RISK=critical; CARD_RISKWHY="change: $CARD_CHANGE" ;; esac
|
|
442
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
443
|
+
for f in ${CARD_FILES//,/ }; do
|
|
444
|
+
if _am_crit_path "$f"; then CARD_RISK=critical; [ -n "$CARD_RISKWHY" ] || CARD_RISKWHY="$f is on a critical path: $_AMW"; fi
|
|
445
|
+
done
|
|
446
|
+
[ "$unglob" = 1 ] && set +f
|
|
447
|
+
return 0
|
|
448
|
+
}
|
|
449
|
+
_am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "the same card" is the same value
|
|
450
|
+
local t
|
|
451
|
+
t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | git hash-object --stdin 2>/dev/null)" || t=""
|
|
452
|
+
case "$t" in ''|*[!0-9a-f]*) t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | cksum 2>/dev/null)"; t="${t%% *}"; printf -v t '%012x' "${t:-0}" 2>/dev/null || t=000000000000 ;; esac
|
|
453
|
+
CARD_ID="${t:0:12}"
|
|
454
|
+
}
|
|
455
|
+
_am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
|
|
456
|
+
local l; _AMV=""
|
|
457
|
+
[ -f "$1" ] || return 1
|
|
458
|
+
while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$2="*) _AMV="${l#*=}"; return 0 ;; esac; done < "$1"
|
|
459
|
+
return 1
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# ---- THE CALIBRATION FLOOR IN FORCE for a class ------------------------------------------------------------------
|
|
463
|
+
# hooks/agent-outcome.sh raises it (crew-model-floors.auto: agent, change, risk, model, calls, not-first-try, when);
|
|
464
|
+
# the user lowers it, one model, once for each raise, with /crew-loosen (crew-model-loosened.tsv: ts, agent, change,
|
|
465
|
+
# risk, from, to; written by hooks/prompt-approval.sh from the user's own message). A lowering counts when it is
|
|
466
|
+
# NEWER than the raise it answers: raised again afterwards, the floor is the raised one. One function, asked by this
|
|
467
|
+
# gate, by the hook that raises, and by the hook that records the lowering.
|
|
468
|
+
_am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor, "" none), _AMF_WHEN, _AMF_LOOSE 0|1,
|
|
469
|
+
# _AMF_TO and _AMF_LTS (the lowering that is in force), _AMF_EFF (the floor that holds now, "" none)
|
|
470
|
+
local a b c t n bad w ts la lb lc lf lt
|
|
471
|
+
_AMF_TIER=""; _AMF_WHEN=""; _AMF_LOOSE=0; _AMF_TO=""; _AMF_LTS=""; _AMF_EFF=""
|
|
472
|
+
[ -n "${_AMS:-}" ] || _am_state
|
|
473
|
+
[ -f "$_AMS/crew-model-floors.auto" ] || return 0
|
|
474
|
+
while IFS=$'\t' read -r a b c t n bad w || [ -n "$a" ]; do
|
|
475
|
+
[ "$a" = "$1" ] && [ "$b" = "$2" ] && [ "$c" = "$3" ] || continue
|
|
476
|
+
_AMF_TIER="${t%$'\r'}"; _AMF_WHEN="${w%$'\r'}"
|
|
477
|
+
done < "$_AMS/crew-model-floors.auto"
|
|
478
|
+
[ -n "$_AMF_TIER" ] || return 0
|
|
479
|
+
_AMF_EFF="$_AMF_TIER"
|
|
480
|
+
[ -f "$_AMS/crew-model-loosened.tsv" ] || return 0
|
|
481
|
+
while IFS=$'\t' read -r ts la lb lc lf lt || [ -n "$ts" ]; do
|
|
482
|
+
[ "$la" = "$1" ] && [ "$lb" = "$2" ] && [ "$lc" = "$3" ] || continue
|
|
483
|
+
lt="${lt%$'\r'}"
|
|
484
|
+
# ISO 8601 in UTC sorts as text. Only a lowering of THIS raise counts: not older than it (the same second is a
|
|
485
|
+
# lowering of it; the hook that raises again never stamps the second of a lowering), from its model, one down.
|
|
486
|
+
[[ "$ts" < "$_AMF_WHEN" ]] && continue
|
|
487
|
+
[ "$lf" = "$_AMF_TIER" ] || continue
|
|
488
|
+
_am_rank "$lf"; n="$_AMR"; _am_rank "$lt"; [ "$_AMR" -gt 0 ] && [ "$_AMR" = $((n-1)) ] || continue
|
|
489
|
+
_AMF_LOOSE=1; _AMF_TO="$lt"; _AMF_LTS="$ts"; _AMF_EFF="$lt"
|
|
490
|
+
done < "$_AMS/crew-model-loosened.tsv"
|
|
491
|
+
return 0
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
# ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
|
|
495
|
+
_am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog; the call's text is never recorded.
|
|
496
|
+
local gl="${CREW_GATE_LOG:-}" v=BLOCK
|
|
497
|
+
case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
|
|
498
|
+
if [ -z "$gl" ]; then
|
|
499
|
+
[ -d ".claude" ] || return 0
|
|
500
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0; fi
|
|
501
|
+
gl=".claude/gate-log.tsv"
|
|
502
|
+
fi
|
|
503
|
+
printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
# ---- IS THIS VERIFY COMMAND ONE A HOOK MAY RUN? ------------------------------------------------------------------
|
|
507
|
+
# The command comes from the card, which the session wrote, and hooks/agent-outcome.sh would run it when the agent
|
|
508
|
+
# stops: outside any tool call, so past the permission rules, the auto-mode classifier and every PreToolUse gate.
|
|
509
|
+
# A list of what must NOT run cannot close that (the shell gate is such a list, and `python3 -c`, `node -e`, `npx`,
|
|
510
|
+
# `find -delete`, a wrapper like `timeout`, and a runner's own options such as `go test -exec` or `make SHELL=…`
|
|
511
|
+
# all pass it; found in review). So the rule is what MAY run, and everything else is left for the session to run
|
|
512
|
+
# itself, with the Bash tool, where the permission layer sees it. One function, asked at the moment the command
|
|
513
|
+
# would start; the rules, in this order:
|
|
514
|
+
# 1. ONE command: no `;`, `&`, `|`, `<`, `>`, backtick, `$(` or second line.
|
|
515
|
+
# 2. Never a git commit or a git push: an approval the user gave the SESSION is not for a hook.
|
|
516
|
+
# 3. NOT DENIED. A `Bash(...)` rule of permissions.deny or permissions.ask that covers it, in ANY settings file
|
|
517
|
+
# (the project's two, the user's, a managed one), stops it: a hook cannot ask, and a deny is a deny. What
|
|
518
|
+
# cannot be read stops it too: a settings file that is there and does not parse (a comment, a byte-order
|
|
519
|
+
# mark), and a deny or ask rule in a form this reader does not match (a `*` that is not at the end, as in
|
|
520
|
+
# `Bash(git * --force)`). Not knowing what is denied is not the same as nothing being denied.
|
|
521
|
+
# 4. ALLOWED BY NAME, one of:
|
|
522
|
+
# a. a line `verify <command>` of .claude/crew-model-rules (the user's file);
|
|
523
|
+
# b. a `Bash(...)` rule of permissions.allow in any of those settings files (a bare `Bash` allows nothing);
|
|
524
|
+
# c. ONLY in `auto` and `bypassPermissions`, the two modes in which Claude Code itself runs a test command
|
|
525
|
+
# without the user having allowed it: a test or build runner from the list below, with arguments that
|
|
526
|
+
# are paths or test names (no argument that begins with `-` or holds `=`: an option can name a program
|
|
527
|
+
# to run), and for make, mvn and gradle no argument at all (a second target is a second task). In
|
|
528
|
+
# `default`, `acceptEdits` and `plan` Claude Code asks first, and `dontAsk` refuses whatever was not
|
|
529
|
+
# allowed beforehand, so there the list allows nothing and only a or b does.
|
|
530
|
+
# 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
|
|
531
|
+
# explicit allow passes.
|
|
532
|
+
_AM_HERE="${BASH_SOURCE%/*}"; [ "$_AM_HERE" = "${BASH_SOURCE}" ] && _AM_HERE=.
|
|
533
|
+
# CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
|
|
534
|
+
_AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
|
|
535
|
+
'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
|
|
536
|
+
'cargo test' 'cargo build' 'cargo check' 'cargo clippy' 'pytest' 'python -m pytest' 'python3 -m pytest'
|
|
537
|
+
'python -m unittest' 'python3 -m unittest' 'mvn test' 'mvn verify' './mvnw test' './mvnw verify' 'gradle test'
|
|
538
|
+
'gradle check' 'gradle build' './gradlew test' './gradlew check' './gradlew build' 'make test' 'make check'
|
|
539
|
+
'make lint' 'make build' 'tsc' 'eslint' 'ruff check' 'mypy' 'phpunit' 'rspec' 'bundle exec rspec' 'swift test'
|
|
540
|
+
'swift build' 'ctest' 'jest' 'vitest' 'mocha' 'mix test' 'composer test' 'test' 'true')
|
|
541
|
+
# /CREW-NOT-A-RUNG
|
|
542
|
+
_am_starts(){ # $1 = command, $2 = prefix -> 0 when the command is the prefix or the prefix and more words
|
|
543
|
+
case "$1" in "$2"|"$2 "*) return 0 ;; esac; return 1; }
|
|
544
|
+
_am_plain_args(){ # $1 = runner, $2 = what follows it -> 0 when every word is a path or a test name
|
|
545
|
+
local w unglob=0 rc=0
|
|
546
|
+
# For these a further word is a further TASK: `make test deploy`, `gradle build publish`, `mvn test install`.
|
|
547
|
+
case "$1" in make\ *|mvn\ *|./mvnw\ *|gradle\ *|./gradlew\ *) [ -z "$2" ]; return ;; esac
|
|
548
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
549
|
+
for w in $2; do
|
|
550
|
+
# dotnet takes a project, a solution or a folder and nothing else: a bare word there is a verb or a target.
|
|
551
|
+
case "$1" in dotnet\ *) case "$w" in */*|*.*) ;; *) rc=1 ;; esac ;; esac
|
|
552
|
+
case "$w" in
|
|
553
|
+
-[A-Za-z]) [ "$1" = test ] || rc=1 ;; # `test -f x`: the one runner whose flags are its questions
|
|
554
|
+
-*|*=*) rc=1 ;;
|
|
555
|
+
*[!A-Za-z0-9_./:@,+%^~\#\[\]\*\?-]*) rc=1 ;;
|
|
556
|
+
esac
|
|
557
|
+
done
|
|
558
|
+
[ "$unglob" = 1 ] && set +f
|
|
559
|
+
return "$rc"
|
|
560
|
+
}
|
|
561
|
+
_am_settings_files(){ # -> _AMSF: the settings files that exist, one per line: project, local, user, managed
|
|
562
|
+
local f u; _AMSF=""
|
|
563
|
+
# The user's settings are where Claude Code keeps them: CLAUDE_CONFIG_DIR when it is set, ~/.claude otherwise.
|
|
564
|
+
u="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}"; u="${u//\\//}"
|
|
565
|
+
for f in "$_AMP/.claude/settings.json" "$_AMP/.claude/settings.local.json" "${u:+$u/settings.json}" \
|
|
566
|
+
"/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json" \
|
|
567
|
+
"/c/Program Files/ClaudeCode/managed-settings.json" "/c/ProgramData/ClaudeCode/managed-settings.json" \
|
|
568
|
+
"${CREW_MANAGED_SETTINGS:-}"; do # one more file to read as managed: it can only add rules
|
|
569
|
+
[ -n "$f" ] && [ -f "$f" ] && _AMSF="$_AMSF$f"$'\n'
|
|
570
|
+
done
|
|
571
|
+
}
|
|
572
|
+
_am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of that list covers it (_AMPH names it);
|
|
573
|
+
# 1 none does; 2 the list cannot be relied on (_AMPH says why): a file that does not parse, or,
|
|
574
|
+
# for deny and ask, a rule in a form this reader does not match
|
|
575
|
+
local c="$1" list="$2" sj="$_AM_HERE/../eval/lib/settings-json.awk" f x g out
|
|
576
|
+
_AMPH=""
|
|
577
|
+
while IFS= read -r f; do
|
|
578
|
+
[ -n "$f" ] || continue
|
|
579
|
+
if ! awk -v op=validate -f "$sj" "$f" >/dev/null 2>&1; then _AMPH="${f##*/} is there and cannot be read as JSON"; return 2; fi
|
|
580
|
+
out="$(awk -v op=strings -v path="permissions.$list" -f "$sj" "$f" 2>/dev/null)" || out=""
|
|
581
|
+
while IFS= read -r x; do
|
|
582
|
+
x="${x%$'\r'}"; [ -n "$x" ] || continue
|
|
583
|
+
if [ "$x" = Bash ]; then [ "$list" = allow ] && continue; _AMPH="$list: Bash (${f##*/})"; return 0; fi
|
|
584
|
+
case "$x" in 'Bash('*')') ;; *) continue ;; esac
|
|
585
|
+
x="${x#Bash(}"; x="${x%)}"; g=""
|
|
586
|
+
case "$x" in
|
|
587
|
+
''|'*'|':*') [ "$list" = allow ] && continue; _AMPH="$list: Bash($x) (${f##*/})"; return 0 ;;
|
|
588
|
+
esac
|
|
589
|
+
# A `*` anywhere but at the end is a form this reader does not match. An allow rule it cannot read allows
|
|
590
|
+
# nothing; a deny or an ask rule it cannot read may cover the command, so the list cannot be relied on.
|
|
591
|
+
g="${x%:\*}"; g="${g% \*}"; g="${g%\*}"
|
|
592
|
+
case "$g" in *'*'*) [ "$list" = allow ] && continue; _AMPH="$list: Bash($x) (${f##*/}) is a form this hook does not match"; return 2 ;; esac
|
|
593
|
+
case "$x" in
|
|
594
|
+
*':*') g="${x%:\*}"; _am_starts "$c" "$g" || continue ;;
|
|
595
|
+
*' *') g="${x% \*}"; _am_starts "$c" "$g" || continue ;;
|
|
596
|
+
*'*') g="${x%\*}"; case "$c" in "$g"*) ;; *) continue ;; esac ;;
|
|
597
|
+
*) [ "$c" = "$x" ] || continue ;;
|
|
598
|
+
esac
|
|
599
|
+
_AMPH="$list: Bash($x) (${f##*/})"; return 0
|
|
600
|
+
done <<< "$out"
|
|
601
|
+
done <<< "$_AMSF"
|
|
602
|
+
return 1
|
|
603
|
+
}
|
|
604
|
+
_am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
|
|
605
|
+
local c="$1" pm="${2:-default}" f r rc k g
|
|
606
|
+
_AMVW=""; _AMVB=""
|
|
607
|
+
case "$c" in ''|none) return 0 ;; esac
|
|
608
|
+
case "$c" in
|
|
609
|
+
*$'\n'*|*$'\r'*) _AMVW="it is more than one line"; return 1 ;;
|
|
610
|
+
*';'*|*'&'*|*'|'*|*'<'*|*'>'*|*'`'*|*'$('*) _AMVW="it chains, pipes, redirects or substitutes (; & | < > \` \$( )"; return 1 ;;
|
|
611
|
+
*[$'\001'-$'\010'$'\013'-$'\037']*) _AMVW="it holds a control character"; return 1 ;;
|
|
612
|
+
esac
|
|
613
|
+
c="${c//$'\t'/ }"; while :; do case "$c" in *' '*) c="${c// / }" ;; *) break ;; esac; done
|
|
614
|
+
c="${c# }"; c="${c% }"
|
|
615
|
+
if [[ " $c " =~ [[:space:]/]git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)[[:space:]] ]]; then
|
|
616
|
+
_AMVW="a hook never commits or pushes: the user's approval is for the session's own call"; return 1
|
|
617
|
+
fi
|
|
618
|
+
[ -n "${_AMP:-}" ] || _am_state
|
|
619
|
+
[ -f "$_AM_HERE/../eval/lib/settings-json.awk" ] || { _AMVW="the settings reader is not beside this hook, so the permission rules cannot be read"; return 1; }
|
|
620
|
+
_am_settings_files
|
|
621
|
+
_am_perm_hit "$c" deny; r=$?
|
|
622
|
+
if [ "$r" = 0 ]; then _AMVW="a permission rule denies it ($_AMPH)"; return 1; fi
|
|
623
|
+
if [ "$r" = 2 ]; then _AMVW="what is denied cannot be known ($_AMPH), so nothing is run by the hook"; return 1; fi
|
|
624
|
+
_am_perm_hit "$c" ask; r=$?
|
|
625
|
+
if [ "$r" = 0 ]; then _AMVW="a permission rule says to ask the user first ($_AMPH), and a hook cannot ask"; return 1; fi
|
|
626
|
+
if [ "$r" = 2 ]; then _AMVW="what has to be asked first cannot be known ($_AMPH), so nothing is run by the hook"; return 1; fi
|
|
627
|
+
_am_rules
|
|
628
|
+
for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
|
|
629
|
+
k="${r%%$'\t'*}"; g="${r#*$'\t'}"; [ "$k" = v ] || continue
|
|
630
|
+
_am_starts "$c" "$g" && { _AMVB="crew-model-rules: verify $g"; break; }
|
|
631
|
+
done
|
|
632
|
+
if [ -z "$_AMVB" ]; then _am_perm_hit "$c" allow; [ "$?" = 0 ] && _AMVB="permissions.$_AMPH"; fi
|
|
633
|
+
if [ -z "$_AMVB" ]; then
|
|
634
|
+
case "$pm" in
|
|
635
|
+
auto|bypassPermissions)
|
|
636
|
+
for r in "${_AM_RUNNERS[@]}"; do
|
|
637
|
+
_am_starts "$c" "$r" || continue
|
|
638
|
+
g=""; [ "$c" != "$r" ] && g="${c#"$r "}"
|
|
639
|
+
if _am_plain_args "$r" "$g"; then _AMVB="runner: $r"; break; fi
|
|
640
|
+
_AMVW="it is a known runner ($r) with an argument a hook does not pass on: an option, an assignment, or for make, mvn and gradle anything after the one target"; return 1
|
|
641
|
+
done
|
|
642
|
+
[ -n "$_AMVB" ] || { _AMVW="it is not a test or build command on this hook's list, and no 'verify <command>' line of .claude/crew-model-rules or Bash(...) rule of permissions.allow names it"; return 1; } ;;
|
|
643
|
+
*) _AMVW="in '${pm:-default}' mode Claude Code does not run a command the user has not allowed, so neither does a hook: allow it by name with a 'verify <command>' line of .claude/crew-model-rules, or a Bash(...) rule of permissions.allow"; return 1 ;;
|
|
644
|
+
esac
|
|
645
|
+
fi
|
|
646
|
+
[ -f "$_AM_HERE/guard-bash.sh" ] || { _AMVW="the shell gate is not beside this hook, so the command cannot be judged"; return 1; }
|
|
647
|
+
case "$pm" in *[!A-Za-z]*|'') pm=default ;; esac
|
|
648
|
+
f="${c//\\/\\\\}"; f="${f//\"/\\\"}"
|
|
649
|
+
r="$(printf '{"session_id":"crew-verify-not-a-session","permission_mode":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"%s"}}' "$pm" "$f" | CREW_GATE_LOG=/dev/null bash "$_AM_HERE/guard-bash.sh" 2>/dev/null)"; rc=$?
|
|
650
|
+
if [ "$rc" != 0 ]; then _AMVW="the shell gate refuses it"; return 1; fi
|
|
651
|
+
case "$r" in
|
|
652
|
+
'') return 0 ;;
|
|
653
|
+
*'"permissionDecision"'*'"ask"'*) _AMVW="the shell gate would ask the user about it, and a hook cannot ask"; return 1 ;;
|
|
654
|
+
*'"permissionDecision"'*'"deny"'*) _AMVW="the shell gate denies it"; return 1 ;;
|
|
655
|
+
*'"permissionDecision":"allow"'*|*'"permissionDecision": "allow"'*) return 0 ;;
|
|
656
|
+
*) _AMVW="the shell gate answered something that is not an allow"; return 1 ;;
|
|
657
|
+
esac
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
# ---- AT WRITE TIME (called by guard-write.sh) -------------------------------------------------------------------
|
|
661
|
+
# The card can leave a file out. So an agent that is not on opus does not write to a critical path at all: the write
|
|
662
|
+
# is refused where it happens, and the agent is told to stop and say so. Which model the agent is on: the record
|
|
663
|
+
# made when it was called (matched to it when it started), else what its own transcript names. Not known is not
|
|
664
|
+
# opus. The session's own writes carry no agent_id and are not this check's.
|
|
665
|
+
_am_write_check(){ # $1 = the payload, $2 = the file about to be written -> returns 0, or refuses and exits 2
|
|
666
|
+
local in="$1" fp="$2" aid tp tier="" f m
|
|
667
|
+
[ "${CREW_MODEL_ROUTING:-}" = off ] && return 0
|
|
668
|
+
case "$in" in *'"agent_id"'*) ;; *) return 0 ;; esac
|
|
669
|
+
_am_state
|
|
670
|
+
_am_crit_path "$fp" || return 0
|
|
671
|
+
_json_keycount "$in" agent_id; _json_slice "$in" agent_id >/dev/null; aid="$_JS"
|
|
672
|
+
if [ "$_KC" != 1 ] || [ -z "$aid" ] || [ "${aid//[A-Za-z0-9._-]/}" != "" ]; then
|
|
673
|
+
# The call says it comes from inside an agent and does not say which one. Not known is not opus.
|
|
674
|
+
_am_log "critical path: write by an agent whose id could not be read"
|
|
675
|
+
echo "GUARD (agent model): $_AMRP is on a critical path ($_AMW) and this call comes from an agent whose id could not be read; only an agent on opus writes there. Stop, and end your report with the two lines: escalate: $_AMRP / confidence: low" >&2
|
|
676
|
+
exit 2
|
|
677
|
+
fi
|
|
678
|
+
if _am_rec_get "$_AMD/agents/$aid" tier; then tier="$_AMV"; fi
|
|
679
|
+
if [ -z "$tier" ]; then
|
|
680
|
+
_json_slice "$in" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
|
|
681
|
+
f="${tp%.jsonl}/subagents/agent-$aid.jsonl"
|
|
682
|
+
if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
|
|
683
|
+
fi
|
|
684
|
+
case "$tier" in opus|fable) return 0 ;; esac
|
|
685
|
+
_am_log "critical path: write by an agent not on opus"
|
|
686
|
+
echo "GUARD (agent model): $_AMRP is on a critical path ($_AMW) and this agent runs on ${tier:-a model that could not be read}; only an agent on opus writes there. Do not reach the file another way. Stop, and end your report with the two lines: escalate: $_AMRP / confidence: low" >&2
|
|
687
|
+
exit 2
|
|
688
|
+
}
|
|
689
|
+
[ "$_AM_LIB" = 1 ] && return 0
|
|
690
|
+
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
691
|
+
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
692
|
+
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
693
|
+
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
694
|
+
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
695
|
+
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
696
|
+
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
697
|
+
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
698
|
+
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
699
|
+
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
700
|
+
# that is not found (status 127, the script continues inside the same command).
|
|
701
|
+
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
702
|
+
_crew_stop(){ # $1 = what happened
|
|
703
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
704
|
+
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
|
|
705
|
+
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
|
|
706
|
+
exit 2
|
|
707
|
+
}
|
|
708
|
+
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
709
|
+
# ---- /CREW-FAILCLOSED
|
|
710
|
+
_gate_main(){
|
|
711
|
+
local tn st m ti rank=0 floor=0 fname="" why="" prompt bg tid here nf=0 l d f p pm vrc r a b c t
|
|
712
|
+
[ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
|
|
713
|
+
IFS= read -r -d '' INPUT || true
|
|
714
|
+
_json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
|
|
715
|
+
case "$tn" in Agent|Task) ;; *) exit 0 ;; esac
|
|
716
|
+
# The call's own fields: everything from "tool_input" on, so a key of the same name in front of it is not read.
|
|
717
|
+
_json_find "$INPUT" '"tool_input"'
|
|
718
|
+
[ "$_JF" -ge 0 ] || exit 0
|
|
719
|
+
ti="${INPUT:_JF}"
|
|
720
|
+
_json_keycount "$ti" subagent_type
|
|
721
|
+
if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names subagent_type more than once, so which agent it starts cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
|
|
722
|
+
_json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
|
|
723
|
+
st="${st##*:}" # a plugin names its agents <plugin>:crew-…
|
|
724
|
+
case "$st" in crew-*) ;; *) exit 0 ;; esac
|
|
725
|
+
case "$st" in *[!A-Za-z0-9_-]*) echo "GUARD (agent model): the agent's name holds a character this gate does not read, so the call is refused." >&2; _am_log "an agent name it cannot read"; exit 2 ;; esac
|
|
726
|
+
_json_keycount "$ti" model
|
|
727
|
+
if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names model more than once, so which model it asks for cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
|
|
728
|
+
_json_slice "$ti" model >/dev/null; _json_unescape "$_JS" >/dev/null; m="$_JU"
|
|
729
|
+
if [ -z "$m" ]; then rank=0; else _am_tier "$m"; if [ -n "$_AMT" ]; then m="$_AMT"; _am_rank "$m"; rank="$_AMR"; else rank=-1; fi; fi
|
|
730
|
+
# The agent's own floor, whatever the card says.
|
|
731
|
+
case "$st" in
|
|
732
|
+
crew-security-expert) floor=3; why="the security audit" ;;
|
|
733
|
+
crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; why="this agent's floor" ;;
|
|
734
|
+
esac
|
|
735
|
+
_am_name "$floor"; fname="$_AMN"
|
|
736
|
+
if [ "$rank" = 0 ]; then
|
|
737
|
+
echo "GUARD (agent model): a call to $st has to name its model. Choose haiku, sonnet or opus by the table in CLAUDE.md (risk decides, not size) and repeat the same call with model set.${fname:+ $st runs on $fname or above.}" >&2
|
|
738
|
+
_am_log "no model"; exit 2
|
|
739
|
+
fi
|
|
740
|
+
if [ "$rank" = -1 ]; then
|
|
741
|
+
echo "GUARD (agent model): '${m:0:40}' is not a model this gate knows. Repeat the call to $st with model haiku, sonnet or opus.${fname:+ $st runs on $fname or above.}" >&2
|
|
742
|
+
_am_log "unknown model"; exit 2
|
|
743
|
+
fi
|
|
744
|
+
if [ "$rank" = 4 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
|
|
745
|
+
echo "GUARD (agent model): fable is not used for a crew agent unless the user has set CREW_ALLOW_FABLE=1 for the session.${fname:+ $st runs on $fname or above.} Repeat the call to $st with haiku, sonnet or opus, by the table in CLAUDE.md." >&2
|
|
746
|
+
_am_log "fable not allowed"; exit 2
|
|
747
|
+
fi
|
|
748
|
+
# ---- the card ----
|
|
749
|
+
_json_keycount "$ti" prompt
|
|
750
|
+
if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names prompt more than once, so its task card cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
|
|
751
|
+
_json_slice "$ti" prompt >/dev/null; prompt="${_JS:0:6000}"; _json_unescape "$prompt" >/dev/null; prompt="$_JU"
|
|
752
|
+
if ! _am_card "$prompt"; then
|
|
753
|
+
echo "GUARD (agent model): a call to $st opens with its task card and this one does not ($CARD_WHY). Begin the task with three lines, then the task itself: files: <files or globs> / change: <text|feature|fix-known|fix-unknown|refactor|migration|security|architecture|test-run|test-write|audit> / verify: <the command that proves it, or none>.${fname:+ $st runs on $fname or above.}" >&2
|
|
754
|
+
_am_log "no card"; exit 2
|
|
755
|
+
fi
|
|
756
|
+
_am_state; _am_risk; _am_digest
|
|
757
|
+
# ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
|
|
758
|
+
if [ "$CARD_VERIFY" = none ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
|
|
759
|
+
case "$CARD_CHANGE" in test-write|audit) if [ "$floor" -lt 2 ]; then floor=2; why="change: $CARD_CHANGE, the referee of the work"; fi ;; esac
|
|
760
|
+
if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
|
|
761
|
+
# A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
|
|
762
|
+
_am_rules
|
|
763
|
+
for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
|
|
764
|
+
case "$r" in f$'\t'*) ;; *) continue ;; esac
|
|
765
|
+
IFS=' ' read -r a b c t <<< "${r#*$'\t'}"
|
|
766
|
+
case "$a" in '*'|"$st") ;; *) continue ;; esac; case "$b" in '*'|"$CARD_CHANGE") ;; *) continue ;; esac; case "$c" in '*'|"$CARD_RISK") ;; *) continue ;; esac
|
|
767
|
+
_am_rank "$t"; if [ "$_AMR" -gt "$floor" ]; then floor="$_AMR"; why="crew-model-rules: floor $a $b $c $t"; fi
|
|
768
|
+
done
|
|
769
|
+
_am_floor_now "$st" "$CARD_CHANGE" "$CARD_RISK"
|
|
770
|
+
if [ -n "$_AMF_EFF" ]; then
|
|
771
|
+
_am_rank "$_AMF_EFF"
|
|
772
|
+
if [ "$_AMR" -gt "$floor" ]; then floor="$_AMR"; why="this class ($st, $CARD_CHANGE, $CARD_RISK) failed its first try too often on the model below"; [ "$_AMF_LOOSE" = 1 ] && why="$why; lowered once by the user, from $_AMF_TIER"; fi
|
|
773
|
+
fi
|
|
774
|
+
# A card whose verify failed: not on the same or a lower model again, and not a third time.
|
|
775
|
+
p=""
|
|
776
|
+
if [ -f "$_AMD/fails/$CARD_ID" ]; then
|
|
777
|
+
d=0
|
|
778
|
+
while IFS= read -r l || [ -n "$l" ]; do l="${l%$'\r'}"; [ -n "$l" ] || continue; nf=$((nf+1)); _am_rank "$l"; if [ "$_AMR" -gt "$d" ]; then d="$_AMR"; p="$l"; fi; done < "$_AMD/fails/$CARD_ID"
|
|
779
|
+
fi
|
|
780
|
+
if [ "$nf" -ge 2 ]; then
|
|
781
|
+
echo "GUARD (agent model): this card's verify command has failed twice, the second time one model up, so it is not run a third time. Ask the user what to do (AskUserQuestion): what failed, on which models, and the choices. A changed task is a new card." >&2
|
|
782
|
+
_am_log "a third run of a failed card"; exit 2
|
|
783
|
+
fi
|
|
784
|
+
if [ "$nf" = 1 ]; then
|
|
785
|
+
_am_rank "$p"; d=$((_AMR+1))
|
|
786
|
+
if [ "$d" -gt 3 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
|
|
787
|
+
echo "GUARD (agent model): this card's verify command failed on $p and there is no model above it to repeat on. Ask the user what to do (AskUserQuestion). A changed task is a new card." >&2
|
|
788
|
+
_am_log "a failed card with no model above"; exit 2
|
|
789
|
+
fi
|
|
790
|
+
if [ "$d" -gt "$floor" ]; then floor="$d"; why="its verify command failed on $p, so the repeat goes one model up"; fi
|
|
791
|
+
fi
|
|
792
|
+
_am_name "$floor"; fname="$_AMN"
|
|
793
|
+
if [ "$rank" -lt "$floor" ]; then
|
|
794
|
+
echo "GUARD (agent model): $st runs on $fname or above for this card ($why), and this call asks for $m. Repeat the same call with model $fname." >&2
|
|
795
|
+
_am_log "below the floor of the card"; exit 2
|
|
796
|
+
fi
|
|
797
|
+
# ---- where it runs, and what its verify command is ----
|
|
798
|
+
_json_keycount "$ti" run_in_background
|
|
799
|
+
if [ "$_KC" -gt 1 ]; then echo "GUARD (agent model): this call names run_in_background more than once, so where it runs cannot be read and the call is refused. Send one." >&2; _am_log "an ambiguous call"; exit 2; fi
|
|
800
|
+
_json_find "$ti" '"run_in_background"'; bg=unset
|
|
801
|
+
if [ "$_KC" = 1 ] && [ "$_JF" -ge 0 ]; then f="${ti:_JF+19:12}"; f="${f//[$' \t\n\r']/}"; case "$f" in :false*) bg=false ;; :true*) bg=true ;; esac; fi
|
|
802
|
+
if [ "$CARD_RISK" = critical ] && [ "$CARD_VERIFY" != none ] && [ "$bg" != false ]; then
|
|
803
|
+
echo "GUARD (agent model): critical work with a verify command runs in the foreground, where its result comes back to you: repeat the same call with run_in_background set to false (left out, an agent starts in the background)." >&2
|
|
804
|
+
_am_log "critical work not in the foreground"; exit 2
|
|
805
|
+
fi
|
|
806
|
+
# The verify command is NOT judged here. Whether the hook runs it is decided when it would start (_am_verify_ok, in
|
|
807
|
+
# hooks/agent-outcome.sh): a command it will not run is recorded as blocked and the session is told to run it
|
|
808
|
+
# itself with the Bash tool, where the permission layer and the shell gate see it. Refusing the call for it would
|
|
809
|
+
# leave no way to use a verify command in a mode where nothing is allowed by name.
|
|
810
|
+
# ---- the record of this call, for the agent it starts ----
|
|
811
|
+
_json_slice "$INPUT" tool_use_id >/dev/null; tid="$_JS"; case "$tid" in ''|*[!A-Za-z0-9._-]*) tid="x$RANDOM" ;; esac
|
|
812
|
+
if mkdir -p "$_AMD/pending" 2>/dev/null; then
|
|
813
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v t '%(%s)T' -1; else t="$(date +%s)"; fi
|
|
814
|
+
{ printf 'agent=%s\ntier=%s\nchange=%s\nrisk=%s\ncard=%s\nbg=%s\nesc=%s\nverify=%s\nfiles=%s\n' "$st" "$m" "$CARD_CHANGE" "$CARD_RISK" "$CARD_ID" "$bg" "${p:--}" "$CARD_VERIFY" "$CARD_FILES" > "$_AMD/pending/$t-$tid"; } 2>/dev/null || true
|
|
815
|
+
fi
|
|
816
|
+
exit 0
|
|
817
|
+
}
|
|
818
|
+
_gate_main "$@"
|
|
819
|
+
_crew_stop "a command of the gate was abandoned"
|