crewforth 3.1.1 → 3.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -1303,15 +1303,20 @@ case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && _grep "$CMD"
|
|
|
1303
1303
|
_HP_B=$' \t' # blank = space or TAB only; a literal `\t` inside [ ] is a backslash and a t to ERE
|
|
1304
1304
|
_HP_OPT="(--local|--global|--system|--worktree|--show-origin|--show-scope|--includes|--no-includes|-z|--null|--name-only|--bool|--int|--path|--bool-or-int|--expiry-date|--all|--type=[A-Za-z-]+|--file=[^${_HP_B}]+|--blob=[^${_HP_B}]+|(--file|-f|--blob|--type)[${_HP_B}]+[^${_HP_B}]+)"
|
|
1305
1305
|
_HP_PRE="^[${_HP_B}]*git([${_HP_B}]+(-C[${_HP_B}]+[^${_HP_B}]+|--git-dir=[^${_HP_B}]+|--work-tree=[^${_HP_B}]+))*[${_HP_B}]+config([${_HP_B}]+${_HP_OPT})*[${_HP_B}]+"
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1306
|
+
# THE SAME RULE FOR crewforth.hookschain. When a project's own hook chain lives OUTSIDE the repository, adopt.sh
|
|
1307
|
+
# keeps its path in this clone's git config under that key and the shim reads it on every commit. A write to it
|
|
1308
|
+
# drops the project's chain or points the shim at another directory, so it is judged by the reader above, key for
|
|
1309
|
+
# key: reads pass, every write form is refused, and so is removing or renaming the [crewforth] section.
|
|
1310
|
+
_HP_G='*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*'; _HC_G='*[Hh][Oo][Oo][Kk][Ss][Cc][Hh][Aa][Ii][Nn]*'
|
|
1311
|
+
_hp_blocks() { # $1 = command, $2 = key as an ERE, $3 = glob of the key's name, $4 = its section -> 0 when it writes the key or drops the section
|
|
1312
|
+
local bsnl=$'\\\n' c="$1" key="$2" kg="$3" sec="$4" t seg r n=0 nc=0
|
|
1313
|
+
local bare="${_HP_PRE}${key}[${_HP_B}]*\$"
|
|
1309
1314
|
local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
|
|
1310
1315
|
local wr="[${_HP_B}](--unset|--unset-all|--add|--replace-all|--edit|-e|set|unset|--rename-section|--remove-section|rename-section|remove-section)([${_HP_B}]|\$)"
|
|
1311
1316
|
local -a reads=()
|
|
1312
1317
|
case "$c" in *"$bsnl"*) _gsub "$c" '\\'$'\n' ' ' bs; c="$_GS" ;; esac # backslash-newline -> a space
|
|
1313
1318
|
_unquoted "$c"; t="$_GS"
|
|
1314
|
-
|
|
1319
|
+
[[ $t == $kg || $t == *[Ss][Ee][Cc][Tt][Ii][Oo][Nn]* ]] || return 1 # after unquoting: hooks"P"ath
|
|
1315
1320
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1316
1321
|
# The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
|
|
1317
1322
|
# would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
|
|
@@ -1327,7 +1332,7 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
|
|
|
1327
1332
|
case "$raw" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
|
|
1328
1333
|
_split_segs "$c"
|
|
1329
1334
|
for seg in ${_SPL[@]+"${_SPL[@]}"}; do
|
|
1330
|
-
[[ $seg ==
|
|
1335
|
+
[[ $seg == $kg ]] || continue
|
|
1331
1336
|
n=$((n+1)); [ "$n" -gt 64 ] && { reads=(); break; } # past 64, no read is argued: the old rule judges it all
|
|
1332
1337
|
# A write flag anywhere in the segment disproves the read (`--list --unset core.hooksPath`): git 2.54 refuses
|
|
1333
1338
|
# those pairs, but the gate does not lean on another tool's option parser.
|
|
@@ -1339,14 +1344,15 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
|
|
|
1339
1344
|
# Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
|
|
1340
1345
|
for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
|
|
1341
1346
|
_unquoted "$c"; t="$_GS"
|
|
1342
|
-
_ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*
|
|
1343
|
-
_ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?([^;&|[:space:]]+[[:space:]]+)?
|
|
1347
|
+
_ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*'"$key" "$kg" && return 0
|
|
1348
|
+
_ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?([^;&|[:space:]]+[[:space:]]+)?'"$sec"'([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0 # the section as the old name OR the new one
|
|
1344
1349
|
return 1
|
|
1345
1350
|
}
|
|
1346
1351
|
# Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
|
|
1347
1352
|
# that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
|
|
1348
1353
|
[ "$HAS_GIT" = 1 ] && _ere i "$CMD" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1349
|
-
[ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1354
|
+
[ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" 'core\.hooksPath' "$_HP_G" core && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1355
|
+
[ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" 'crewforth\.hookschain' "$_HC_G" crewforth && block "git config crewforth.hookschain (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper
|
|
1350
1356
|
# The same setting by two side doors. `git config include.path f` (or includeIf.<cond>.path) makes git read ANOTHER
|
|
1351
1357
|
# file as configuration, and that file can carry hooksPath; GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
|
|
1352
1358
|
# hand git a setting through the environment (measured: with them a commit skipped its hooks). Both are refused by
|
|
@@ -1359,6 +1365,8 @@ if [ "$HAS_GIT" = 1 ]; then
|
|
|
1359
1365
|
# key right after `-c` (measured, 3.1.0 review).
|
|
1360
1366
|
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' \
|
|
1361
1367
|
&& block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1368
|
+
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+crewforth\.hookschain' "$_HC_G" \
|
|
1369
|
+
&& block "git -c crewforth.hookschain (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper
|
|
1362
1370
|
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+include(if)?\.' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
|
|
1363
1371
|
&& block "git config include.path (another file read as configuration)" "4.5" tamper
|
|
1364
1372
|
_ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&][^|;&]*(include\.path|includeif\.)' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
|
|
@@ -1367,6 +1375,7 @@ if [ "$HAS_GIT" = 1 ]; then
|
|
|
1367
1375
|
_ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&]([^|;&]*[[:space:]])?(--edit|-e)([[:space:]]|$)' '*[Cc][Oo][Nn][Ff][Ii][Gg]*' \
|
|
1368
1376
|
&& block "git config --edit (the configuration file opened for writing)" "4.5" tamper
|
|
1369
1377
|
case "$_t" in *GIT_CONFIG_*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*) block "core.hooksPath through GIT_CONFIG_ variables (disarms the git hooks)" "4.5" tamper ;; esac
|
|
1378
|
+
case "$_t" in *GIT_CONFIG_*[Hh][Oo][Oo][Kk][Ss][Cc][Hh][Aa][Ii][Nn]*) block "crewforth.hookschain through GIT_CONFIG_ variables (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper ;; esac
|
|
1370
1379
|
fi
|
|
1371
1380
|
# A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
|
|
1372
1381
|
# in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
|
|
@@ -1447,8 +1456,14 @@ fi
|
|
|
1447
1456
|
# ---- the reader of a call (the commit gate further down describes what it is for) ----
|
|
1448
1457
|
_c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span and every escaped character as one marker
|
|
1449
1458
|
# (\001 n \001, its text in _C47_Q[n]); redirections set apart from the words they touch; heredoc bodies gone
|
|
1459
|
+
[ "${_C47_SRC-$'\002'}" = "$1" ] && return 0 # the same text, already read in this call
|
|
1450
1460
|
local s="$1" out="" pre c w hd="" c2 nl=$'\n' n=0 body t bt=0
|
|
1451
|
-
_C47_M=$'\001'; _C47_Q=(); _C47_QX=""
|
|
1461
|
+
_C47_M=$'\001'; _C47_Q=(); _C47_QX=""; _C47_SRC="$1"
|
|
1462
|
+
# WHAT THE READER MET, for a caller that accepts only the plainest text (_a44_collapse): b = a backslash that
|
|
1463
|
+
# escapes a character outside quotes · u = a quote that is never closed · x = a double-quoted text holding `$`, a
|
|
1464
|
+
# backtick, a backslash or `!` (other than a message read from a here-document with a quoted delimiter, closed
|
|
1465
|
+
# at once) · a = a $'…' text · c = a comment that was left out. Flags only: what is read does not depend on them.
|
|
1466
|
+
_C47_F=""
|
|
1452
1467
|
_gsub "$s" $'\r' ''; _gsub "$_GS" "$_C47_M" ''; s="$_GS"
|
|
1453
1468
|
while :; do
|
|
1454
1469
|
pre="${s%%[\"\'\\\;\&\|\<\>\#\(\)\`\$$nl]*}"; out="$out$pre"
|
|
@@ -1457,22 +1472,45 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
|
|
|
1457
1472
|
case "$c" in
|
|
1458
1473
|
\\) case "$s" in
|
|
1459
1474
|
"$nl"*) s="${s:1}" ;; # a backslash-newline joins: both characters go
|
|
1460
|
-
'') ;;
|
|
1461
|
-
*) _C47_Q[n]="${s:0:1}"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)); s="${s:1}" ;; # an escaped character is itself, quoted
|
|
1475
|
+
'') _C47_F="${_C47_F}b" ;; # the last character of the call: it escapes nothing, and goes
|
|
1476
|
+
*) _C47_Q[n]="${s:0:1}"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)); s="${s:1}"; _C47_F="${_C47_F}b" ;; # an escaped character is itself, quoted
|
|
1462
1477
|
esac ;;
|
|
1463
|
-
\') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s="" ;; esac
|
|
1478
|
+
\') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s=""; _C47_F="${_C47_F}u" ;; esac
|
|
1464
1479
|
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1465
1480
|
\") body=""
|
|
1481
|
+
t=0 # t = 1: the strict form of that message (below)
|
|
1466
1482
|
case "$s" in
|
|
1467
1483
|
'$(cat <<'*) # a message read from a here-document: its body is skipped as a whole, quotes and all
|
|
1484
|
+
# THE STRICT FORM, the only substitution an approved call may hold: `"$(cat <<'WORD'` + newline, a body
|
|
1485
|
+
# that ends at the first line that is WORD, then `)"` with nothing but blanks before it. Its body is
|
|
1486
|
+
# literal. Anything looser (an unquoted delimiter, text after it, something after the body) is flagged x.
|
|
1487
|
+
case "$s" in '$(cat <<'\'*)
|
|
1488
|
+
c2="${s:9}"; w="${c2%%\'*}"
|
|
1489
|
+
case "$w" in ''|*[!A-Za-z0-9_]*) ;; *)
|
|
1490
|
+
c2="${c2:${#w}+1}"
|
|
1491
|
+
case "$c2" in "$nl"*) c2="${c2:1}"
|
|
1492
|
+
case "$c2" in
|
|
1493
|
+
"$w$nl"*) c2="${c2:${#w}+1}"; t=1 ;;
|
|
1494
|
+
*"$nl$w$nl"*) pre="${c2%%"$nl$w$nl"*}"; c2="${c2:${#pre}+${#w}+2}"; t=1 ;;
|
|
1495
|
+
esac
|
|
1496
|
+
if [ "$t" = 1 ]; then
|
|
1497
|
+
while :; do case "$c2" in [$' \t']*) c2="${c2:1}" ;; *) break ;; esac; done
|
|
1498
|
+
case "$c2" in ')"'*) ;; *) t=0 ;; esac
|
|
1499
|
+
fi ;;
|
|
1500
|
+
esac ;;
|
|
1501
|
+
esac ;;
|
|
1502
|
+
esac
|
|
1503
|
+
[ "$t" = 1 ] || _C47_F="${_C47_F}x"
|
|
1468
1504
|
w="${s:8}"; w="${w#-}"; w="${w#[\'\"]}"; w="${w%%[!A-Za-z0-9_]*}"
|
|
1469
1505
|
case "$s" in *"$nl$w$nl"*) [ -n "$w" ] && { pre="${s%%"$nl$w$nl"*}"; s="${s:${#pre}+${#w}+2}"; body='(a here-document)'; } ;; esac ;;
|
|
1470
1506
|
esac
|
|
1471
1507
|
while :; do # to the closing quote that is not escaped
|
|
1472
1508
|
pre="${s%%[\"\\]*}"
|
|
1473
|
-
|
|
1509
|
+
case "$pre" in *[\$\`\!]*) [ "$t" = 1 ] || _C47_F="${_C47_F}x" ;; esac
|
|
1510
|
+
[ "$pre" = "$s" ] && { body="$body$s"; s=""; _C47_F="${_C47_F}u"; break; }
|
|
1474
1511
|
c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
|
|
1475
1512
|
[ "$c2" = '"' ] && break
|
|
1513
|
+
_C47_F="${_C47_F}x"
|
|
1476
1514
|
body="$body${s:0:1}"; s="${s:1}"
|
|
1477
1515
|
done
|
|
1478
1516
|
# "$@" and "${A[@]}" stay quoted and still become SEVERAL words: `set -- -n; git commit -m x "$@"`.
|
|
@@ -1480,7 +1518,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
|
|
|
1480
1518
|
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1481
1519
|
\$) case "$s" in
|
|
1482
1520
|
\'*) # $'…' : a quoted text in which a backslash escapes, so a quote after one does not close it
|
|
1483
|
-
s="${s:1}"; body=""
|
|
1521
|
+
s="${s:1}"; body=""; _C47_F="${_C47_F}a"
|
|
1484
1522
|
while :; do
|
|
1485
1523
|
pre="${s%%[\'\\]*}"
|
|
1486
1524
|
[ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
|
|
@@ -1535,7 +1573,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
|
|
|
1535
1573
|
esac
|
|
1536
1574
|
out="$out$c" ;;
|
|
1537
1575
|
\#) case "$out" in
|
|
1538
|
-
''|*[$' \t']) case "$s" in *"$nl"*) pre="${s%%"$nl"*}"; s="${s:${#pre}}" ;; *) s="" ;; esac ;; # a comment, to the end of its line
|
|
1576
|
+
''|*[$' \t']) case "$s" in *"$nl"*) pre="${s%%"$nl"*}"; s="${s:${#pre}}" ;; *) s="" ;; esac; _C47_F="${_C47_F}c" ;; # a comment, to the end of its line
|
|
1539
1577
|
*) out="$out#" ;;
|
|
1540
1578
|
esac ;;
|
|
1541
1579
|
esac
|
|
@@ -1557,6 +1595,30 @@ _c47_w(){ # $1 = one token of _C47_T -> _W: the word as the command receives it
|
|
|
1557
1595
|
case "$t" in *[\$\*\?\[\{]*) _WX=1 ;; esac
|
|
1558
1596
|
_W="$_W$t"
|
|
1559
1597
|
}
|
|
1598
|
+
_c47_q(){ # _C47_T -> _C47_TQ: the same text with every quoted span and escaped character as the one placeholder Q
|
|
1599
|
+
# Split on the marker once (the fields are text, number, text, number, …) and joined: one pass. A first version
|
|
1600
|
+
# cut the markers off one at a time, and each cut scans the string: 9.6 s for 30 KB of escaped characters.
|
|
1601
|
+
# bash does not split on \001 itself (it is the character bash quotes with, measured: one field), so the marker
|
|
1602
|
+
# is first written as \037. A command that holds a \037 of its own is cut the slow way: its fields would shift.
|
|
1603
|
+
[ "${_C47_TQS-$'\002'}" = "$_C47_T" ] && return 0 # this text, already written so in this call
|
|
1604
|
+
local -a P=(); local f o="" b="" k=0 n=0 u=$'\037' m="$_C47_M" s="$_C47_T"
|
|
1605
|
+
case "$s" in
|
|
1606
|
+
*"$u"*) while :; do
|
|
1607
|
+
case "$s" in *"$m"*"$m"*) ;; *) break ;; esac
|
|
1608
|
+
b="${s%%"$m"*}"; s="${s:${#b}+1}"; s="${s#*"$m"}"; o="$o${b}Q"
|
|
1609
|
+
done
|
|
1610
|
+
o="$o$s" ;;
|
|
1611
|
+
*) # The joined text is gathered 64 fields at a time: appending each field to the whole text copies the whole text.
|
|
1612
|
+
_gsub "$s" "$m" "$u"
|
|
1613
|
+
IFS="$u" read -r -d '' -a P <<< "$_GS" || true
|
|
1614
|
+
for f in ${P[@]+"${P[@]}"}; do
|
|
1615
|
+
if [ "$k" = 0 ]; then b="$b$f"; k=1; else b="${b}Q"; k=0; fi
|
|
1616
|
+
n=$((n+1)); [ "$n" -ge 64 ] && { o="$o$b"; b=""; n=0; }
|
|
1617
|
+
done
|
|
1618
|
+
o="$o$b"; o="${o%$'\n'}" ;; # the newline the here-string added
|
|
1619
|
+
esac
|
|
1620
|
+
_C47_TQ="$o"; _C47_TQS="$_C47_T"
|
|
1621
|
+
}
|
|
1560
1622
|
# The four rules below cost a grep each, so they run only for a command that could name a gate file at all. Builtin.
|
|
1561
1623
|
_gate_named(){ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*|*[Gg][Ii][Tt][/\\]*[Cc][Oo][Nn][Ff][Ii][Gg]*|*[Gg][Ii][Tt]-[Ss][Hh][Ii][Mm]*|*.[Gg][Ii][Tt][Cc][Oo][Nn][Ff][Ii][Gg]*) return 0 ;; esac; return 1; }
|
|
1562
1624
|
# CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
|
|
@@ -1823,7 +1885,7 @@ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a
|
|
|
1823
1885
|
t="$CMD_UQ"; _APN=0
|
|
1824
1886
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1825
1887
|
case "$t" in
|
|
1826
|
-
*crewforth-appr*|*crew-approval-pending*) _APN=1 ;;
|
|
1888
|
+
*crewforth-appr*|*crew-approval-pending*|*crew-approval-asked*) _APN=1 ;;
|
|
1827
1889
|
esac
|
|
1828
1890
|
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
1829
1891
|
# FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
|
|
@@ -1929,6 +1991,12 @@ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
|
1929
1991
|
fi
|
|
1930
1992
|
[ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
|
|
1931
1993
|
_mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
|
|
1994
|
+
# The refuter ledger (hooks/refuter-ledger.sh) by its file name: the agent's name alone is not refused, so a command
|
|
1995
|
+
# that greps for crew-refuter still runs. The same honest scope as the rule above.
|
|
1996
|
+
_rf_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1997
|
+
case "$CMD_UQ" in *crew-refuter.tsv*) rc=0 ;; esac
|
|
1998
|
+
[ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
|
|
1999
|
+
_rf_named && block "the refuter ledger named in a command (only Crewforth's hook writes it)" "4.5" tamper
|
|
1932
2000
|
_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
|
|
1933
2001
|
|
|
1934
2002
|
# §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
|
|
@@ -2288,43 +2356,28 @@ _a44_collapse(){ # $1 = command, $2 = commit|push, $3 = tool -> 0 and _A44_S (q
|
|
|
2288
2356
|
local LC_ALL=C
|
|
2289
2357
|
case "$s" in *[!\ -~]*|*\?*) _A44_WHY="outside the Bash tool an approved call is plain ASCII on one line (PowerShell reads typographic quotes as quotes); use the Bash tool for any other message"; return 1 ;; esac
|
|
2290
2358
|
fi
|
|
2359
|
+
# ONE READER. The pairing of quotes used to be done here a second time; the text is now read by _c47_read, and
|
|
2360
|
+
# this function only says which of the things that reader met an approved call may not hold. Blanks and newlines
|
|
2361
|
+
# around the call are no part of it (a newline INSIDE it is a second line, and is refused below as a separator).
|
|
2291
2362
|
_gsub "$s" $'\r' ''; s="$_GS"
|
|
2363
|
+
while :; do case "$s" in [$' \t\n']*) s="${s:1}" ;; *) break ;; esac; done
|
|
2364
|
+
# A `2>&1` that ends the call goes with those blanks, on a line of its own too: it redirects nothing but the
|
|
2365
|
+
# call's own output (`git commit -m x` + newline + `2>&1` was accepted before the readers were one, and still is).
|
|
2292
2366
|
while :; do
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
\\) case "$s" in
|
|
2298
|
-
$'\n'*) out="$out "; s="${s:1}" ;; # a backslash-newline joins two lines
|
|
2299
|
-
*) _A44_WHY="a backslash outside quotes"; return 1 ;;
|
|
2300
|
-
esac ;;
|
|
2301
|
-
\') case "$s" in *\'*) ;; *) _A44_WHY="an unpaired quote"; return 1 ;; esac
|
|
2302
|
-
body="${s%%\'*}"; s="${s:${#body}+1}"; out="${out}Q" ;;
|
|
2303
|
-
*) case "$s" in
|
|
2304
|
-
'$(cat <<'\'*)
|
|
2305
|
-
rest="${s:9}"; w="${rest%%\'*}"
|
|
2306
|
-
case "$w" in ''|*[!A-Za-z0-9_]*) _A44_WHY="a here-document whose delimiter is not a plain quoted word"; return 1 ;; esac
|
|
2307
|
-
rest="${rest:${#w}+1}"
|
|
2308
|
-
case "$rest" in $'\n'*) rest="${rest:1}" ;; *) _A44_WHY="text on the line that opens the here-document"; return 1 ;; esac
|
|
2309
|
-
# The body ends at the FIRST line that is the delimiter, as it does for the shell.
|
|
2310
|
-
case "$rest" in
|
|
2311
|
-
"$w"$'\n'*) rest="${rest:${#w}+1}" ;;
|
|
2312
|
-
*$'\n'"$w"$'\n'*) body="${rest%%$'\n'"$w"$'\n'*}"; rest="${rest:${#body}+${#w}+2}" ;;
|
|
2313
|
-
*) _A44_WHY="a here-document that is not closed on its own line"; return 1 ;;
|
|
2314
|
-
esac
|
|
2315
|
-
while :; do case "$rest" in [$' \t']*) rest="${rest:1}" ;; *) break ;; esac; done
|
|
2316
|
-
case "$rest" in
|
|
2317
|
-
')"'*) s="${rest:2}"; out="${out}Q" ;;
|
|
2318
|
-
*) _A44_WHY="something follows the here-document inside the substitution"; return 1 ;;
|
|
2319
|
-
esac ;;
|
|
2320
|
-
*\"*)
|
|
2321
|
-
body="${s%%\"*}"
|
|
2322
|
-
case "$body" in *[\$\`\\\!]*) _A44_WHY="a double-quoted text that holds \$, a backtick, a backslash or ! (single-quote it, or read the message from a here-document with a quoted delimiter)"; return 1 ;; esac
|
|
2323
|
-
s="${s:${#body}+1}"; out="${out}Q" ;;
|
|
2324
|
-
*) _A44_WHY="an unpaired quote"; return 1 ;;
|
|
2325
|
-
esac ;;
|
|
2367
|
+
case "$s" in
|
|
2368
|
+
*[$' \t\n']) s="${s%?}" ;;
|
|
2369
|
+
*[$' \t\n']'2>&1') s="${s%'2>&1'}" ;;
|
|
2370
|
+
*) break ;;
|
|
2326
2371
|
esac
|
|
2327
2372
|
done
|
|
2373
|
+
_c47_read "$s"
|
|
2374
|
+
case "$_C47_F" in
|
|
2375
|
+
*u*) _A44_WHY="an unpaired quote"; return 1 ;;
|
|
2376
|
+
*b*) _A44_WHY="a backslash outside quotes"; return 1 ;;
|
|
2377
|
+
*x*) _A44_WHY="a double-quoted text that holds \$, a backtick, a backslash or ! (single-quote it, or read the message from a here-document with a quoted delimiter)"; return 1 ;;
|
|
2378
|
+
*[ac]*) _A44_WHY="the call is more than one plain command (a separator, a substitution, a redirection, a comment, a glob or a second line)"; return 1 ;;
|
|
2379
|
+
esac
|
|
2380
|
+
_c47_q; out="$_C47_TQ" # a quoted span is one placeholder
|
|
2328
2381
|
out="${out//$'\t'/ }"; out="${out// 2>&1/ }"
|
|
2329
2382
|
while :; do case "$out" in [$' \n']*) out="${out:1}" ;; *) break ;; esac; done
|
|
2330
2383
|
while :; do case "$out" in *[$' \n']) out="${out%?}" ;; *) break ;; esac; done
|
|
@@ -2463,12 +2516,130 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
|
|
|
2463
2516
|
_APRW="read: ${seen:-no settings file found}"
|
|
2464
2517
|
return 1
|
|
2465
2518
|
}
|
|
2519
|
+
# IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
|
|
2520
|
+
# on PreToolUse, prompt-approval.sh on PostToolUse). Asked only to choose which way the refusal offers first.
|
|
2521
|
+
_q_wired(){
|
|
2522
|
+
local f t d="${CLAUDE_PROJECT_DIR:-.}"
|
|
2523
|
+
d="${d//\\//}"; d="${d%/}"
|
|
2524
|
+
for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
|
|
2525
|
+
[ -n "$f" ] && [ -f "$f" ] || continue
|
|
2526
|
+
t=""; IFS= read -r -d '' t < "$f" || true
|
|
2527
|
+
case "$t" in *'"PreToolUse"'*AskUserQuestion*guard-question.sh*'"PostToolUse"'*AskUserQuestion*prompt-approval.sh*) return 0 ;; esac
|
|
2528
|
+
done
|
|
2529
|
+
return 1
|
|
2530
|
+
}
|
|
2531
|
+
# MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
|
|
2532
|
+
# from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
|
|
2533
|
+
# asks as it always has.
|
|
2534
|
+
_gb_prh(){ # 0 = a PermissionRequest hook may be wired; _PRH names where
|
|
2535
|
+
local here="${BASH_SOURCE%/*}"
|
|
2536
|
+
[ "$here" = "${BASH_SOURCE}" ] && here=.
|
|
2537
|
+
[ -f "$here/prompt-approval.sh" ] || return 1
|
|
2538
|
+
declare -F _crew_prh_scan >/dev/null 2>&1 || . "$here/prompt-approval.sh"
|
|
2539
|
+
declare -F _crew_prh_scan >/dev/null 2>&1 || return 1
|
|
2540
|
+
_crew_prh_scan
|
|
2541
|
+
}
|
|
2466
2542
|
allow_approved(){
|
|
2467
2543
|
gatelog ALLOW 4.4 "the user's own approval message covers this command"
|
|
2468
2544
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
|
|
2469
2545
|
"$(json_escape "§4.4: the user approved this in their own message, and what was approved is what this command does")"
|
|
2470
2546
|
exit 0
|
|
2471
2547
|
}
|
|
2548
|
+
# THE WRITTEN APPROVAL (and, in auto, the click). Reached where no prompt can be shown to a person - auto, dontAsk - and
|
|
2549
|
+
# in default / acceptEdits where a PermissionRequest hook may answer the prompt before the person does (3.2.0).
|
|
2550
|
+
_gate_written(){
|
|
2551
|
+
# No prompt reaches a person here, but their own message does: see _approval_ok above.
|
|
2552
|
+
_approval_ok && allow_approved
|
|
2553
|
+
[ -n "${_PRF:-}" ] || { _PRF=0; _gb_prh && _PRF=1; } # the default branch has already asked
|
|
2554
|
+
gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
|
|
2555
|
+
case "$PERM_MODE" in
|
|
2556
|
+
default|acceptEdits)
|
|
2557
|
+
echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and a PermissionRequest hook may be wired ($_PRH). Such a hook answers the permission prompt before the person does, so this gate does not raise one." >&2 ;;
|
|
2558
|
+
*)
|
|
2559
|
+
echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
|
|
2560
|
+
[ "$_PRF" = 1 ] && echo "The approval question is closed too: a PermissionRequest hook may be wired ($_PRH), and it can answer the question before the person does." >&2 ;;
|
|
2561
|
+
esac
|
|
2562
|
+
echo "Not allowed now: $_APW." >&2
|
|
2563
|
+
if ! _appr_wired; then
|
|
2564
|
+
# The record is written by one hook. Where it is not wired, a message from the user records nothing, and
|
|
2565
|
+
# telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
|
|
2566
|
+
echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A '/crew-approve' message records nothing here." >&2
|
|
2567
|
+
if [ "$_PRF" = 1 ]; then
|
|
2568
|
+
echo "Tell the user exactly that, in their language, and give them the way that works: the command in their own terminal (or wire prompt-approval.sh, or remove the PermissionRequest hook)." >&2
|
|
2569
|
+
else
|
|
2570
|
+
echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
2571
|
+
fi
|
|
2572
|
+
echo "Do not create an approval any other way." >&2
|
|
2573
|
+
exit 2
|
|
2574
|
+
fi
|
|
2575
|
+
_aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
|
|
2576
|
+
# WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
|
|
2577
|
+
# the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
|
|
2578
|
+
# THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
|
|
2579
|
+
# within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
|
|
2580
|
+
# and the name rule above): a session that could write it would choose what the user's approval covers.
|
|
2581
|
+
_pw=""
|
|
2582
|
+
if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
|
|
2583
|
+
_pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
|
|
2584
|
+
_json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
|
|
2585
|
+
case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
|
|
2586
|
+
if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
|
|
2587
|
+
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
|
|
2588
|
+
_po=""; _ps=""; _pt=""
|
|
2589
|
+
if [ -f "$_pd/crew-approval-pending" ]; then
|
|
2590
|
+
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"
|
|
2591
|
+
fi
|
|
2592
|
+
_pw="$_aps"
|
|
2593
|
+
case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
|
|
2594
|
+
if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
|
|
2595
|
+
case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
|
|
2596
|
+
fi
|
|
2597
|
+
{ printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
|
|
2598
|
+
fi
|
|
2599
|
+
fi
|
|
2600
|
+
if [ "$PERM_MODE" = auto ] && [ "$_PRF" = 0 ] && _q_wired && declare -F _crew_q_pre >/dev/null 2>&1 && [ -f "${BASH_SOURCE%/*}/guard-question.sh" ]; then
|
|
2601
|
+
echo "ASK THE USER WITH ONE CLICK (stage first: the approval covers what is staged at that moment): show the commit message whole, in ONE code block, then call AskUserQuestion with exactly this input, nothing added or changed:" >&2
|
|
2602
|
+
echo "{\"questions\":[$_CREW_Q_JSON]}" >&2
|
|
2603
|
+
echo "Their answer is recorded by Crewforth itself (the line it adds says so); then run the command it names. If they would rather type, the way below works as well." >&2
|
|
2604
|
+
fi
|
|
2605
|
+
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
|
|
2606
|
+
echo " 1. the commit message, whole, in ONE code block;" >&2
|
|
2607
|
+
if [ -n "$_pw" ]; then
|
|
2608
|
+
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
|
|
2609
|
+
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
|
|
2610
|
+
else
|
|
2611
|
+
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
|
|
2612
|
+
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
|
|
2613
|
+
fi
|
|
2614
|
+
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
|
|
2615
|
+
echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
|
|
2616
|
+
if [ "$_PRF" = 1 ]; then
|
|
2617
|
+
echo "The other way: the command in their own terminal. (Shift+Tab to default/acceptEdits does not help while that hook may be wired: the gate does not raise a prompt it could answer.)" >&2
|
|
2618
|
+
else
|
|
2619
|
+
echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
2620
|
+
fi
|
|
2621
|
+
echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
|
|
2622
|
+
exit 2
|
|
2623
|
+
}
|
|
2624
|
+
# WHO WROTE THE MESSAGE. The discipline gives the commit message to crew-commit-agent; for a long time nothing
|
|
2625
|
+
# called it and the main thread wrote every message itself. This says so, and never blocks (K7): the latest
|
|
2626
|
+
# crew-review-agent or crew-commit-agent call in the session's transcript, and when it is not the commit agent the
|
|
2627
|
+
# approval carries one line. A call made by a subagent (agent_id in the payload), a push, or a transcript that
|
|
2628
|
+
# cannot be read says nothing: not known is not "did not run". One grep over the transcript, on a commit only
|
|
2629
|
+
# (measured: 0.52 s on a 107 MB transcript, 0.03 s on a 3 MB one, macOS).
|
|
2630
|
+
_commit_agent_note(){
|
|
2631
|
+
_CAN=""
|
|
2632
|
+
git_has "$CMD_SEEN" 'commit' || return 0
|
|
2633
|
+
case "$INPUT" in *'"agent_id"'*) return 0 ;; esac
|
|
2634
|
+
local tp last
|
|
2635
|
+
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
|
|
2636
|
+
[ -n "$tp" ] && [ -f "$tp" ] || return 0
|
|
2637
|
+
last="$(grep -E '"subagent_type": ?"([a-z0-9-]+:)?crew-(review|commit)-agent"' "$tp" 2>/dev/null | tail -n 1)"
|
|
2638
|
+
case "$last" in *crew-commit-agent*) return 0 ;; esac
|
|
2639
|
+
_CAN="⚠️ crew-commit-agent has not run since the last review: the message above was written by the main thread, not by the agent that owns it (/crew-ship).
|
|
2640
|
+
|
|
2641
|
+
"
|
|
2642
|
+
}
|
|
2472
2643
|
# Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
|
|
2473
2644
|
ask_user(){
|
|
2474
2645
|
gatelog ASK 4.4 "commit/push approval prompt"
|
|
@@ -2669,7 +2840,7 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
|
|
|
2669
2840
|
if [ "$_WX" = 1 ]; then _C47_CFG="a -c setting the shell expands first"
|
|
2670
2841
|
else
|
|
2671
2842
|
shopt -q nocasematch && i=1 || i=0; shopt -s nocasematch
|
|
2672
|
-
case "$_W" in *hookspath*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
|
|
2843
|
+
case "$_W" in *hookspath*|*hookschain*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
|
|
2673
2844
|
[ "$i" = 0 ] && shopt -u nocasematch
|
|
2674
2845
|
fi ;;
|
|
2675
2846
|
-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix) shift ;;
|
|
@@ -2947,84 +3118,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
2947
3118
|
# arguments.
|
|
2948
3119
|
_c46_scan() {
|
|
2949
3120
|
_C46_REDIR=0; _C46_WT=""
|
|
2950
|
-
|
|
2951
|
-
#
|
|
2952
|
-
# `git commit -m '
|
|
2953
|
-
#
|
|
2954
|
-
#
|
|
2955
|
-
#
|
|
2956
|
-
# the
|
|
2957
|
-
#
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
# `\r` and `\n` in place. A stock Windows machine is the second case, so there the fallback IS the product.
|
|
2961
|
-
#
|
|
2962
|
-
# ALL carriage returns go, escaped or real, and THIS is the line that fixes the defect CI caught — stated
|
|
2963
|
-
# plainly because the first explanation written here was wrong. A CI run on `windows-latest` refused
|
|
2964
|
-
# `git commit \` + CRLF + ` -m c` while the same case passed on macOS AND on a real Windows desktop, and
|
|
2965
|
-
# the cause is the TIER: that image has jq, so the command arrives DECODED, while a stock desktop has
|
|
2966
|
-
# neither jq nor python3 and sees JSON's two-character escapes. A Windows-native binary also opens stdout
|
|
2967
|
-
# in TEXT mode, so every LF it writes becomes CRLF — and a command that already held `\r\n` reaches the
|
|
2968
|
-
# hook as `\` + CR + CR + LF. The previous single CRLF fold ate one CR, the continuation rule then looked
|
|
2969
|
-
# for `\` + LF, found the other CR in the way, and the lone backslash was read as a pathspec. Stripping
|
|
2970
|
-
# every CR handles any number of them with no loop. Isolated by measurement: this one line, applied to the
|
|
2971
|
-
# failing version on its own, turns that case green.
|
|
2972
|
-
#
|
|
2973
|
-
# A LONE CR keeps its verdict rather than its bytes: it vanishes into the token before it, so
|
|
2974
|
-
# `git commit -m c<CR>echo done` still leaves `done` a bare token and is still refused — confirmed correct
|
|
2975
|
-
# by a Windows session that checked bash's own argv (CR is not in IFS, so git really is handed `done`).
|
|
2976
|
-
#
|
|
2977
|
-
# The bracket expression `[\\]` is used for every backslash below, and it is NOT what fixed the above. It
|
|
2978
|
-
# replaced escaped patterns on a hypothesis about bash 5 reading them differently, and that hypothesis was
|
|
2979
|
-
# MEASURED FALSE on bash 5.3.15 — both spellings behave alike there. It stays only because a bracket
|
|
2980
|
-
# expression cannot be misread by anyone (calibrated here: `[\\]n` matches a backslash before an `n` and
|
|
2981
|
-
# leaves a bare `n` alone) and because it keeps replacements free of backslashes. No correctness claim.
|
|
2982
|
-
# Through _gsub (see there): the same substitutions, a piece at a time.
|
|
2983
|
-
_gsub "$s" '[\\]r' '' bs; _gsub "$_GS" $'\r' ''; _gsub "$_GS" '[\\]n' $'\n' bs
|
|
2984
|
-
_gsub "$_GS" '[\\]'"\\'" Q bs; _gsub "$_GS" '[\\]\"' Q bs; s="$_GS"
|
|
2985
|
-
# A quoted span collapses to the single placeholder `Q`, and this is the whole design: the CONTENT of a
|
|
2986
|
-
# quote must not be read as an option or a path, but the TOKEN has to survive. The first version DELETED
|
|
2987
|
-
# the span, and that was a measured fail-open on Windows — `git commit -m c "a.txt"` and
|
|
2988
|
-
# `git commit -m c -- "a.txt"` lost the pathspec entirely and were allowed, while their unquoted spellings
|
|
2989
|
-
# were refused, and both commit the same unreviewed line. No trick is needed to get past a gate like that,
|
|
2990
|
-
# only the ordinary habit of quoting a path, which is mandatory once it contains a space. The placeholder
|
|
2991
|
-
# keeps adjacency too, so `-m"msg"` becomes `-mQ` (an attached value, correct) and not `-m Q`.
|
|
2992
|
-
# Double quotes go FIRST: an apostrophe inside a double-quoted message (`-m "don't"`) is ordinary, whereas
|
|
2993
|
-
# a double quote inside a single-quoted one is rare, so this order mangles the rarer shape.
|
|
2994
|
-
# THE WALK IS BY LENGTH, and only over what is left. It used to be `rest="${s#*\"}"` on the whole string, and
|
|
2995
|
-
# that one expansion costs the SQUARE of the distance to the quote in bash (measured, bash 3.2, one call: 13 ms at
|
|
2996
|
-
# 5 KB, 108 ms at 10 KB, 317 ms at 21 KB) — once per pair, on a string whose collapsed front keeps growing. A
|
|
2997
|
-
# quote-dense commit message therefore took 6 s at 10 KB, 14 s at 16 KB and 335 s at 46 KB (macOS; 14.2 s at
|
|
2998
|
-
# 18 KB on Windows), in 3.0.1 too, and a hook that reaches its 600 s timeout does not block. The result is the
|
|
2999
|
-
# same string: the text before a pair holds no quote and neither does the `Q` that replaces it, so the next pair
|
|
3000
|
-
# is the first one in the remainder (pinned in smoke-test against the old loop, on the same inputs).
|
|
3001
|
-
local acc="" p2
|
|
3002
|
-
while :; do
|
|
3003
|
-
case "$s" in *\"*\"*) ;; *) break ;; esac
|
|
3004
|
-
pre="${s%%\"*}"; rest="${s:${#pre}+1}"; p2="${rest%%\"*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
|
|
3005
|
-
done
|
|
3006
|
-
s="$acc$s"; acc=""
|
|
3007
|
-
while :; do
|
|
3008
|
-
case "$s" in *\'*\'*) ;; *) break ;; esac
|
|
3009
|
-
pre="${s%%\'*}"; rest="${s:${#pre}+1}"; p2="${rest%%\'*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
|
|
3010
|
-
done
|
|
3011
|
-
s="$acc$s"
|
|
3012
|
-
# A backslash-newline is a LINE CONTINUATION, the opposite of a separator: it JOINS. Measured, before this,
|
|
3013
|
-
# `git commit \` + newline + ` -m c` refused the commit, because the lone `\` became a token and read as a
|
|
3014
|
-
# pathspec. It has to run before the conversion below, or the newline is gone when we look for it.
|
|
3015
|
-
_gsub "$s" '[\\]'$'\n' ' ' bs; s="$_GS"
|
|
3016
|
-
# A NEWLINE IS A COMMAND SEPARATOR and has to become one, or a multi-line Bash call is misread: measured,
|
|
3017
|
-
# `git commit -m c` followed by a line `echo done` refused the commit, because `done` was read as a
|
|
3018
|
-
# pathspec. Splitting alone cannot save it — the default IFS eats newlines, so the boundary is gone by the
|
|
3019
|
-
# time the walk sees tokens.
|
|
3020
|
-
_gsub "$s" $'\n' ';'; s="$_GS"
|
|
3021
|
-
# SEPARATORS BECOME THEIR OWN TOKENS. Without this, `git commit -m c; echo done` refused the commit: the
|
|
3022
|
-
# token was `c;`, `-m` swallowed it whole, the separator inside it was never seen, and `echo` read as a
|
|
3023
|
-
# pathspec. The fail-open twin is worse and was measured too — in
|
|
3024
|
-
# `if true; then git commit -m c -- a.txt; fi` the pathspec token was `a.txt;`, which the `--` lookahead
|
|
3025
|
-
# dismissed as a separator, so the commit was ALLOWED. Padding fixes both at once, and `&&`/`||` simply
|
|
3026
|
-
# become two tokens, which the walk already treats as one boundary.
|
|
3027
|
-
_gsub "$s" ';' ' ; '; _gsub "$_GS" '&' ' & '; _gsub "$_GS" '|' ' | '; s="$_GS"
|
|
3121
|
+
# ONE READER. This scan used to pair the quotes itself (double quotes first, then single, an escaped quote
|
|
3122
|
+
# neutralised by hand, a newline turned into `;`), and so did the approval reader; three readings of the same
|
|
3123
|
+
# text could disagree, and one of them did: `git commit -m 'a"' -a ; echo 'b"'` hid `-a` inside quotes paired
|
|
3124
|
+
# the wrong way. It now walks what _c47_read reads (quotes as the shell pairs them, an escaped character as a
|
|
3125
|
+
# character, a here-document's body gone, every separator as ` ; `, a redirection set apart from its word).
|
|
3126
|
+
# A quoted span or an escaped character becomes the one placeholder `Q`: its CONTENT must not be read as an
|
|
3127
|
+
# option or a path, but the TOKEN has to survive, and it keeps its place (`-m"msg"` is `-mQ`, an attached
|
|
3128
|
+
# value; a quoted pathspec is still a word after `--`).
|
|
3129
|
+
local s
|
|
3130
|
+
_c47_read "$1"; _c47_q; s="$_C47_TQ"
|
|
3028
3131
|
# Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
|
|
3029
3132
|
# commit could be judged on whatever files happen to sit there.
|
|
3030
3133
|
local unglob=0
|
|
@@ -3227,7 +3330,11 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3227
3330
|
# answered by the thing it is gating is not a gate.
|
|
3228
3331
|
case "$PERM_MODE" in
|
|
3229
3332
|
default|acceptEdits)
|
|
3230
|
-
# A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
|
|
3333
|
+
# A prompt provably reaches the user in these modes: ask, and let them approve in one keypress. UNLESS a
|
|
3334
|
+
# PermissionRequest hook may be wired: it answers that prompt before the person does (measured on 2.1.294:
|
|
3335
|
+
# the Bash prompt this gate raises was approved by such a hook with nobody at the keyboard). Then the typed
|
|
3336
|
+
# approval is the only one left, exactly as in auto.
|
|
3337
|
+
_gb_prh && { _PRF=1; _gate_written; }
|
|
3231
3338
|
SHORT="$CMD"
|
|
3232
3339
|
[ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
|
|
3233
3340
|
# §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
|
|
@@ -3240,65 +3347,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3240
3347
|
|
|
3241
3348
|
" ;;
|
|
3242
3349
|
esac
|
|
3350
|
+
_commit_agent_note
|
|
3243
3351
|
ask_user "§4.4 commit/push approval gate. Claude wants to run:
|
|
3244
3352
|
|
|
3245
3353
|
$SHORT
|
|
3246
3354
|
|
|
3247
|
-
${BRANCH_WARN}Approve only if the commit message above was shown to you and you agree with it. Approving lets Claude run the command itself."
|
|
3355
|
+
${BRANCH_WARN}${_CAN}Approve only if the commit message above was shown to you and you agree with it. Approving lets Claude run the command itself."
|
|
3248
3356
|
;;
|
|
3249
3357
|
auto|dontAsk)
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
|
|
3253
|
-
echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
|
|
3254
|
-
echo "Not allowed now: $_APW." >&2
|
|
3255
|
-
if ! _appr_wired; then
|
|
3256
|
-
# The record is written by one hook. Where it is not wired, a message from the user records nothing, and
|
|
3257
|
-
# telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
|
|
3258
|
-
echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A '/crew-approve' message records nothing here." >&2
|
|
3259
|
-
echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
3260
|
-
echo "Do not create an approval any other way." >&2
|
|
3261
|
-
exit 2
|
|
3262
|
-
fi
|
|
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
|
|
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
|
|
3289
|
-
echo " 1. the commit message, whole, in ONE code block;" >&2
|
|
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
|
|
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
|
|
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
|
|
3299
|
-
echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
3300
|
-
echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
|
|
3301
|
-
exit 2 ;;
|
|
3358
|
+
_commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
|
|
3359
|
+
_gate_written ;;
|
|
3302
3360
|
*)
|
|
3303
3361
|
# bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
|
|
3304
3362
|
# human, so we fail closed rather than let the gate silently evaporate.
|