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
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -1375,7 +1375,7 @@ fi
|
|
|
1375
1375
|
# the shell is Turing-complete, so this is defence-in-depth — guard-write.sh covers the Write/Edit tools (the
|
|
1376
1376
|
# model's natural path to a file), and install-time read-only hook files would be the airtight layer.
|
|
1377
1377
|
_GP='[/\\]+(\.[/\\]+)*' # a path separator as the shell and the filesystem take it: `/`, `\`, doubled, with `/./` between
|
|
1378
|
-
GATE='(\.(claude'"$_GP"'(hooks|git-shim|settings\.json|DISCIPLINE\.md|eval'"$_GP"'lib'"$_GP"'crew-env\.sh)|git'"$_GP"'hooks|git'"$_GP"'(config|worktrees'"$_GP"'[^/\\[:space:]]+'"$_GP"'config|modules'"$_GP"'[^[:space:]]+'"$_GP"'config))|\.gitconfig([^A-Za-z0-9_.-]|$)|\.config'"$_GP"'git'"$_GP"'config([^A-Za-z0-9_.-]|$))'
|
|
1378
|
+
GATE='(\.(claude'"$_GP"'(hooks|git-shim|settings\.json|settings\.local\.json|DISCIPLINE\.md|eval'"$_GP"'lib'"$_GP"'crew-env\.sh)|git'"$_GP"'hooks|git'"$_GP"'(config|worktrees'"$_GP"'[^/\\[:space:]]+'"$_GP"'config|modules'"$_GP"'[^[:space:]]+'"$_GP"'config))|\.gitconfig([^A-Za-z0-9_.-]|$)|\.config'"$_GP"'git'"$_GP"'config([^A-Za-z0-9_.-]|$))'
|
|
1379
1379
|
# .git/config (with a worktree's and a submodule's own) is on the list because core.hooksPath LIVES there: the rules
|
|
1380
1380
|
# above stop `git config core.hooksPath …`, and a plain `printf '[core]\n\thooksPath = /dev/null\n' >> .git/config`
|
|
1381
1381
|
# walked past them — after it a commit from the user's own terminal skips the trace and secret scans (measured, 3.1.0
|
|
@@ -1823,7 +1823,7 @@ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a
|
|
|
1823
1823
|
t="$CMD_UQ"; _APN=0
|
|
1824
1824
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1825
1825
|
case "$t" in
|
|
1826
|
-
*crewforth-appr*) _APN=1 ;;
|
|
1826
|
+
*crewforth-appr*|*crew-approval-pending*) _APN=1 ;;
|
|
1827
1827
|
esac
|
|
1828
1828
|
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
1829
1829
|
# FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
|
|
@@ -1857,9 +1857,13 @@ _appr_named
|
|
|
1857
1857
|
_appr_word(){ # $1 = a word of the command -> 0 when it, or what follows its first `=`, is an approval
|
|
1858
1858
|
local w="$1"
|
|
1859
1859
|
[ "${#w}" -le 160 ] || return 1
|
|
1860
|
-
_crew_appr_op "$w"; [ -
|
|
1861
|
-
|
|
1862
|
-
|
|
1860
|
+
_crew_appr_op "$w"; [ -z "$OP" ] && case "$w" in *=*) _crew_appr_op "${w#*=}" ;; esac
|
|
1861
|
+
[ -n "$OP" ] || return 1
|
|
1862
|
+
# `/crew-approve` with nothing after it is a single word, and a single word turns up where nothing is started:
|
|
1863
|
+
# `grep -rn /crew-approve .claude/hooks`. It counts only in a command one of whose words IS claude (also
|
|
1864
|
+
# claude.exe, a path that ends in it, the package's name): then it is a prompt, or about to be piped into one.
|
|
1865
|
+
if [ "$OP" = pending ] && [ "$_AWC" != 1 ]; then OP=""; return 1; fi
|
|
1866
|
+
return 0
|
|
1863
1867
|
}
|
|
1864
1868
|
_appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names it)
|
|
1865
1869
|
# The command AS IT WAS SENT: the prompt of a plain `claude -p "…"` has been taken out of CMD by now (it is text for
|
|
@@ -1871,7 +1875,10 @@ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names i
|
|
|
1871
1875
|
[ -f "$here/prompt-approval.sh" ] || return 1 # no recording hook: no approval can be recorded at all
|
|
1872
1876
|
declare -F _crew_appr_op >/dev/null 2>&1 || . "$here/prompt-approval.sh"
|
|
1873
1877
|
declare -F _crew_appr_op >/dev/null 2>&1 || return 1
|
|
1874
|
-
OP=""
|
|
1878
|
+
OP=""; _AWC=0
|
|
1879
|
+
case " ${CMD_UQ//[;|&()]/ } " in
|
|
1880
|
+
*[[:space:]]claude[[:space:]]*|*[[:space:]]claude.exe[[:space:]]*|*[[:space:]]claude.cmd[[:space:]]*|*/claude[[:space:]]*|*/claude.exe[[:space:]]*|*claude-code*) _AWC=1 ;;
|
|
1881
|
+
esac
|
|
1875
1882
|
while :; do
|
|
1876
1883
|
pre="${s%%[\"\'\\$' \t\n;|&()<>']*}"
|
|
1877
1884
|
[ -n "$pre" ] && { w="$w$pre"; inw=1; }
|
|
@@ -1897,6 +1904,31 @@ _appr_in_claude_call(){ # 0 = a word of this command is an approval (OP names i
|
|
|
1897
1904
|
[ "$inw" = 1 ] && _appr_word "$w" && return 0
|
|
1898
1905
|
return 1
|
|
1899
1906
|
}
|
|
1907
|
+
# What model routing decides from is written by the user and by Crewforth's own hooks (guard-write.sh has the same rule
|
|
1908
|
+
# for the file tools): the user's rules, the floors, the list of critical paths, and the records of calls and results.
|
|
1909
|
+
# A command that NAMES one is refused, reading included; model-outcomes.tsv, the plain record, may be read. The
|
|
1910
|
+
# name is `crew-model…` for all of them (the rules, the floors, the user's lowerings, the records of calls).
|
|
1911
|
+
# HONEST SCOPE: this stops an accident and a plain attempt, not a determined one. A command that builds the name
|
|
1912
|
+
# from pieces (`f=crew-mo; … ${f}del-loosened.tsv`), writes through an interpreter (`python3 -c`), or moves a
|
|
1913
|
+
# folder above it (`cd .claude && mv state x`) spells none of what is matched here and is past this rule.
|
|
1914
|
+
_mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1915
|
+
case "$CMD_UQ" in *crew-model*|*crew-critical-paths*) rc=0 ;; esac
|
|
1916
|
+
# ...and by a glob that could reach one, or the folder itself in a command that moves, copies or removes: a word
|
|
1917
|
+
# that holds `.claude/state` and a wildcard, or that IS that folder beside rm, mv, cp, rmdir, rsync, tar or ln.
|
|
1918
|
+
if [ "$rc" = 1 ]; then
|
|
1919
|
+
local w unglob=0 verb=0
|
|
1920
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
1921
|
+
for w in $CMD_UQ; do case "$w" in rm|mv|cp|rmdir|rsync|tar|ln|*/rm|*/mv|*/cp) verb=1 ;; esac; done
|
|
1922
|
+
for w in $CMD_UQ; do
|
|
1923
|
+
case "$w" in
|
|
1924
|
+
*.claude[/\\]state[/\\]*[\*\?\[]*|*.claude[/\\]state[\*\?\[]*) rc=0 ;;
|
|
1925
|
+
*.claude[/\\]state|*.claude[/\\]state[/\\]) [ "$verb" = 1 ] && rc=0 ;;
|
|
1926
|
+
esac
|
|
1927
|
+
done
|
|
1928
|
+
[ "$unglob" = 1 ] && set +f
|
|
1929
|
+
fi
|
|
1930
|
+
[ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
|
|
1931
|
+
_mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
|
|
1900
1932
|
_appr_in_claude_call && block "a command that hands a Claude Code session an approval as its prompt (only the user's own message approves)" "4.4" tamper
|
|
1901
1933
|
|
|
1902
1934
|
# §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
|
|
@@ -3229,10 +3261,39 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
|
|
|
3229
3261
|
exit 2
|
|
3230
3262
|
fi
|
|
3231
3263
|
_aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
|
|
3264
|
+
# WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
|
|
3265
|
+
# the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
|
|
3266
|
+
# THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
|
|
3267
|
+
# within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
|
|
3268
|
+
# and the name rule above): a session that could write it would choose what the user's approval covers.
|
|
3269
|
+
_pw=""
|
|
3270
|
+
if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
|
|
3271
|
+
_pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
|
|
3272
|
+
_json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
|
|
3273
|
+
case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
|
|
3274
|
+
if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
|
|
3275
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _pn '%(%s)T' -1; else _pn="$(date +%s)"; fi
|
|
3276
|
+
_po=""; _ps=""; _pt=""
|
|
3277
|
+
if [ -f "$_pd/crew-approval-pending" ]; then
|
|
3278
|
+
while IFS= read -r _pl || [ -n "$_pl" ]; do case "$_pl" in sid=*) _ps="${_pl#sid=}" ;; op=*) _po="${_pl#op=}" ;; ts=*) _pt="${_pl#ts=}" ;; esac; done < "$_pd/crew-approval-pending"
|
|
3279
|
+
fi
|
|
3280
|
+
_pw="$_aps"
|
|
3281
|
+
case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
|
|
3282
|
+
if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
|
|
3283
|
+
case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
|
|
3284
|
+
fi
|
|
3285
|
+
{ printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
|
|
3286
|
+
fi
|
|
3287
|
+
fi
|
|
3232
3288
|
echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
|
|
3233
3289
|
echo " 1. the commit message, whole, in ONE code block;" >&2
|
|
3234
|
-
|
|
3235
|
-
|
|
3290
|
+
if [ -n "$_pw" ]; then
|
|
3291
|
+
echo " 2. under it one sentence in their language (If you approve, send only this:), and then the command on a line of its own, exactly as it is:" >&2
|
|
3292
|
+
echo "/crew-approve"$'\n'" That line is the whole message, and only the user can type it. It approves what was just refused here: $_pw. (Spelled out, the user can type /crew-approve commit, push or commit+push instead.)" >&2
|
|
3293
|
+
else
|
|
3294
|
+
echo " 2. under it ONE line, the sentence in their language and the command as it is (only the user can type it): If you approve, send only this: /crew-approve $_aps" >&2
|
|
3295
|
+
echo " Name the one the user can type for this call: /crew-approve commit, /crew-approve push, or /crew-approve commit+push when a push follows the commit." >&2
|
|
3296
|
+
fi
|
|
3236
3297
|
echo "Their message has to be that command alone, typed by them: a sentence such as 'go ahead and commit' is not an approval, and you cannot run the command for them. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
|
|
3237
3298
|
echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
|
|
3238
3299
|
echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
@@ -81,8 +81,14 @@ while IFS= read -r sv; do
|
|
|
81
81
|
[ -n "$sv" ] || continue
|
|
82
82
|
_json_unescape "$sv" >/dev/null; _crew_appr_op "$_JU"
|
|
83
83
|
if [ -n "$OP" ]; then
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
if [ "$OP" = loosen ]; then
|
|
85
|
+
echo "GUARD (§4.4): this call schedules a prompt that lowers a model floor. Only the user can type /crew-loosen; a prompt a tool schedules would lower the floor in their place, so the call is refused." >&2
|
|
86
|
+
echo "Schedule the work without it. If a floor looks too high, say so: the user can type /crew-loosen $LO_AGENT $LO_CHANGE $LO_RISK themselves." >&2
|
|
87
|
+
else
|
|
88
|
+
echo "GUARD (§4.4): this call schedules a prompt that is an approval for a commit or a push ($OP). An approval is what the user types; a prompt a tool schedules would approve in their place, so the call is refused." >&2
|
|
89
|
+
_so=" $OP"; [ "$OP" = pending ] && _so=""
|
|
90
|
+
echo "Schedule the work without it. For the approval itself, show the commit message and ask: the user can type /crew-approve$_so when they agree." >&2
|
|
91
|
+
fi
|
|
86
92
|
exit 2
|
|
87
93
|
fi
|
|
88
94
|
done <<< "$strs"
|
package/kit/hooks/guard-write.sh
CHANGED
|
@@ -387,6 +387,7 @@ block(){ # $1 = rule name for the log (must keep the `gate-file edit` prefix
|
|
|
387
387
|
WHY_SCRIPT="This file is a gate script — rewriting it would disarm the trace/secret/approval gates."
|
|
388
388
|
WHY_DISC="This file is Crewforth's discipline document — it IS the text of §4.1-§4.5, so editing it empties the rules the gates enforce."
|
|
389
389
|
WHY_LINK="A parent directory of this path is a symlink and it resolves into a gate directory, so the write would land on a gate file."
|
|
390
|
+
WHY_MODEL="This file is what model routing decides from: the user's rules, a class's floor, the critical paths, or the record of a call and its result. Only the user and Crewforth's own hooks write it, so a session cannot lower its own floor or erase its own failure."
|
|
390
391
|
WHY_APPR="This file records the user's own approval for a commit or a push (§4.4). Only the user's message writes it, so a session cannot approve its own commit."
|
|
391
392
|
WHY_GITCFG="This file holds core.hooksPath: writing it can switch the git hooks off without touching one of them. Settings go through git config, which the Bash guard reads."
|
|
392
393
|
WHY_LONG="The path in this payload is longer than any filesystem accepts. It is refused rather than parsed, because parsing it is the slow path an attacker would aim at."
|
|
@@ -544,6 +545,13 @@ _is_gate(){ # 0 = gate file; sets GATE_RULE and GATE_WHY
|
|
|
544
545
|
# Matched by name wherever it is: a linked worktree keeps its git directory elsewhere.
|
|
545
546
|
*/[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]|[Cc][Rr][Ee][Ww][Ff][Oo][Rr][Tt][Hh]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll])
|
|
546
547
|
GATE_RULE="approval-record edit (Write/Edit tools)"; GATE_WHY="$WHY_APPR"; return 0 ;;
|
|
548
|
+
# What model routing decides from (hooks/guard-agent-model.sh, hooks/agent-outcome.sh): the user's own rules,
|
|
549
|
+
# the floors raised from the record of outcomes, the list of critical paths, and the records of calls and
|
|
550
|
+
# results. A session that writes one lowers its own floor or wipes its own failure.
|
|
551
|
+
*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Cc][Rr][Ee][Ww]-[Mm][Oo][Dd][Ee][Ll]-[Rr][Uu][Ll][Ee][Ss]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Cc][Rr][Ee][Ww]-[Mm][Oo][Dd][Ee][Ll]-[Rr][Uu][Ll][Ee][Ss]\
|
|
552
|
+
|*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-*|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Cc][Rr][Ee][Ww]-*\
|
|
553
|
+
|*/.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Mm][Oo][Dd][Ee][Ll]-[Oo][Uu][Tt][Cc][Oo][Mm][Ee][Ss].[Tt][Ss][Vv]|.[Cc][Ll][Aa][Uu][Dd][Ee]/[Ss][Tt][Aa][Tt][Ee]/[Mm][Oo][Dd][Ee][Ll]-[Oo][Uu][Tt][Cc][Oo][Mm][Ee][Ss].[Tt][Ss][Vv])
|
|
554
|
+
GATE_RULE="gate-file edit (model routing record)"; GATE_WHY="$WHY_MODEL"; return 0 ;;
|
|
547
555
|
esac
|
|
548
556
|
# core.hooksPath lives in git's configuration files: the repository's (.git/config, with a linked worktree's and a
|
|
549
557
|
# submodule's own) and the user's (~/.gitconfig, ~/.config/git/config). Writing one switches the git hooks off without
|
|
@@ -602,6 +610,29 @@ while case "$_anc" in */*) true ;; *) false ;; esac; do
|
|
|
602
610
|
fi
|
|
603
611
|
done
|
|
604
612
|
|
|
613
|
+
# ---- model routing: an agent that is not on opus does not write to a critical path ------------------------------
|
|
614
|
+
# The card a crew agent is called with can leave a file out, so the files it names are not the last word: the
|
|
615
|
+
# write itself is checked. Asked only of a call that comes from inside an agent (the payload then carries agent_id;
|
|
616
|
+
# measured on Claude Code 2.1.294), so the session's own writes pay one test of the payload's text and nothing else.
|
|
617
|
+
# One definition of "critical path" and of "which model is this agent on": hooks/guard-agent-model.sh, sourced.
|
|
618
|
+
case "$INPUT" in *'"agent_id"'*)
|
|
619
|
+
if [ "${CREW_MODEL_ROUTING:-}" != off ]; then
|
|
620
|
+
_gwm="${BASH_SOURCE%/*}"; [ "$_gwm" = "${BASH_SOURCE}" ] && _gwm=.
|
|
621
|
+
if [ -f "$_gwm/guard-agent-model.sh" ]; then
|
|
622
|
+
. "$_gwm/guard-agent-model.sh"
|
|
623
|
+
declare -F _am_write_check >/dev/null 2>&1 && _am_write_check "$INPUT" "$FP"
|
|
624
|
+
# An agent does not write the settings files either: a Bash(...) rule in permissions.allow is one of the two
|
|
625
|
+
# ways the USER allows a verify command by name, and an agent that could add one would allow its own.
|
|
626
|
+
case "${FP//\\//}" in
|
|
627
|
+
*/.claude/settings.json|.claude/settings.json|*/.claude/settings.local.json|.claude/settings.local.json)
|
|
628
|
+
declare -F _am_log >/dev/null 2>&1 && _am_log "a settings file written from inside an agent"
|
|
629
|
+
echo "GUARD (agent model): an agent does not write ${FP##*/}: a permission rule there decides which verify command a hook may run, and that is the user's to give. Stop, say what you wanted to change, and end your report with confidence: low" >&2
|
|
630
|
+
exit 2 ;;
|
|
631
|
+
esac
|
|
632
|
+
fi
|
|
633
|
+
fi ;;
|
|
634
|
+
esac
|
|
635
|
+
|
|
605
636
|
# ---- team board: you may not start work nobody knows you started -----------------------------------------------
|
|
606
637
|
# The claim lock already makes it impossible for two people to HOLD the same item — a losing claim is refused in
|
|
607
638
|
# under a second, before any code exists. The hole this closes is the other one: somebody who never claims at all.
|
|
@@ -358,20 +358,28 @@ _crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktre
|
|
|
358
358
|
|
|
359
359
|
# ---- WHICH TEXT IS AN APPROVAL: one function, asked here of the user's prompt and by guard-schedule.sh of a
|
|
360
360
|
# prompt a tool is about to schedule ---------------------------------------------------------------------------
|
|
361
|
-
_crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push, or "" when the text is
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
_crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push | pending | loosen, or "" when the text is none
|
|
362
|
+
# `loosen` is the other thing only the user's own message does: `/crew-loosen <agent> <change> <risk>` lowers a
|
|
363
|
+
# floor that model routing raised (3.1.0). It is matched HERE so that everything that refuses to hand a session
|
|
364
|
+
# an approval (a scheduled prompt, a `claude -p` call) refuses to hand it this as well: one question, one answer.
|
|
365
|
+
local P="$1" _k _v _vr LC_ALL=C
|
|
366
|
+
OP=""; LO_AGENT=""; LO_CHANGE=""; LO_RISK=""
|
|
364
367
|
P="${P//$'\r'/}"
|
|
365
368
|
while :; do case "$P" in [$' \t\n']*) P="${P:1}" ;; *) break ;; esac; done
|
|
366
369
|
while :; do case "$P" in *[$' \t\n']) P="${P%?}" ;; *) break ;; esac; done
|
|
367
|
-
[ "${#P}" -le
|
|
370
|
+
[ "${#P}" -le 90 ] || return 0
|
|
371
|
+
_vr=""
|
|
368
372
|
case "$P" in
|
|
369
373
|
*$'\n'*) return 0 ;;
|
|
370
374
|
# THE COMMAND: `/crew-approve <what>`, or `/<plugin>:crew-approve <what>` as a plugin names its commands. Measured
|
|
371
375
|
# on Claude Code 2.1.284: a command the user types reaches UserPromptSubmit with `prompt` as typed, and a skill
|
|
372
376
|
# the model starts with the Skill tool raises PreToolUse and PostToolUse only, never that event.
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
# With NOTHING after it, `/crew-approve` approves what this session was last refused (3.1.0): OP is `pending`
|
|
378
|
+
# here, and the hook's own flow looks up what that is. It is an approval like the others to everything that
|
|
379
|
+
# asks this function, so a scheduled or handed-over `/crew-approve` is refused the same way.
|
|
380
|
+
/*) _k="${P%%[$' \t']*}"
|
|
381
|
+
if [ "$_k" = "$P" ]; then _k="${_k#/}"; [ "${_k##*:}" = crew-approve ] && OP=pending; return 0; fi
|
|
382
|
+
_v="${P:${#_k}}"; _vr="$_v"; _k="${_k#/}"; _k="@${_k##*:}" ;; # `@`: a command, so `crew-approve: commit` as text is not one
|
|
375
383
|
# The text form of 3.1.0's release candidates, kept: `approve: <what>` and `onay: <what>`.
|
|
376
384
|
*:*) _k="${P%%:*}"; _v="${P#*:}" ;;
|
|
377
385
|
*) return 0 ;;
|
|
@@ -384,6 +392,15 @@ _crew_appr_op(){ # $1 = a text, decoded -> OP: commit | push | commit+push, or
|
|
|
384
392
|
case "$_v" in commit) OP=commit ;; push) OP=push ;; commit+push) OP=commit+push ;; esac ;;
|
|
385
393
|
esac
|
|
386
394
|
shopt -u nocasematch
|
|
395
|
+
# The second command is read as it is written, lower case, with exactly three words after it.
|
|
396
|
+
if [ "$_k" = "@crew-loosen" ]; then
|
|
397
|
+
local a b c d
|
|
398
|
+
IFS=$' \t' read -r a b c d <<< "$_vr"
|
|
399
|
+
[ -n "${c:-}" ] && [ -z "${d:-}" ] || return 0
|
|
400
|
+
case "$a" in crew-*) ;; *) return 0 ;; esac; case "$a$b" in *[!a-z0-9-]*) return 0 ;; esac
|
|
401
|
+
case "$c" in critical|normal) ;; *) return 0 ;; esac
|
|
402
|
+
OP=loosen; LO_AGENT="$a"; LO_CHANGE="$b"; LO_RISK="$c"
|
|
403
|
+
fi
|
|
387
404
|
}
|
|
388
405
|
|
|
389
406
|
[ "$_PA_LIB" = 1 ] && return 0
|
|
@@ -433,6 +450,72 @@ say(){ # $1 = for the user, $2 = for the model. Both are built from fixed text
|
|
|
433
450
|
}
|
|
434
451
|
no(){ say "Crewforth: approval NOT recorded - $1" "The user's message is an approval for $OP, but Crewforth could not record it: $1 Tell the user exactly that; the commit/push gate stays closed until they approve again."; }
|
|
435
452
|
|
|
453
|
+
# ---- /crew-loosen: a floor model routing raised is lowered by the user, and by nobody else ----------------------
|
|
454
|
+
# hooks/agent-outcome.sh raises a class's floor when its first try fails too often, and nothing lowers it again but
|
|
455
|
+
# this. The same proof as for an approval that a person is typing into their own session: the turn is not a
|
|
456
|
+
# notice or another session's message (above), the session is not a headless one, and it has a transcript already,
|
|
457
|
+
# so this is not the prompt a command or a schedule started a session with. In every permission mode: this is not
|
|
458
|
+
# a commit. ONE model down, ONCE for each time the floor was raised; the record of outcomes is counted afresh for
|
|
459
|
+
# that class from here, so the old failures do not raise it straight back.
|
|
460
|
+
if [ "$OP" = loosen ]; then
|
|
461
|
+
nol(){ say "Crewforth: floor NOT lowered - $1" "The user sent the command only the user can type /crew-loosen, for $LO_AGENT $LO_CHANGE $LO_RISK, and Crewforth did not lower anything: $1 Tell the user exactly that."; }
|
|
462
|
+
case "${CLAUDE_CODE_SESSION_ATTENDED-}" in 0) nol "this session has nobody in front of it (it was started with -p, or by a program)." ;; esac
|
|
463
|
+
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; TP="${_JU//\\//}"
|
|
464
|
+
{ [ -n "$TP" ] && [ -s "$TP" ]; } || nol "this is the first message of this session, or the session keeps no transcript."
|
|
465
|
+
_lh="${BASH_SOURCE%/*}"; [ "$_lh" = "${BASH_SOURCE}" ] && _lh=.
|
|
466
|
+
[ -f "$_lh/guard-agent-model.sh" ] || nol "model routing is not installed beside this hook."
|
|
467
|
+
[ "${CREW_MODEL_ROUTING:-}" = off ] && nol "model routing is switched off (CREW_MODEL_ROUTING=off), so no floor is in force."
|
|
468
|
+
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$CWD}"; . "$_lh/guard-agent-model.sh"
|
|
469
|
+
declare -F _am_floor_now >/dev/null 2>&1 || nol "model routing beside this hook is an older one."
|
|
470
|
+
_am_state
|
|
471
|
+
_am_floor_now "$LO_AGENT" "$LO_CHANGE" "$LO_RISK"
|
|
472
|
+
[ -n "$_AMF_TIER" ] || nol "no raised floor is on record for $LO_AGENT / $LO_CHANGE / $LO_RISK (.claude/state/crew-model-floors.auto has no line for it)."
|
|
473
|
+
[ "$_AMF_LOOSE" = 0 ] || nol "that floor was already lowered once ($_AMF_TIER to $_AMF_TO, $_AMF_LTS). It is lowered once for each time it is raised."
|
|
474
|
+
_am_rank "$_AMF_TIER"; [ "$_AMR" -ge 2 ] || nol "the floor on record is $_AMF_TIER, and there is no model below it."
|
|
475
|
+
_am_name $((_AMR-1)); LTO="$_AMN"
|
|
476
|
+
LTS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"; case "$LTS" in [0-9][0-9][0-9][0-9]-*Z) ;; *) nol "the clock could not be read." ;; esac
|
|
477
|
+
LF="$_AMS/crew-model-loosened.tsv"
|
|
478
|
+
mkdir -p "$_AMS" 2>/dev/null || nol "the state folder could not be made."
|
|
479
|
+
[ -s "$LF" ] || { printf 'ts\tagent\tchange\trisk\tfrom\tto\n' > "$LF"; } 2>/dev/null || nol "the record could not be written."
|
|
480
|
+
LFROM="$_AMF_TIER"
|
|
481
|
+
{ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$LTS" "$LO_AGENT" "$LO_CHANGE" "$LO_RISK" "$LFROM" "$LTO" >> "$LF"; } 2>/dev/null || nol "the record could not be written."
|
|
482
|
+
# Said only when it is TRUE: the floor is read back the way the gate will read it. A line that was written and
|
|
483
|
+
# does not count (a clock that went backwards, a floors file that changed underneath) is not a lowering.
|
|
484
|
+
_am_floor_now "$LO_AGENT" "$LO_CHANGE" "$LO_RISK"
|
|
485
|
+
{ [ "$_AMF_LOOSE" = 1 ] && [ "$_AMF_EFF" = "$LTO" ]; } || nol "the line was written but the floor read back is still ${_AMF_EFF:-$LFROM}, so it is not in force (is the clock behind the time the floor was raised, $_AMF_WHEN?)."
|
|
486
|
+
say "Crewforth: floor lowered - $LO_AGENT / $LO_CHANGE / $LO_RISK from $LFROM to $LTO. Its outcomes are counted afresh from now." \
|
|
487
|
+
"The user lowered the calibration floor of $LO_AGENT / $LO_CHANGE / $LO_RISK from $LFROM to $LTO with their own command. The card's own rules still hold (critical work on opus, no verify command on sonnet or above, the agent's floor). If the class fails its first try too often again, the floor rises again."
|
|
488
|
+
fi
|
|
489
|
+
|
|
490
|
+
# ---- `/crew-approve` with nothing after it: what this session was last refused ---------------------------------
|
|
491
|
+
# guard-bash.sh, when it refuses a commit or a push in auto / dontAsk, writes down what it refused
|
|
492
|
+
# (.claude/state/crew-approval-pending: the session, commit | push | commit+push, the second). The bare command
|
|
493
|
+
# approves THAT and nothing else: the same session's, not older than 30 minutes. What is approved is therefore
|
|
494
|
+
# never read from the message and never from the model: the gate wrote it, from the command it refused. From here
|
|
495
|
+
# on the flow is the one a spelled-out approval takes, with every proof it asks for (the mode, a session that is
|
|
496
|
+
# attended, a transcript on disk) and the same record at the end.
|
|
497
|
+
BARE=0
|
|
498
|
+
if [ "$OP" = pending ]; then
|
|
499
|
+
BARE=1
|
|
500
|
+
_json_slice "$INPUT" permission_mode >/dev/null
|
|
501
|
+
case "$_JS" in auto|dontAsk) ;; *) exit 0 ;; esac # elsewhere the gate asks the user itself; nothing waits here
|
|
502
|
+
_json_slice "$INPUT" session_id >/dev/null; _bsid="$_JS"
|
|
503
|
+
_bp="${CLAUDE_PROJECT_DIR:-$CWD}"; _bp="${_bp//\\//}"; _bp="${_bp%/}/.claude/state/crew-approval-pending"
|
|
504
|
+
_bs=""; _bo=""; _bt=""
|
|
505
|
+
if [ -f "$_bp" ]; then
|
|
506
|
+
while IFS= read -r _bl || [ -n "$_bl" ]; do
|
|
507
|
+
case "$_bl" in sid=*) _bs="${_bl#sid=}" ;; op=*) _bo="${_bl#op=}" ;; ts=*) _bt="${_bl#ts=}" ;; esac
|
|
508
|
+
done < "$_bp"
|
|
509
|
+
fi
|
|
510
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then printf -v _bn '%(%s)T' -1; else _bn="$(date +%s)"; fi
|
|
511
|
+
OP="a commit or a push"
|
|
512
|
+
case "$_bo" in commit|push|commit+push) ;; *) no "nothing is waiting for approval in this session: no commit or push was refused. Have the commit message shown and the commit tried first; or name it: the user can type /crew-approve commit, push or commit+push." ;; esac
|
|
513
|
+
{ [ -n "$_bsid" ] && [ "$_bs" = "$_bsid" ]; } || no "what is waiting for approval was refused in another session, not this one. Nothing is approved; have it tried here first."
|
|
514
|
+
case "$_bt$_bn" in *[!0-9]*|'') no "the time of what is waiting could not be read." ;; esac
|
|
515
|
+
[ $((_bn - _bt)) -ge 0 ] && [ $((_bn - _bt)) -le 1800 ] || no "what was waiting for approval ($_bo) is more than 30 minutes old. Nothing is approved; have it tried again first."
|
|
516
|
+
OP="$_bo" # used up further down, once the approval is on record
|
|
517
|
+
fi
|
|
518
|
+
|
|
436
519
|
_json_slice "$INPUT" permission_mode >/dev/null; PM="$_JS"
|
|
437
520
|
case "$PM" in
|
|
438
521
|
auto|dontAsk) ;;
|
|
@@ -509,6 +592,9 @@ case "$TS" in ''|*[!0-9]*) no "the clock could not be read." ;; esac
|
|
|
509
592
|
|
|
510
593
|
{ printf 'v=1\nop=%s\ntree=%s\nhead=%s\nbranch=%s\nremote=%s\nurl=%s\nsid=%s\nts=%s\n' "$OP" "$TREE" "$HEAD_NOW" "$BR" "$RM" "$URL" "$SID" "$TS" > "$REC"; } 2>/dev/null \
|
|
511
594
|
|| no "the record could not be written in the git directory."
|
|
595
|
+
# What was waiting has been approved: it is not approved a second time. Only here, after every proof and the record
|
|
596
|
+
# itself: a bare command that recorded nothing (a headless session, a first message) leaves it for the user.
|
|
597
|
+
[ "$BARE" = 1 ] && { : > "$_bp"; } 2>/dev/null
|
|
512
598
|
|
|
513
599
|
_h="${HEAD_NOW:0:7}"; _d="${TREE:0:7}"
|
|
514
600
|
# THE ADDRESS IS SHOWN TO THE USER, not only compared: a rewrite set BEFORE the approval (`url.<x>.insteadOf`) is
|
|
@@ -527,5 +613,6 @@ case "$OP" in
|
|
|
527
613
|
HOW="Run git commit -m with the message, alone in its call (no other option than -q, -s or -v, no paths, no cd, no pipe, nothing chained; single-quote the message; §4.6 still applies), then in a separate call exactly: git push $RM $BR" ;;
|
|
528
614
|
esac
|
|
529
615
|
[ -n "$_u" ] && _u=" ($RM pushes to $_u)"
|
|
530
|
-
|
|
616
|
+
_bw=""; [ "$BARE" = 1 ] && _bw="approved: $OP (what this session was refused). "
|
|
617
|
+
say "Crewforth: ${_bw}approval recorded - $WHAT$_u. Valid for 30 minutes; your next message ends it." \
|
|
531
618
|
"The user approved this in their own message and Crewforth recorded it: $WHAT. $HOW The approval covers what was staged and where HEAD was at that moment, in this session: if either changes it no longer applies and the user approves again. It ends in 30 minutes or at the user's next message. Force-push and the rest of §4.5 are not opened by it."
|
package/kit/settings.json
CHANGED
|
@@ -146,6 +146,43 @@
|
|
|
146
146
|
]
|
|
147
147
|
}
|
|
148
148
|
],
|
|
149
|
+
"SubagentStart": [
|
|
150
|
+
{
|
|
151
|
+
"hooks": [
|
|
152
|
+
{
|
|
153
|
+
"type": "command",
|
|
154
|
+
"shell": "bash",
|
|
155
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
|
|
156
|
+
"timeout": 60
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"SubagentStop": [
|
|
162
|
+
{
|
|
163
|
+
"hooks": [
|
|
164
|
+
{
|
|
165
|
+
"type": "command",
|
|
166
|
+
"shell": "bash",
|
|
167
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
|
|
168
|
+
"timeout": 600
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
],
|
|
173
|
+
"PostToolUse": [
|
|
174
|
+
{
|
|
175
|
+
"matcher": "Agent|Task",
|
|
176
|
+
"hooks": [
|
|
177
|
+
{
|
|
178
|
+
"type": "command",
|
|
179
|
+
"shell": "bash",
|
|
180
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
|
|
181
|
+
"timeout": 60
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
],
|
|
149
186
|
"SessionStart": [
|
|
150
187
|
{
|
|
151
188
|
"matcher": "startup|resume|clear|compact|fork",
|
|
@@ -172,6 +209,12 @@
|
|
|
172
209
|
"shell": "bash",
|
|
173
210
|
"command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/board-sync.sh",
|
|
174
211
|
"timeout": 60
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"type": "command",
|
|
215
|
+
"shell": "bash",
|
|
216
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" 2>/dev/null; bash .claude/hooks/agent-outcome.sh",
|
|
217
|
+
"timeout": 60
|
|
175
218
|
}
|
|
176
219
|
]
|
|
177
220
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: crew-approve
|
|
3
3
|
description: Approve the commit or push Claude just showed you (auto and dontAsk modes).
|
|
4
|
-
argument-hint: "[commit | push | commit+push]"
|
|
4
|
+
argument-hint: "[commit | push | commit+push] (nothing: what was just refused)"
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
metadata:
|
|
7
7
|
kind: command
|
|
@@ -19,6 +19,9 @@ Read the line Crewforth added to this turn and do what it says:
|
|
|
19
19
|
`git commit -m '…'` with the message you showed (single-quoted, or from a here-document with a quoted delimiter);
|
|
20
20
|
a push is `git push <remote> <branch>`. The review record of §4.6 is still required.
|
|
21
21
|
- **"approval NOT recorded"**: tell the user the reason it gives, and run nothing.
|
|
22
|
+
- **With no argument** the command approves what the gate last refused in this session (the line says which:
|
|
23
|
+
"approved: commit+push"); when nothing was refused, or it was more than 30 minutes ago, nothing is recorded and
|
|
24
|
+
the line says so.
|
|
22
25
|
- **No such line**: the session is in a mode where the gate asks the user itself (`default`, `acceptEdits`), or
|
|
23
26
|
the argument was not `commit`, `push` or `commit+push`. Say which, and in the first case run the command and let
|
|
24
27
|
the gate's own prompt ask.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crew-loosen
|
|
3
|
+
description: Lower a model floor that Crewforth raised for a class of work (one model, once).
|
|
4
|
+
argument-hint: "<agent> <change> <risk>"
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
metadata:
|
|
7
|
+
kind: command
|
|
8
|
+
---
|
|
9
|
+
# /crew-loosen
|
|
10
|
+
Argument: $ARGUMENTS
|
|
11
|
+
|
|
12
|
+
The user typed this command. Typing it is the decision: `hooks/prompt-approval.sh` read the message itself and
|
|
13
|
+
lowered the calibration floor of that class of work (agent × change × risk) by one model, in
|
|
14
|
+
`.claude/state/crew-model-loosened.tsv`. Nothing in this text lowers anything, and you cannot do this for the user:
|
|
15
|
+
the file is closed to you, and a prompt you schedule or hand to another session is refused.
|
|
16
|
+
|
|
17
|
+
Read the line Crewforth added to this turn and say what it says:
|
|
18
|
+
|
|
19
|
+
- **"floor lowered"**: tell the user from which model to which, and that the class is counted afresh: if its first
|
|
20
|
+
try fails too often again, the floor rises again.
|
|
21
|
+
- **"floor NOT lowered"**: tell the user the reason it gives (no raised floor on record for that class, already
|
|
22
|
+
lowered once, the first message of a session, a headless session).
|
|
23
|
+
- **No such line**: the arguments were not `<agent> <change> <risk>`: a `crew-…` agent, a kind of change from the
|
|
24
|
+
card (`feature`, `fix-known`, `test-write` …) and `critical` or `normal`. Say that, with the three as they stand
|
|
25
|
+
in `.claude/state/crew-model-floors.auto`.
|
|
26
|
+
|
|
27
|
+
What this does not touch: critical work still runs on opus, work with no verify command on sonnet or above, and
|
|
28
|
+
an agent's own floor stays. Only the floor that the record of outcomes raised is lowered.
|
|
@@ -51,8 +51,17 @@ _field() { # _field <key> -> value on stdout
|
|
|
51
51
|
#
|
|
52
52
|
# This approves the call the viewer was shown. It does not unblock anything:
|
|
53
53
|
# another hook that exits 2 still blocks, and this hook's own denials are exit 2.
|
|
54
|
+
# The mode is the spool's `mode` file when there is one, and the mode the hook was configured with otherwise. The
|
|
55
|
+
# file starts as the mode the session started in and changes in exactly one way: the viewer approves a plan, in
|
|
56
|
+
# the panel, into acceptEdits or default (permissions.js `decide`). A session that started in plan mode could not
|
|
57
|
+
# otherwise ever have an allowance said out loud, plan approved or not. The check against what the harness itself
|
|
58
|
+
# reports, below, stays: a file that says `default` approves nothing while the session is still in `plan`.
|
|
54
59
|
_grant() {
|
|
55
|
-
|
|
60
|
+
local gm="${CREW_GATE_MODE:-}"
|
|
61
|
+
if [ -f "$SPOOL/mode" ]; then
|
|
62
|
+
read -r gm < "$SPOOL/mode" 2>/dev/null || gm=""
|
|
63
|
+
fi
|
|
64
|
+
case "$gm" in
|
|
56
65
|
acceptEdits|default) ;;
|
|
57
66
|
*) exit 0 ;;
|
|
58
67
|
esac
|
|
@@ -69,6 +78,57 @@ TUID="$(_field tool_use_id || true)"
|
|
|
69
78
|
SID="$(_field session_id || true)"
|
|
70
79
|
[ -n "$TOOL" ] || exit 0 # nothing to ask about
|
|
71
80
|
|
|
81
|
+
# ---- two calls the viewer is not asked about ---------------------------------
|
|
82
|
+
# Silence here is not an approval. The hook says nothing, and Claude Code decides the call as it would with no
|
|
83
|
+
# panel: its own mode rules and every other hook still apply.
|
|
84
|
+
#
|
|
85
|
+
# ToolSearch looks up a tool's definition and changes nothing. A session reaches for it before the tools it has
|
|
86
|
+
# to load first, ExitPlanMode among them, and asking the viewer to allow a lookup taught them to click through.
|
|
87
|
+
[ "$TOOL" = "ToolSearch" ] && exit 0
|
|
88
|
+
|
|
89
|
+
# The plan file. In plan mode Claude Code writes its plan to a file of its own before it asks to leave the mode,
|
|
90
|
+
# and plan mode lets it write that file and no other. The viewer could only refuse it, and a plan they have not
|
|
91
|
+
# been shown yet is not something to refuse: the plan itself comes to the panel with ExitPlanMode.
|
|
92
|
+
#
|
|
93
|
+
# Every condition has to hold, and anything that cannot be checked is asked about as before:
|
|
94
|
+
# - the session is in plan mode, by Claude Code's own report in this call
|
|
95
|
+
# - the tool is Write (an Edit is asked about)
|
|
96
|
+
# - the file is named *.md (as written: not .MD, not "x.md/" or "x.md."), and the path has no ".." in it
|
|
97
|
+
# - the path is spelled the way this platform spells one: "\" separates directories on Windows (MSYS, Cygwin)
|
|
98
|
+
# and nowhere else, so on any other system a path with a "\" in it is asked about
|
|
99
|
+
# - the file is not a symbolic link
|
|
100
|
+
# - the directory it is in, resolved, IS Claude Code's plans directory, resolved: not a directory under it
|
|
101
|
+
# The two directories are resolved by the same builtin (cd -P) and compared as it reports them, so how a path is
|
|
102
|
+
# spelled (C:\… or /c/…, a link on the way) does not matter. No process is started.
|
|
103
|
+
_plan_file() {
|
|
104
|
+
[ "$TOOL" = "Write" ] || return 1
|
|
105
|
+
[ "$(_field permission_mode || true)" = "plan" ] || return 1
|
|
106
|
+
local fp; fp="$(_field file_path || true)"
|
|
107
|
+
[ -n "$fp" ] || return 1
|
|
108
|
+
case "$fp" in *..*) return 1 ;; esac
|
|
109
|
+
case "$fp" in *.md) ;; *) return 1 ;; esac
|
|
110
|
+
local dir
|
|
111
|
+
case "${OSTYPE:-}" in
|
|
112
|
+
msys*|cygwin*)
|
|
113
|
+
fp="${fp//\\\\/\\}" # JSON writes one backslash as two
|
|
114
|
+
dir="${fp%[/\\]*}" ;;
|
|
115
|
+
*)
|
|
116
|
+
case "$fp" in *\\*) return 1 ;; esac # not a separator here: a name with one in it is not the plan file
|
|
117
|
+
dir="${fp%/*}" ;;
|
|
118
|
+
esac
|
|
119
|
+
[ "$dir" != "$fp" ] && [ -n "$dir" ] || return 1
|
|
120
|
+
[ -L "$fp" ] && return 1
|
|
121
|
+
[ -e "$fp" ] && [ ! -f "$fp" ] && return 1
|
|
122
|
+
local plans="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/plans" was="$PWD" at want
|
|
123
|
+
cd -P -- "$dir" 2>/dev/null || return 1
|
|
124
|
+
at="$PWD"
|
|
125
|
+
if ! cd -P -- "$plans" 2>/dev/null; then cd -- "$was" 2>/dev/null; return 1; fi
|
|
126
|
+
want="$PWD"
|
|
127
|
+
cd -- "$was" 2>/dev/null
|
|
128
|
+
[ -n "$at" ] && [ "$at" = "$want" ]
|
|
129
|
+
}
|
|
130
|
+
_plan_file && exit 0
|
|
131
|
+
|
|
72
132
|
# Ids become filenames; anything unexpected is refused rather than sanitised
|
|
73
133
|
# into something that might escape the spool.
|
|
74
134
|
case "$TUID" in
|
|
@@ -76,6 +136,21 @@ case "$TUID" in
|
|
|
76
136
|
esac
|
|
77
137
|
|
|
78
138
|
REQ="$SPOOL/req"; ANS="$SPOOL/ans"; ALWAYS="$SPOOL/always"
|
|
139
|
+
|
|
140
|
+
# The two tools that need a person. Claude Code offers them in a headless session only because Studio names a
|
|
141
|
+
# permission host for it (studio-host.mjs); what answers them is this hook, from what the viewer chose in the
|
|
142
|
+
# panel. A question and a plan take longer to read than one command, so they wait longer before the denial.
|
|
143
|
+
ASK=0
|
|
144
|
+
case "$TOOL" in
|
|
145
|
+
AskUserQuestion|ExitPlanMode) ASK=1; WAIT="${CREW_GATE_WAIT_ASK:-$WAIT}" ;;
|
|
146
|
+
esac
|
|
147
|
+
# The hook is wired twice: once for every tool, with the harness's ordinary limit, and once for these two alone,
|
|
148
|
+
# with the longer one they need. Each copy answers its own and leaves the other's at once, in silence: silence is
|
|
149
|
+
# no decision, so the copy that does answer decides. With no role (a settings file from before there were two)
|
|
150
|
+
# this copy answers everything.
|
|
151
|
+
case "${CREW_GATE_ROLE:-}:$ASK" in
|
|
152
|
+
general:1|ask:0) exit 0 ;;
|
|
153
|
+
esac
|
|
79
154
|
mkdir -p "$REQ" "$ANS" "$ALWAYS" 2>/dev/null || exit 0
|
|
80
155
|
|
|
81
156
|
# A tool the panel already blanket-approved for this session skips the round trip.
|
|
@@ -187,10 +262,21 @@ esac
|
|
|
187
262
|
START=$SECONDS
|
|
188
263
|
while :; do
|
|
189
264
|
if [ -f "$ANS/$TUID" ]; then
|
|
190
|
-
|
|
265
|
+
VERDICT=deny; OUT=""
|
|
266
|
+
# Two lines at most: the verdict, and for an answered question or an approved plan the output to print.
|
|
267
|
+
{ IFS= read -r VERDICT; IFS= read -r OUT; } < "$ANS/$TUID" 2>/dev/null || :
|
|
191
268
|
rm -f "$ANS/$TUID" "$REQ/$TUID.json" 2>/dev/null
|
|
192
|
-
case "$VERDICT" in
|
|
193
|
-
allow|always) _grant ;;
|
|
269
|
+
case "$ASK:$VERDICT" in
|
|
270
|
+
0:allow|0:always) _grant ;;
|
|
271
|
+
# The viewer's answers, or their approval of the plan, exactly as the server wrote them: `allow` with the
|
|
272
|
+
# tool's input, which is how the hooks reference says a hook answers these two tools. Printed only for them,
|
|
273
|
+
# and only when there is something to print.
|
|
274
|
+
1:output) [ -n "$OUT" ] || { printf 'studio: the answer could not be read — denied\n' >&2; exit 2; }
|
|
275
|
+
printf '%s\n' "$OUT"; exit 0 ;;
|
|
276
|
+
# A plan approved with a change of mode. The hook cannot change the mode, so it says nothing and the
|
|
277
|
+
# permission host, which can, answers from what the viewer chose.
|
|
278
|
+
1:host) [ "$TOOL" = ExitPlanMode ] && exit 0
|
|
279
|
+
printf 'studio: denied in the panel\n' >&2; exit 2 ;;
|
|
194
280
|
*) printf 'studio: denied in the panel\n' >&2; exit 2 ;;
|
|
195
281
|
esac
|
|
196
282
|
fi
|