crewforth 3.1.0-rc.5 → 3.1.0-rc.7
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 +76 -12
- package/README.md +3 -2
- package/kit/AGENT_TEMPLATE.md +41 -21
- package/kit/CLAUDE.md +9 -7
- package/kit/agents/crew-backend-expert.md +4 -1
- package/kit/agents/crew-commit-agent.md +8 -3
- package/kit/agents/crew-database-expert.md +4 -1
- package/kit/agents/crew-devops-expert.md +4 -1
- package/kit/agents/crew-frontend-expert.md +4 -1
- package/kit/agents/crew-performance-expert.md +4 -1
- package/kit/agents/crew-planner.md +10 -1
- package/kit/agents/crew-privacy-agent.md +4 -1
- package/kit/agents/crew-review-agent.md +4 -1
- package/kit/agents/crew-security-expert.md +4 -1
- package/kit/agents/crew-session-manager.md +4 -1
- package/kit/agents/crew-test-expert.md +4 -1
- package/kit/eval/smoke-test.sh +860 -27
- package/kit/hooks/agent-outcome.sh +437 -0
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-agent-model.sh +494 -60
- package/kit/hooks/guard-bash.sh +69 -8
- package/kit/hooks/guard-schedule.sh +8 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +94 -7
- package/kit/settings.json +43 -0
- package/kit/skills/crew-approve/SKILL.md +4 -1
- 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 +85 -6
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +45 -7
- 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 +8 -1
- package/kit/studio/web/app.js +203 -32
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +91 -42
- package/kit/studio/web/chat.js +11 -1
- package/kit/studio/web/convo.js +9 -0
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +29 -9
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +27 -5
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +30 -0
- package/kit/studio/web/style.css +85 -9
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +26 -7
- package/package.json +1 -1
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# guard-agent-model.sh - a crew agent is called with a model, and
|
|
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
3
|
#
|
|
4
|
-
# WHY. The model for a piece of work is chosen per call, by risk (the table in CLAUDE.md)
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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 is not sent to the background (run_in_background true), where its
|
|
25
|
+
# result does not reach the session. A call that leaves the field out passes: not every Agent tool has it.
|
|
26
|
+
# THE WORDING IS READ BY ANOTHER PROGRAM (the Studio panel): a refusal is one line that begins
|
|
27
|
+
# `GUARD (agent model):`, and a floor is said as ` runs on <model> or above`. The suite pins both.
|
|
28
|
+
#
|
|
29
|
+
# ALSO A LIBRARY. hooks/guard-write.sh sources this file for the check at write time (an agent that is not on opus
|
|
30
|
+
# does not write to a critical path, whatever its card said), and hooks/agent-outcome.sh for the card, the class and
|
|
31
|
+
# the state. Sourced, the file defines its functions and returns before it reads anything.
|
|
16
32
|
#
|
|
17
33
|
# NOT TOUCHED: an agent that is not crew-* (Explore, general-purpose, a project's own), and any tool but Agent.
|
|
18
34
|
# SWITCHES, read from the session's environment:
|
|
19
|
-
# CREW_MODEL_ROUTING=off
|
|
35
|
+
# CREW_MODEL_ROUTING=off nothing here does anything, which is how it was before 3.1.0.
|
|
20
36
|
# CREW_ALLOW_FABLE=1 `fable` may be named; without it a call that names it is refused.
|
|
21
|
-
# HONEST SCOPE:
|
|
22
|
-
#
|
|
37
|
+
# HONEST SCOPE: the card is written by the caller and can be wrong. The write-time check and the verify command are
|
|
38
|
+
# what bound that; neither reads intent.
|
|
23
39
|
set -uo pipefail
|
|
24
40
|
# ---- CREW-LOCALE -----------------------------------------------------------------------------------------
|
|
25
41
|
# Everything this gate matches with runs in the C locale, whatever the session's own is. Under a Turkish locale
|
|
@@ -40,30 +56,6 @@ _CREW_LOCALE="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
|
|
|
40
56
|
case "$_CREW_LOCALE" in C|POSIX) _CREW_LOCALE="" ;; esac
|
|
41
57
|
export LC_ALL=C
|
|
42
58
|
# ---- /CREW-LOCALE
|
|
43
|
-
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
44
|
-
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
45
|
-
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
46
|
-
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
47
|
-
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
48
|
-
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
49
|
-
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
50
|
-
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
51
|
-
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
52
|
-
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
53
|
-
# that is not found (status 127, the script continues inside the same command).
|
|
54
|
-
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
55
|
-
_crew_stop(){ # $1 = what happened
|
|
56
|
-
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
57
|
-
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
|
|
58
|
-
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
|
|
59
|
-
exit 2
|
|
60
|
-
}
|
|
61
|
-
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
62
|
-
# ---- /CREW-FAILCLOSED
|
|
63
|
-
_gate_main(){
|
|
64
|
-
local tn st m ti rank=0 floor=0 fname=""
|
|
65
|
-
[ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
|
|
66
|
-
IFS= read -r -d '' INPUT || true
|
|
67
59
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
68
60
|
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
69
61
|
# `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
|
|
@@ -342,6 +334,384 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
342
334
|
done
|
|
343
335
|
}
|
|
344
336
|
# ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
|
|
337
|
+
_AM_LIB=0; [ "${BASH_SOURCE[0]}" != "$0" ] && _AM_LIB=1
|
|
338
|
+
# ---- state: where the records of this gate and of agent-outcome.sh live ----------------------------------------
|
|
339
|
+
_am_state(){ # -> _AMP (the project), _AMS (.claude/state), _AMD (the records of calls, agents and results)
|
|
340
|
+
_AMP="${CLAUDE_PROJECT_DIR:-$PWD}"; _AMP="${_AMP//\\//}"; _AMP="${_AMP%/}"
|
|
341
|
+
_AMS="$_AMP/.claude/state"; _AMD="$_AMS/crew-model"
|
|
342
|
+
}
|
|
343
|
+
_am_rank(){ # $1 = haiku|sonnet|opus|fable -> _AMR 1..4, 0 for anything else
|
|
344
|
+
case "$1" in haiku) _AMR=1 ;; sonnet) _AMR=2 ;; opus) _AMR=3 ;; fable) _AMR=4 ;; *) _AMR=0 ;; esac; }
|
|
345
|
+
_am_name(){ # $1 = 1..4 -> _AMN
|
|
346
|
+
case "$1" in 1) _AMN=haiku ;; 2) _AMN=sonnet ;; 3) _AMN=opus ;; 4) _AMN=fable ;; *) _AMN="" ;; esac; }
|
|
347
|
+
_am_tier(){ # $1 = a model as a call or a transcript names it -> _AMT haiku|sonnet|opus|fable, "" unknown
|
|
348
|
+
local nc=0; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
349
|
+
case "$1" in *haiku*) _AMT=haiku ;; *sonnet*) _AMT=sonnet ;; *opus*) _AMT=opus ;; *fable*) _AMT=fable ;; *) _AMT="" ;; esac
|
|
350
|
+
[ "$nc" = 0 ] && shopt -u nocasematch; return 0; }
|
|
351
|
+
|
|
352
|
+
# ---- CRITICAL PATHS ---------------------------------------------------------------------------------------------
|
|
353
|
+
# One definition, asked of a card's files (this gate), of the file an agent is about to write (guard-write.sh) and
|
|
354
|
+
# of every file of the project (the scan in agent-outcome.sh). A path is critical when one of its parts is one of
|
|
355
|
+
# these WORDS, standing as a word: after a separator or at a capital (`auth/`, `user_auth.py`, `AuthController.cs`,
|
|
356
|
+
# `userAuth.ts`, `AUTH_KEYS`) and not running on in lower case (`author.md`, `cryptography/` are not). And by name:
|
|
357
|
+
# a .sql file, schema.prisma. The path is read from the project's root down, so the name of the folder the project
|
|
358
|
+
# sits in decides nothing. .claude/crew-model-rules, which only the user writes, adds (`+ glob`) and removes
|
|
359
|
+
# (`- glob`); the last line that matches wins.
|
|
360
|
+
_AM_W_L='auth|authn|authz|authentication|authorization|oauth|payments?|billing|migrations?|security|crypto|secrets?'
|
|
361
|
+
_AM_W_C='Auth|Authn|Authz|Authentication|Authorization|OAuth|Payments?|Billing|Migrations?|Security|Crypto|Secrets?'
|
|
362
|
+
_AM_W_U='AUTH|AUTHN|AUTHZ|OAUTH|PAYMENTS?|BILLING|MIGRATIONS?|SECURITY|CRYPTO|SECRETS?'
|
|
363
|
+
_AM_RE_L="(^|[^A-Za-z])($_AM_W_L)([^a-z]|\$)"
|
|
364
|
+
_AM_RE_C="(^|[^A-Z])($_AM_W_C)([^a-z]|\$)"
|
|
365
|
+
_AM_RE_U="(^|[^A-Za-z])($_AM_W_U)([^A-Za-z]|\$)"
|
|
366
|
+
_AM_RULES_READ=0; _AM_RULES=()
|
|
367
|
+
_am_rules(){ # reads .claude/crew-model-rules once -> _AM_RULES: "+<TAB>glob", "-<TAB>glob", "f<TAB>agent change risk tier", "v<TAB>command"
|
|
368
|
+
local l a b c d
|
|
369
|
+
[ "$_AM_RULES_READ" = 1 ] && return 0
|
|
370
|
+
_AM_RULES_READ=1; _AM_RULES=()
|
|
371
|
+
[ -n "${_AMP:-}" ] || _am_state
|
|
372
|
+
[ -f "$_AMP/.claude/crew-model-rules" ] || return 0
|
|
373
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
374
|
+
l="${l%$'\r'}"; l="${l%%#*}"
|
|
375
|
+
while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
|
|
376
|
+
while :; do case "$l" in *[$' \t']) l="${l%?}" ;; *) break ;; esac; done
|
|
377
|
+
case "$l" in
|
|
378
|
+
"+ "*|"+"$'\t'*) l="${l:1}"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done; _AM_RULES+=("+"$'\t'"$l") ;;
|
|
379
|
+
"- "*|"-"$'\t'*) l="${l:1}"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done; _AM_RULES+=("-"$'\t'"$l") ;;
|
|
380
|
+
"verify "*) l="${l#verify }"; while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
|
|
381
|
+
[ -n "$l" ] && case "$l" in *[\;\&\|\<\>\`\$]*) ;; *) _AM_RULES+=("v"$'\t'"$l") ;; esac ;;
|
|
382
|
+
"floor "*) IFS=$' \t' read -r a b c d _ <<< "${l#floor }"; _am_tier "${d:-}"
|
|
383
|
+
[ -n "$_AMT" ] && [ -n "${c:-}" ] && _AM_RULES+=("f"$'\t'"$a $b $c $_AMT") ;;
|
|
384
|
+
esac
|
|
385
|
+
done < "$_AMP/.claude/crew-model-rules"
|
|
386
|
+
}
|
|
387
|
+
_am_rel(){ # $1 = a path as a card or a tool names it -> _AMRP, from the project's root where it is under it
|
|
388
|
+
local p="${1//\\//}"
|
|
389
|
+
[ -n "${_AMP:-}" ] || _am_state
|
|
390
|
+
case "$p" in "$_AMP"/*) p="${p#"$_AMP"/}" ;; ./*) p="${p#./}" ;; esac
|
|
391
|
+
_AMRP="$p"
|
|
392
|
+
}
|
|
393
|
+
_am_crit_path(){ # $1 = a path or a glob -> 0 critical (_AMW says by what), 1 not
|
|
394
|
+
local p r k g hit=1
|
|
395
|
+
_am_rel "$1"; p="$_AMRP"; _AMW=""
|
|
396
|
+
if [[ "$p" =~ $_AM_RE_L ]] || [[ "$p" =~ $_AM_RE_C ]] || [[ "$p" =~ $_AM_RE_U ]]; then hit=0; _AMW="${BASH_REMATCH[2]}"
|
|
397
|
+
else case "$p" in *.sql|*.SQL) hit=0; _AMW="a .sql file" ;; schema.prisma|*/schema.prisma) hit=0; _AMW="schema.prisma" ;; esac; fi
|
|
398
|
+
_am_rules
|
|
399
|
+
for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
|
|
400
|
+
k="${r%%$'\t'*}"; g="${r#*$'\t'}"
|
|
401
|
+
case "$k" in +|-) ;; *) continue ;; esac
|
|
402
|
+
# A rule's glob matches the path, anything under it, and the same at any depth.
|
|
403
|
+
case "$p" in $g|$g/*|*/$g|*/$g/*) if [ "$k" = + ]; then hit=0; _AMW="crew-model-rules: + $g"; else hit=1; _AMW=""; fi ;; esac
|
|
404
|
+
done
|
|
405
|
+
return "$hit"
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# ---- THE CARD ---------------------------------------------------------------------------------------------------
|
|
409
|
+
_AM_CHANGES=' text feature fix-known fix-unknown refactor migration security architecture test-run test-write audit '
|
|
410
|
+
_am_card(){ # $1 = a task text -> 0 and CARD_FILES, CARD_CHANGE, CARD_VERIFY; 1 and CARD_WHY
|
|
411
|
+
local s="$1" l k v n=0 seen=""
|
|
412
|
+
CARD_FILES=""; CARD_CHANGE=""; CARD_VERIFY=""; CARD_WHY=""
|
|
413
|
+
s="${s//$'\r'/}"
|
|
414
|
+
while [ "$n" -lt 3 ] && [ -n "$s" ]; do
|
|
415
|
+
l="${s%%$'\n'*}"; if [ "$l" = "$s" ]; then s=""; else s="${s#*$'\n'}"; fi
|
|
416
|
+
while :; do case "$l" in [$' \t']*) l="${l:1}" ;; *) break ;; esac; done
|
|
417
|
+
[ -n "$l" ] || continue
|
|
418
|
+
n=$((n+1))
|
|
419
|
+
case "$l" in *:*) ;; *) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;; esac
|
|
420
|
+
k="${l%%:*}"; v="${l#*:}"
|
|
421
|
+
while :; do case "$v" in [$' \t']*) v="${v:1}" ;; *) break ;; esac; done
|
|
422
|
+
while :; do case "$v" in *[$' \t']) v="${v%?}" ;; *) break ;; esac; done
|
|
423
|
+
case "$k" in
|
|
424
|
+
files) CARD_FILES="$v" ;;
|
|
425
|
+
change) CARD_CHANGE="$v" ;;
|
|
426
|
+
verify) CARD_VERIFY="$v" ;;
|
|
427
|
+
*) CARD_WHY="line $n of the task is not one of files:, change:, verify:"; return 1 ;;
|
|
428
|
+
esac
|
|
429
|
+
case "$seen" in *" $k "*) CARD_WHY="the card names $k twice"; return 1 ;; esac
|
|
430
|
+
seen="$seen $k "
|
|
431
|
+
done
|
|
432
|
+
[ "$n" = 3 ] || { CARD_WHY="the task does not begin with the three lines files:, change:, verify:"; return 1; }
|
|
433
|
+
[ -n "$CARD_FILES" ] || { CARD_WHY="files: is empty (name the files or globs, or the folder)"; return 1; }
|
|
434
|
+
case "$_AM_CHANGES" in *" $CARD_CHANGE "*) ;; *) CARD_WHY="change: is '${CARD_CHANGE:0:40}', not one of:$_AM_CHANGES"; return 1 ;; esac
|
|
435
|
+
[ -n "$CARD_VERIFY" ] || { CARD_WHY="verify: is empty (a command, or none)"; return 1; }
|
|
436
|
+
case "$CARD_VERIFY" in none|None|NONE|-) CARD_VERIFY=none ;; esac
|
|
437
|
+
return 0
|
|
438
|
+
}
|
|
439
|
+
_am_risk(){ # after _am_card -> CARD_RISK critical|normal, CARD_RISKWHY
|
|
440
|
+
local f unglob=0
|
|
441
|
+
CARD_RISK=normal; CARD_RISKWHY=""
|
|
442
|
+
case " migration security architecture fix-unknown " in *" $CARD_CHANGE "*) CARD_RISK=critical; CARD_RISKWHY="change: $CARD_CHANGE" ;; esac
|
|
443
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
444
|
+
for f in ${CARD_FILES//,/ }; do
|
|
445
|
+
if _am_crit_path "$f"; then CARD_RISK=critical; [ -n "$CARD_RISKWHY" ] || CARD_RISKWHY="$f is on a critical path: $_AMW"; fi
|
|
446
|
+
done
|
|
447
|
+
[ "$unglob" = 1 ] && set +f
|
|
448
|
+
return 0
|
|
449
|
+
}
|
|
450
|
+
_am_digest(){ # after _am_card -> CARD_ID: 12 hex of what the card says, so "the same card" is the same value
|
|
451
|
+
local t
|
|
452
|
+
t="$(printf '%s\n%s\n%s\n' "$CARD_FILES" "$CARD_CHANGE" "$CARD_VERIFY" | git hash-object --stdin 2>/dev/null)" || t=""
|
|
453
|
+
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
|
|
454
|
+
CARD_ID="${t:0:12}"
|
|
455
|
+
}
|
|
456
|
+
_am_rec_get(){ # $1 = a record file, $2 = key -> _AMV (lines are key=value)
|
|
457
|
+
local l; _AMV=""
|
|
458
|
+
[ -f "$1" ] || return 1
|
|
459
|
+
while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$2="*) _AMV="${l#*=}"; return 0 ;; esac; done < "$1"
|
|
460
|
+
return 1
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# ---- THE CALIBRATION FLOOR IN FORCE for a class ------------------------------------------------------------------
|
|
464
|
+
# hooks/agent-outcome.sh raises it (crew-model-floors.auto: agent, change, risk, model, calls, not-first-try, when);
|
|
465
|
+
# the user lowers it, one model, once for each raise, with /crew-loosen (crew-model-loosened.tsv: ts, agent, change,
|
|
466
|
+
# risk, from, to; written by hooks/prompt-approval.sh from the user's own message). A lowering counts when it is
|
|
467
|
+
# NEWER than the raise it answers: raised again afterwards, the floor is the raised one. One function, asked by this
|
|
468
|
+
# gate, by the hook that raises, and by the hook that records the lowering.
|
|
469
|
+
_am_floor_now(){ # $1 agent, $2 change, $3 risk -> _AMF_TIER (the raised floor, "" none), _AMF_WHEN, _AMF_LOOSE 0|1,
|
|
470
|
+
# _AMF_TO and _AMF_LTS (the lowering that is in force), _AMF_EFF (the floor that holds now, "" none)
|
|
471
|
+
local a b c t n bad w ts la lb lc lf lt
|
|
472
|
+
_AMF_TIER=""; _AMF_WHEN=""; _AMF_LOOSE=0; _AMF_TO=""; _AMF_LTS=""; _AMF_EFF=""
|
|
473
|
+
[ -n "${_AMS:-}" ] || _am_state
|
|
474
|
+
[ -f "$_AMS/crew-model-floors.auto" ] || return 0
|
|
475
|
+
while IFS=$'\t' read -r a b c t n bad w || [ -n "$a" ]; do
|
|
476
|
+
[ "$a" = "$1" ] && [ "$b" = "$2" ] && [ "$c" = "$3" ] || continue
|
|
477
|
+
_AMF_TIER="${t%$'\r'}"; _AMF_WHEN="${w%$'\r'}"
|
|
478
|
+
done < "$_AMS/crew-model-floors.auto"
|
|
479
|
+
[ -n "$_AMF_TIER" ] || return 0
|
|
480
|
+
_AMF_EFF="$_AMF_TIER"
|
|
481
|
+
[ -f "$_AMS/crew-model-loosened.tsv" ] || return 0
|
|
482
|
+
while IFS=$'\t' read -r ts la lb lc lf lt || [ -n "$ts" ]; do
|
|
483
|
+
[ "$la" = "$1" ] && [ "$lb" = "$2" ] && [ "$lc" = "$3" ] || continue
|
|
484
|
+
lt="${lt%$'\r'}"
|
|
485
|
+
# ISO 8601 in UTC sorts as text. Only a lowering of THIS raise counts: not older than it (the same second is a
|
|
486
|
+
# lowering of it; the hook that raises again never stamps the second of a lowering), from its model, one down.
|
|
487
|
+
[[ "$ts" < "$_AMF_WHEN" ]] && continue
|
|
488
|
+
[ "$lf" = "$_AMF_TIER" ] || continue
|
|
489
|
+
_am_rank "$lf"; n="$_AMR"; _am_rank "$lt"; [ "$_AMR" -gt 0 ] && [ "$_AMR" = $((n-1)) ] || continue
|
|
490
|
+
_AMF_LOOSE=1; _AMF_TO="$lt"; _AMF_LTS="$ts"; _AMF_EFF="$lt"
|
|
491
|
+
done < "$_AMS/crew-model-loosened.tsv"
|
|
492
|
+
return 0
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
# ---- the gate log: a refusal of this gate is recorded like any other gate's -------------------------------------
|
|
496
|
+
_am_log(){ # $1 = the rule. Same file and line shape as guard-bash.sh's gatelog; the call's text is never recorded.
|
|
497
|
+
local gl="${CREW_GATE_LOG:-}" v=BLOCK
|
|
498
|
+
case "$1" in "note: "*) v=NOTE ;; esac # something worth knowing that refused nothing
|
|
499
|
+
if [ -z "$gl" ]; then
|
|
500
|
+
[ -d ".claude" ] || return 0
|
|
501
|
+
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
|
|
502
|
+
gl=".claude/gate-log.tsv"
|
|
503
|
+
fi
|
|
504
|
+
printf '%s\t§model\t%s\t\n' "$v" "$1" >> "$gl" 2>/dev/null || true
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# ---- IS THIS VERIFY COMMAND ONE A HOOK MAY RUN? ------------------------------------------------------------------
|
|
508
|
+
# The command comes from the card, which the session wrote, and hooks/agent-outcome.sh would run it when the agent
|
|
509
|
+
# stops: outside any tool call, so past the permission rules, the auto-mode classifier and every PreToolUse gate.
|
|
510
|
+
# A list of what must NOT run cannot close that (the shell gate is such a list, and `python3 -c`, `node -e`, `npx`,
|
|
511
|
+
# `find -delete`, a wrapper like `timeout`, and a runner's own options such as `go test -exec` or `make SHELL=…`
|
|
512
|
+
# all pass it; found in review). So the rule is what MAY run, and everything else is left for the session to run
|
|
513
|
+
# itself, with the Bash tool, where the permission layer sees it. One function, asked at the moment the command
|
|
514
|
+
# would start; the rules, in this order:
|
|
515
|
+
# 1. ONE command: no `;`, `&`, `|`, `<`, `>`, backtick, `$(` or second line.
|
|
516
|
+
# 2. Never a git commit or a git push: an approval the user gave the SESSION is not for a hook.
|
|
517
|
+
# 3. NOT DENIED. A `Bash(...)` rule of permissions.deny or permissions.ask that covers it, in ANY settings file
|
|
518
|
+
# (the project's two, the user's, a managed one), stops it: a hook cannot ask, and a deny is a deny. What
|
|
519
|
+
# cannot be read stops it too: a settings file that is there and does not parse (a comment, a byte-order
|
|
520
|
+
# mark), and a deny or ask rule in a form this reader does not match (a `*` that is not at the end, as in
|
|
521
|
+
# `Bash(git * --force)`). Not knowing what is denied is not the same as nothing being denied.
|
|
522
|
+
# 4. ALLOWED BY NAME, one of:
|
|
523
|
+
# a. a line `verify <command>` of .claude/crew-model-rules (the user's file);
|
|
524
|
+
# b. a `Bash(...)` rule of permissions.allow in any of those settings files (a bare `Bash` allows nothing);
|
|
525
|
+
# c. ONLY in `auto` and `bypassPermissions`, the two modes in which Claude Code itself runs a test command
|
|
526
|
+
# without the user having allowed it: a test or build runner from the list below, with arguments that
|
|
527
|
+
# are paths or test names (no argument that begins with `-` or holds `=`: an option can name a program
|
|
528
|
+
# to run), and for make, mvn and gradle no argument at all (a second target is a second task). In
|
|
529
|
+
# `default`, `acceptEdits` and `plan` Claude Code asks first, and `dontAsk` refuses whatever was not
|
|
530
|
+
# allowed beforehand, so there the list allows nothing and only a or b does.
|
|
531
|
+
# 5. THE SHELL GATE AGREES, asked under a session id that is not the session's: only "nothing to say" or an
|
|
532
|
+
# explicit allow passes.
|
|
533
|
+
_AM_HERE="${BASH_SOURCE%/*}"; [ "$_AM_HERE" = "${BASH_SOURCE}" ] && _AM_HERE=.
|
|
534
|
+
# CREW-NOT-A-RUNG: names of commands a PROJECT's verify line may begin with; nothing here is called by Crewforth.
|
|
535
|
+
_AM_RUNNERS=('npm test' 'npm t' 'pnpm test' 'yarn test' 'bun test' 'deno test'
|
|
536
|
+
'dotnet test' 'dotnet build' 'flutter test' 'flutter analyze' 'dart test' 'dart analyze' 'go test' 'go build' 'go vet'
|
|
537
|
+
'cargo test' 'cargo build' 'cargo check' 'cargo clippy' 'pytest' 'python -m pytest' 'python3 -m pytest'
|
|
538
|
+
'python -m unittest' 'python3 -m unittest' 'mvn test' 'mvn verify' './mvnw test' './mvnw verify' 'gradle test'
|
|
539
|
+
'gradle check' 'gradle build' './gradlew test' './gradlew check' './gradlew build' 'make test' 'make check'
|
|
540
|
+
'make lint' 'make build' 'tsc' 'eslint' 'ruff check' 'mypy' 'phpunit' 'rspec' 'bundle exec rspec' 'swift test'
|
|
541
|
+
'swift build' 'ctest' 'jest' 'vitest' 'mocha' 'mix test' 'composer test' 'test' 'true')
|
|
542
|
+
# /CREW-NOT-A-RUNG
|
|
543
|
+
_am_starts(){ # $1 = command, $2 = prefix -> 0 when the command is the prefix or the prefix and more words
|
|
544
|
+
case "$1" in "$2"|"$2 "*) return 0 ;; esac; return 1; }
|
|
545
|
+
_am_plain_args(){ # $1 = runner, $2 = what follows it -> 0 when every word is a path or a test name
|
|
546
|
+
local w unglob=0 rc=0
|
|
547
|
+
# For these a further word is a further TASK: `make test deploy`, `gradle build publish`, `mvn test install`.
|
|
548
|
+
case "$1" in make\ *|mvn\ *|./mvnw\ *|gradle\ *|./gradlew\ *) [ -z "$2" ]; return ;; esac
|
|
549
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
550
|
+
for w in $2; do
|
|
551
|
+
# dotnet takes a project, a solution or a folder and nothing else: a bare word there is a verb or a target.
|
|
552
|
+
case "$1" in dotnet\ *) case "$w" in */*|*.*) ;; *) rc=1 ;; esac ;; esac
|
|
553
|
+
case "$w" in
|
|
554
|
+
-[A-Za-z]) [ "$1" = test ] || rc=1 ;; # `test -f x`: the one runner whose flags are its questions
|
|
555
|
+
-*|*=*) rc=1 ;;
|
|
556
|
+
*[!A-Za-z0-9_./:@,+%^~\#\[\]\*\?-]*) rc=1 ;;
|
|
557
|
+
esac
|
|
558
|
+
done
|
|
559
|
+
[ "$unglob" = 1 ] && set +f
|
|
560
|
+
return "$rc"
|
|
561
|
+
}
|
|
562
|
+
_am_settings_files(){ # -> _AMSF: the settings files that exist, one per line: project, local, user, managed
|
|
563
|
+
local f u; _AMSF=""
|
|
564
|
+
# The user's settings are where Claude Code keeps them: CLAUDE_CONFIG_DIR when it is set, ~/.claude otherwise.
|
|
565
|
+
u="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}"; u="${u//\\//}"
|
|
566
|
+
for f in "$_AMP/.claude/settings.json" "$_AMP/.claude/settings.local.json" "${u:+$u/settings.json}" \
|
|
567
|
+
"/Library/Application Support/ClaudeCode/managed-settings.json" "/etc/claude-code/managed-settings.json" \
|
|
568
|
+
"/c/Program Files/ClaudeCode/managed-settings.json" "/c/ProgramData/ClaudeCode/managed-settings.json" \
|
|
569
|
+
"${CREW_MANAGED_SETTINGS:-}"; do # one more file to read as managed: it can only add rules
|
|
570
|
+
[ -n "$f" ] && [ -f "$f" ] && _AMSF="$_AMSF$f"$'\n'
|
|
571
|
+
done
|
|
572
|
+
}
|
|
573
|
+
_am_perm_hit(){ # $1 = command, $2 = allow|deny|ask -> 0 a Bash(...) rule of that list covers it (_AMPH names it);
|
|
574
|
+
# 1 none does; 2 the list cannot be relied on (_AMPH says why): a file that does not parse, or,
|
|
575
|
+
# for deny and ask, a rule in a form this reader does not match
|
|
576
|
+
local c="$1" list="$2" sj="$_AM_HERE/../eval/lib/settings-json.awk" f x g out
|
|
577
|
+
_AMPH=""
|
|
578
|
+
while IFS= read -r f; do
|
|
579
|
+
[ -n "$f" ] || continue
|
|
580
|
+
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
|
|
581
|
+
out="$(awk -v op=strings -v path="permissions.$list" -f "$sj" "$f" 2>/dev/null)" || out=""
|
|
582
|
+
while IFS= read -r x; do
|
|
583
|
+
x="${x%$'\r'}"; [ -n "$x" ] || continue
|
|
584
|
+
if [ "$x" = Bash ]; then [ "$list" = allow ] && continue; _AMPH="$list: Bash (${f##*/})"; return 0; fi
|
|
585
|
+
case "$x" in 'Bash('*')') ;; *) continue ;; esac
|
|
586
|
+
x="${x#Bash(}"; x="${x%)}"; g=""
|
|
587
|
+
case "$x" in
|
|
588
|
+
''|'*'|':*') [ "$list" = allow ] && continue; _AMPH="$list: Bash($x) (${f##*/})"; return 0 ;;
|
|
589
|
+
esac
|
|
590
|
+
# A `*` anywhere but at the end is a form this reader does not match. An allow rule it cannot read allows
|
|
591
|
+
# nothing; a deny or an ask rule it cannot read may cover the command, so the list cannot be relied on.
|
|
592
|
+
g="${x%:\*}"; g="${g% \*}"; g="${g%\*}"
|
|
593
|
+
case "$g" in *'*'*) [ "$list" = allow ] && continue; _AMPH="$list: Bash($x) (${f##*/}) is a form this hook does not match"; return 2 ;; esac
|
|
594
|
+
case "$x" in
|
|
595
|
+
*':*') g="${x%:\*}"; _am_starts "$c" "$g" || continue ;;
|
|
596
|
+
*' *') g="${x% \*}"; _am_starts "$c" "$g" || continue ;;
|
|
597
|
+
*'*') g="${x%\*}"; case "$c" in "$g"*) ;; *) continue ;; esac ;;
|
|
598
|
+
*) [ "$c" = "$x" ] || continue ;;
|
|
599
|
+
esac
|
|
600
|
+
_AMPH="$list: Bash($x) (${f##*/})"; return 0
|
|
601
|
+
done <<< "$out"
|
|
602
|
+
done <<< "$_AMSF"
|
|
603
|
+
return 1
|
|
604
|
+
}
|
|
605
|
+
_am_verify_ok(){ # $1 = the command, $2 = permission mode -> 0 it may run (_AMVB says by what); 1 and _AMVW says why not
|
|
606
|
+
local c="$1" pm="${2:-default}" f r rc k g
|
|
607
|
+
_AMVW=""; _AMVB=""
|
|
608
|
+
case "$c" in ''|none) return 0 ;; esac
|
|
609
|
+
case "$c" in
|
|
610
|
+
*$'\n'*|*$'\r'*) _AMVW="it is more than one line"; return 1 ;;
|
|
611
|
+
*';'*|*'&'*|*'|'*|*'<'*|*'>'*|*'`'*|*'$('*) _AMVW="it chains, pipes, redirects or substitutes (; & | < > \` \$( )"; return 1 ;;
|
|
612
|
+
*[$'\001'-$'\010'$'\013'-$'\037']*) _AMVW="it holds a control character"; return 1 ;;
|
|
613
|
+
esac
|
|
614
|
+
c="${c//$'\t'/ }"; while :; do case "$c" in *' '*) c="${c// / }" ;; *) break ;; esac; done
|
|
615
|
+
c="${c# }"; c="${c% }"
|
|
616
|
+
if [[ " $c " =~ [[:space:]/]git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)[[:space:]] ]]; then
|
|
617
|
+
_AMVW="a hook never commits or pushes: the user's approval is for the session's own call"; return 1
|
|
618
|
+
fi
|
|
619
|
+
[ -n "${_AMP:-}" ] || _am_state
|
|
620
|
+
[ -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; }
|
|
621
|
+
_am_settings_files
|
|
622
|
+
_am_perm_hit "$c" deny; r=$?
|
|
623
|
+
if [ "$r" = 0 ]; then _AMVW="a permission rule denies it ($_AMPH)"; return 1; fi
|
|
624
|
+
if [ "$r" = 2 ]; then _AMVW="what is denied cannot be known ($_AMPH), so nothing is run by the hook"; return 1; fi
|
|
625
|
+
_am_perm_hit "$c" ask; r=$?
|
|
626
|
+
if [ "$r" = 0 ]; then _AMVW="a permission rule says to ask the user first ($_AMPH), and a hook cannot ask"; return 1; fi
|
|
627
|
+
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
|
|
628
|
+
_am_rules
|
|
629
|
+
for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
|
|
630
|
+
k="${r%%$'\t'*}"; g="${r#*$'\t'}"; [ "$k" = v ] || continue
|
|
631
|
+
_am_starts "$c" "$g" && { _AMVB="crew-model-rules: verify $g"; break; }
|
|
632
|
+
done
|
|
633
|
+
if [ -z "$_AMVB" ]; then _am_perm_hit "$c" allow; [ "$?" = 0 ] && _AMVB="permissions.$_AMPH"; fi
|
|
634
|
+
if [ -z "$_AMVB" ]; then
|
|
635
|
+
case "$pm" in
|
|
636
|
+
auto|bypassPermissions)
|
|
637
|
+
for r in "${_AM_RUNNERS[@]}"; do
|
|
638
|
+
_am_starts "$c" "$r" || continue
|
|
639
|
+
g=""; [ "$c" != "$r" ] && g="${c#"$r "}"
|
|
640
|
+
if _am_plain_args "$r" "$g"; then _AMVB="runner: $r"; break; fi
|
|
641
|
+
_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
|
|
642
|
+
done
|
|
643
|
+
[ -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; } ;;
|
|
644
|
+
*) _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 ;;
|
|
645
|
+
esac
|
|
646
|
+
fi
|
|
647
|
+
[ -f "$_AM_HERE/guard-bash.sh" ] || { _AMVW="the shell gate is not beside this hook, so the command cannot be judged"; return 1; }
|
|
648
|
+
case "$pm" in *[!A-Za-z]*|'') pm=default ;; esac
|
|
649
|
+
f="${c//\\/\\\\}"; f="${f//\"/\\\"}"
|
|
650
|
+
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=$?
|
|
651
|
+
if [ "$rc" != 0 ]; then _AMVW="the shell gate refuses it"; return 1; fi
|
|
652
|
+
case "$r" in
|
|
653
|
+
'') return 0 ;;
|
|
654
|
+
*'"permissionDecision"'*'"ask"'*) _AMVW="the shell gate would ask the user about it, and a hook cannot ask"; return 1 ;;
|
|
655
|
+
*'"permissionDecision"'*'"deny"'*) _AMVW="the shell gate denies it"; return 1 ;;
|
|
656
|
+
*'"permissionDecision":"allow"'*|*'"permissionDecision": "allow"'*) return 0 ;;
|
|
657
|
+
*) _AMVW="the shell gate answered something that is not an allow"; return 1 ;;
|
|
658
|
+
esac
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
# ---- AT WRITE TIME (called by guard-write.sh) -------------------------------------------------------------------
|
|
662
|
+
# 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
|
|
663
|
+
# is refused where it happens, and the agent is told to stop and say so. Which model the agent is on: the record
|
|
664
|
+
# made when it was called (matched to it when it started), else what its own transcript names. Not known is not
|
|
665
|
+
# opus. The session's own writes carry no agent_id and are not this check's.
|
|
666
|
+
_am_write_check(){ # $1 = the payload, $2 = the file about to be written -> returns 0, or refuses and exits 2
|
|
667
|
+
local in="$1" fp="$2" aid tp tier="" f m
|
|
668
|
+
[ "${CREW_MODEL_ROUTING:-}" = off ] && return 0
|
|
669
|
+
case "$in" in *'"agent_id"'*) ;; *) return 0 ;; esac
|
|
670
|
+
_am_state
|
|
671
|
+
_am_crit_path "$fp" || return 0
|
|
672
|
+
_json_keycount "$in" agent_id; _json_slice "$in" agent_id >/dev/null; aid="$_JS"
|
|
673
|
+
if [ "$_KC" != 1 ] || [ -z "$aid" ] || [ "${aid//[A-Za-z0-9._-]/}" != "" ]; then
|
|
674
|
+
# The call says it comes from inside an agent and does not say which one. Not known is not opus.
|
|
675
|
+
_am_log "critical path: write by an agent whose id could not be read"
|
|
676
|
+
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
|
|
677
|
+
exit 2
|
|
678
|
+
fi
|
|
679
|
+
if _am_rec_get "$_AMD/agents/$aid" tier; then tier="$_AMV"; fi
|
|
680
|
+
if [ -z "$tier" ]; then
|
|
681
|
+
_json_slice "$in" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
|
|
682
|
+
f="${tp%.jsonl}/subagents/agent-$aid.jsonl"
|
|
683
|
+
if [ -f "$f" ]; then m="$(grep -m1 -o '"model":"[^"]*"' "$f" 2>/dev/null)" || m=""; _am_tier "$m"; tier="$_AMT"; fi
|
|
684
|
+
fi
|
|
685
|
+
case "$tier" in opus|fable) return 0 ;; esac
|
|
686
|
+
_am_log "critical path: write by an agent not on opus"
|
|
687
|
+
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
|
|
688
|
+
exit 2
|
|
689
|
+
}
|
|
690
|
+
[ "$_AM_LIB" = 1 ] && return 0
|
|
691
|
+
# ---- CREW-FAILCLOSED ---------------------------------------------------------------------------------------
|
|
692
|
+
# A gate that stops on an error of its own must refuse the call, not let it through. Claude Code blocks a tool call
|
|
693
|
+
# on exit 2 only; a hook that dies with 1, or runs on past the rule that broke, has allowed it. Both happened in
|
|
694
|
+
# one defect (measured, bash 5): a local array declared and not set is "unbound" under `set -u`, the rule that read
|
|
695
|
+
# it was abandoned in the middle, the script went on with the NEXT top-level command and left with 0, and
|
|
696
|
+
# `rm .claude/hooks/pre-commit` passed. bash does one of two things with such an error, by the kind of error:
|
|
697
|
+
# * it leaves the shell with a status that is neither 0 nor 2 -> the EXIT trap below turns that into a refusal;
|
|
698
|
+
# * it abandons the top-level command it was in and continues -> the whole gate IS one top-level command,
|
|
699
|
+
# _gate_main, which only ever ends by `exit`. If it RETURNS, it was abandoned, and the line after it refuses.
|
|
700
|
+
# Not covered, and said so: an error inside a command substitution (only that subshell ends), and a command
|
|
701
|
+
# that is not found (status 127, the script continues inside the same command).
|
|
702
|
+
# Byte-identical in every PreToolUse gate; the suite pins it. No process is started.
|
|
703
|
+
_crew_stop(){ # $1 = what happened
|
|
704
|
+
declare -F gatelog >/dev/null 2>&1 && gatelog BLOCK 4.5 "the gate stopped on an error of its own"
|
|
705
|
+
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
|
|
706
|
+
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
|
|
707
|
+
exit 2
|
|
708
|
+
}
|
|
709
|
+
trap '_crew_rc=$?; trap - EXIT; case "$_crew_rc" in 0|2) exit "$_crew_rc" ;; esac; _crew_stop "exit status $_crew_rc"' EXIT
|
|
710
|
+
# ---- /CREW-FAILCLOSED
|
|
711
|
+
_gate_main(){
|
|
712
|
+
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
|
|
713
|
+
[ "${CREW_MODEL_ROUTING:-}" = off ] && exit 0
|
|
714
|
+
IFS= read -r -d '' INPUT || true
|
|
345
715
|
_json_slice "$INPUT" tool_name >/dev/null; tn="$_JS"
|
|
346
716
|
case "$tn" in Agent|Task) ;; *) exit 0 ;; esac
|
|
347
717
|
# The call's own fields: everything from "tool_input" on, so a key of the same name in front of it is not read.
|
|
@@ -349,42 +719,106 @@ _json_find "$INPUT" '"tool_input"'
|
|
|
349
719
|
[ "$_JF" -ge 0 ] || exit 0
|
|
350
720
|
ti="${INPUT:_JF}"
|
|
351
721
|
_json_keycount "$ti" subagent_type
|
|
352
|
-
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; exit 2; fi
|
|
722
|
+
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
|
|
353
723
|
_json_slice "$ti" subagent_type >/dev/null; _json_unescape "$_JS" >/dev/null; st="$_JU"
|
|
354
724
|
st="${st##*:}" # a plugin names its agents <plugin>:crew-…
|
|
355
725
|
case "$st" in crew-*) ;; *) exit 0 ;; esac
|
|
726
|
+
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
|
|
356
727
|
_json_keycount "$ti" model
|
|
357
|
-
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; exit 2; fi
|
|
728
|
+
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
|
|
358
729
|
_json_slice "$ti" model >/dev/null; _json_unescape "$_JS" >/dev/null; m="$_JU"
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
'') rank=0 ;;
|
|
362
|
-
*haiku*) rank=1; m=haiku ;;
|
|
363
|
-
*sonnet*) rank=2; m=sonnet ;;
|
|
364
|
-
*opus*) rank=3; m=opus ;;
|
|
365
|
-
*fable*) rank=4; m=fable ;;
|
|
366
|
-
*) rank=-1 ;;
|
|
367
|
-
esac
|
|
368
|
-
shopt -u nocasematch
|
|
730
|
+
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
|
|
731
|
+
# The agent's own floor, whatever the card says.
|
|
369
732
|
case "$st" in
|
|
370
|
-
crew-security-expert) floor=3;
|
|
371
|
-
crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2;
|
|
733
|
+
crew-security-expert) floor=3; why="the security audit" ;;
|
|
734
|
+
crew-privacy-agent|crew-review-agent|crew-planner|crew-database-expert) floor=2; why="this agent's floor" ;;
|
|
372
735
|
esac
|
|
736
|
+
_am_name "$floor"; fname="$_AMN"
|
|
373
737
|
if [ "$rank" = 0 ]; then
|
|
374
738
|
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
|
|
375
|
-
exit 2
|
|
739
|
+
_am_log "no model"; exit 2
|
|
376
740
|
fi
|
|
377
741
|
if [ "$rank" = -1 ]; then
|
|
378
|
-
echo "GUARD (agent model): '$m' 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
|
|
379
|
-
exit 2
|
|
742
|
+
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
|
|
743
|
+
_am_log "unknown model"; exit 2
|
|
380
744
|
fi
|
|
381
745
|
if [ "$rank" = 4 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
|
|
382
746
|
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
|
|
383
|
-
exit 2
|
|
747
|
+
_am_log "fable not allowed"; exit 2
|
|
748
|
+
fi
|
|
749
|
+
# ---- the card ----
|
|
750
|
+
_json_keycount "$ti" prompt
|
|
751
|
+
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
|
|
752
|
+
_json_slice "$ti" prompt >/dev/null; prompt="${_JS:0:6000}"; _json_unescape "$prompt" >/dev/null; prompt="$_JU"
|
|
753
|
+
if ! _am_card "$prompt"; then
|
|
754
|
+
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
|
|
755
|
+
_am_log "no card"; exit 2
|
|
756
|
+
fi
|
|
757
|
+
_am_state; _am_risk; _am_digest
|
|
758
|
+
# ---- the floor of THIS card: the highest of what applies, and the reason that set it ----
|
|
759
|
+
if [ "$CARD_VERIFY" = none ] && [ "$floor" -lt 2 ]; then floor=2; why="no verify command, so nothing checks the work but a reader"; fi
|
|
760
|
+
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
|
|
761
|
+
if [ "$CARD_RISK" = critical ] && [ "$CARD_CHANGE" != test-run ] && [ "$floor" -lt 3 ]; then floor=3; why="critical work: $CARD_RISKWHY"; fi
|
|
762
|
+
# A class the user's rules or the record of outcomes hold higher. Neither can lower anything here.
|
|
763
|
+
_am_rules
|
|
764
|
+
for r in ${_AM_RULES[@]+"${_AM_RULES[@]}"}; do
|
|
765
|
+
case "$r" in f$'\t'*) ;; *) continue ;; esac
|
|
766
|
+
IFS=' ' read -r a b c t <<< "${r#*$'\t'}"
|
|
767
|
+
case "$a" in '*'|"$st") ;; *) continue ;; esac; case "$b" in '*'|"$CARD_CHANGE") ;; *) continue ;; esac; case "$c" in '*'|"$CARD_RISK") ;; *) continue ;; esac
|
|
768
|
+
_am_rank "$t"; if [ "$_AMR" -gt "$floor" ]; then floor="$_AMR"; why="crew-model-rules: floor $a $b $c $t"; fi
|
|
769
|
+
done
|
|
770
|
+
_am_floor_now "$st" "$CARD_CHANGE" "$CARD_RISK"
|
|
771
|
+
if [ -n "$_AMF_EFF" ]; then
|
|
772
|
+
_am_rank "$_AMF_EFF"
|
|
773
|
+
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
|
|
774
|
+
fi
|
|
775
|
+
# A card whose verify failed: not on the same or a lower model again, and not a third time.
|
|
776
|
+
p=""
|
|
777
|
+
if [ -f "$_AMD/fails/$CARD_ID" ]; then
|
|
778
|
+
d=0
|
|
779
|
+
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"
|
|
384
780
|
fi
|
|
781
|
+
if [ "$nf" -ge 2 ]; then
|
|
782
|
+
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
|
|
783
|
+
_am_log "a third run of a failed card"; exit 2
|
|
784
|
+
fi
|
|
785
|
+
if [ "$nf" = 1 ]; then
|
|
786
|
+
_am_rank "$p"; d=$((_AMR+1))
|
|
787
|
+
if [ "$d" -gt 3 ] && [ "${CREW_ALLOW_FABLE:-}" != 1 ]; then
|
|
788
|
+
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
|
|
789
|
+
_am_log "a failed card with no model above"; exit 2
|
|
790
|
+
fi
|
|
791
|
+
if [ "$d" -gt "$floor" ]; then floor="$d"; why="its verify command failed on $p, so the repeat goes one model up"; fi
|
|
792
|
+
fi
|
|
793
|
+
_am_name "$floor"; fname="$_AMN"
|
|
385
794
|
if [ "$rank" -lt "$floor" ]; then
|
|
386
|
-
echo "GUARD (agent model): $st runs on $fname or above, and this call asks for $m. Repeat the same call with model $fname.
|
|
387
|
-
exit 2
|
|
795
|
+
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
|
|
796
|
+
_am_log "below the floor of the card"; exit 2
|
|
797
|
+
fi
|
|
798
|
+
# ---- where it runs, and what its verify command is ----
|
|
799
|
+
_json_keycount "$ti" run_in_background
|
|
800
|
+
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
|
|
801
|
+
_json_find "$ti" '"run_in_background"'; bg=unset
|
|
802
|
+
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
|
|
803
|
+
# Measured on Claude Code 2.1.294 and 2.1.296. Where the Agent tool has run_in_background, a call that leaves it out
|
|
804
|
+
# starts in the background. Where background tasks are off the tool has NO such parameter: the model cannot send it
|
|
805
|
+
# and the call runs in the foreground. The payload of the two is the same, so a call that leaves the field out is
|
|
806
|
+
# let through. (Asking for `false` refused every critical call with a verify command in a session whose tool has
|
|
807
|
+
# no such field, each time.) Only a call that SAYS background is refused; agent-outcome.sh tells the session when a
|
|
808
|
+
# call that left the field out turned out to be launched in the background.
|
|
809
|
+
if [ "$CARD_RISK" = critical ] && [ "$CARD_VERIFY" != none ] && [ "$bg" = true ]; then
|
|
810
|
+
echo "GUARD (agent model): critical work with a verify command is not sent to the background, where its result does not come back to you: repeat the same call with run_in_background set to false." >&2
|
|
811
|
+
_am_log "critical work sent to the background"; exit 2
|
|
812
|
+
fi
|
|
813
|
+
# The verify command is NOT judged here. Whether the hook runs it is decided when it would start (_am_verify_ok, in
|
|
814
|
+
# hooks/agent-outcome.sh): a command it will not run is recorded as blocked and the session is told to run it
|
|
815
|
+
# itself with the Bash tool, where the permission layer and the shell gate see it. Refusing the call for it would
|
|
816
|
+
# leave no way to use a verify command in a mode where nothing is allowed by name.
|
|
817
|
+
# ---- the record of this call, for the agent it starts ----
|
|
818
|
+
_json_slice "$INPUT" tool_use_id >/dev/null; tid="$_JS"; case "$tid" in ''|*[!A-Za-z0-9._-]*) tid="x$RANDOM" ;; esac
|
|
819
|
+
if mkdir -p "$_AMD/pending" 2>/dev/null; then
|
|
820
|
+
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
|
|
821
|
+
{ 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
|
|
388
822
|
fi
|
|
389
823
|
exit 0
|
|
390
824
|
}
|