crewforth 3.1.1 → 3.2.0-rc.10
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 +323 -0
- package/README.md +6 -3
- package/VERSION +1 -1
- package/adopt.sh +66 -6
- package/bin/cli.js +3 -2
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +15 -14
- package/kit/agents/crew-commit-agent.md +11 -4
- 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-review-agent.md +2 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +128 -6
- package/kit/eval/gate-report.sh +2 -0
- 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 +132 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +164 -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/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +266 -0
- package/kit/eval/lib/crew-map/stacks.mjs +547 -0
- package/kit/eval/lib/crew-map/surface.mjs +535 -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/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +19 -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/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +878 -53
- package/kit/hooks/agent-outcome.sh +44 -12
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +177 -24
- package/kit/hooks/guard-bash.sh +295 -204
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +24 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +389 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +51 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -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 +55 -0
- package/kit/skills/crew-review/SKILL.md +4 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- 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 +25 -2
- package/kit/studio/server/index.js +187 -21
- 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/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -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/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +185 -0
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +287 -17
- package/kit/studio/web/canvas.js +58 -122
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +13 -6
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +410 -0
- package/kit/studio/web/map.js +596 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +165 -0
- package/kit/studio/web/tokens.css +6 -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 +24 -1
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -148,7 +148,14 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
|
|
|
148
148
|
# ---- /CREW-JOIN
|
|
149
149
|
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
150
150
|
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
151
|
-
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
151
|
+
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"
|
|
152
|
+
# Where the gate log goes: one rule for every gate and reader (eval/lib/gate-log.sh).
|
|
153
|
+
[ -f "$_crew_d/../eval/lib/gate-log.sh" ] && . "$_crew_d/../eval/lib/gate-log.sh"
|
|
154
|
+
# What a stopped or asked command would have touched (eval/lib/preview.sh), read only when one is stopped or asked.
|
|
155
|
+
_CREW_PV_LIB="$_crew_d/../eval/lib/preview.sh"; unset _crew_d
|
|
156
|
+
# The preview of a command. ALWAYS called as "$(_pv_line …)": the command substitution is the subshell that keeps an
|
|
157
|
+
# exit or a failure in the library from ending this hook or changing its decision (smoke plants both).
|
|
158
|
+
_pv_line(){ [ -f "$_CREW_PV_LIB" ] || return 0; . "$_CREW_PV_LIB" 2>/dev/null && crew_preview "$1" "$2" 2>/dev/null && printf '%s' "${_CREW_PV:-}"; return 0; }
|
|
152
159
|
# Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
|
|
153
160
|
# of this hook. `read` returns 1 at end of input; the text is read regardless.
|
|
154
161
|
IFS= read -r -d '' INPUT || true
|
|
@@ -168,20 +175,17 @@ IFS= read -r -d '' INPUT || true
|
|
|
168
175
|
# token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
|
|
169
176
|
# buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
|
|
170
177
|
# positive, which is the only thing it is good for.
|
|
171
|
-
# Where the
|
|
172
|
-
#
|
|
173
|
-
# the
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
+
# Where the log goes. An explicit CREW_GATE_LOG is the operator's call and is used as given. .claude/gate-log.tsv
|
|
179
|
+
# only where writing there cannot surprise anyone: outside a git repo, or where git ignores it (a kit install
|
|
180
|
+
# ignores .claude/; the plugin edition drops into repos the installer never touched, and this repo proved the
|
|
181
|
+
# failure: the suite left a gate-log.tsv in `git status` waiting to be committed). Elsewhere it goes inside the
|
|
182
|
+
# repository's .git, which is never committed. git is asked only when a decision is logged (a block, an approval
|
|
183
|
+
# prompt or a CLAUDE_GIT_OK allow); ordinary commands never reach it.
|
|
184
|
+
# Since 3.2.0 a repository that tracks .claude/ gets the log inside its .git (eval/lib/gate-log.sh), where it was
|
|
185
|
+
# not written at all before.
|
|
178
186
|
_gatelog_path(){
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
182
|
-
git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
|
|
183
|
-
fi
|
|
184
|
-
printf '%s' ".claude/gate-log.tsv"
|
|
187
|
+
declare -F crew_gate_log_write >/dev/null || return 0
|
|
188
|
+
crew_gate_log_write; printf '%s' "$_CREW_GL"
|
|
185
189
|
}
|
|
186
190
|
gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
|
|
187
191
|
# Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
|
|
@@ -720,6 +724,8 @@ block(){
|
|
|
720
724
|
gatelog BLOCK "$2" "$1"
|
|
721
725
|
echo "GUARD (§$2): '$1' stopped AT THE TOOL LEVEL." >&2
|
|
722
726
|
[ -z "${_BLOCK_NOTE:-}" ] || echo "$_BLOCK_NOTE" >&2
|
|
727
|
+
# What it would have touched, when the rule names a kind (_PV_KIND): read only, after the decision is made.
|
|
728
|
+
if [ -n "${_PV_KIND:-}" ]; then _pvo="$(_pv_line "$_PV_KIND" "${_PV_CMD:-$CMD}")"; [ -n "$_pvo" ] && echo "$_pvo" >&2; fi
|
|
723
729
|
case "${3:-}" in
|
|
724
730
|
loss) echo "Nothing here is undone by retrying — but the refusal is not the end of the task. Do the same job the reversible way: name the paths instead of sweeping, and look first (git clean -n, ls) so you act on what you can see. Only if the broad form is genuinely required does the user run it in their own terminal." >&2 ;;
|
|
725
731
|
history) echo "This rewrites or discards work that is already committed. Ask the person who shares the branch; a new commit usually reaches the same end without rewriting." >&2 ;;
|
|
@@ -865,15 +871,15 @@ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag
|
|
|
865
871
|
_git_d45(){ # $1 = command text; block() exits on the first rule it breaks
|
|
866
872
|
local CMD="$1" HAS_GIT=0 _PUSHSEG _seg # has() reads CMD: this one
|
|
867
873
|
case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; esac
|
|
868
|
-
{ git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
|
|
874
|
+
{ git_has "$CMD" 'reset' && has '--hard'; } && { _PV_KIND=reset; _PV_CMD="$CMD"; block "git reset --hard" "4.5" history; }
|
|
869
875
|
if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
|
|
870
876
|
_grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
|
|
871
877
|
while IFS= read -r _seg; do
|
|
872
878
|
[ -n "$_seg" ] || continue
|
|
873
|
-
_push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
|
|
879
|
+
_push_forces "$_seg" && { _PV_KIND=push; _PV_CMD="$_seg"; block "git push --force" "4.5" history; break; }
|
|
874
880
|
done <<< "$_PUSHSEG"
|
|
875
881
|
fi
|
|
876
|
-
{ git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
|
|
882
|
+
{ git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && { _PV_KIND=clean; _PV_CMD="$CMD"; block "git clean -f" "4.5" loss; }
|
|
877
883
|
return 0
|
|
878
884
|
}
|
|
879
885
|
# ---- the quoted argument of a command that does not run it ---------------------------------------------------
|
|
@@ -1109,7 +1115,7 @@ case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
|
|
|
1109
1115
|
# short cluster, so `rm -Rf /`, `rm -fR /`, `rm -f -r /` and `rm --recursive --force /` all walked past while
|
|
1110
1116
|
# `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
|
|
1111
1117
|
# another reaching the identical state. Case, flag order and the long form are all the same command.
|
|
1112
|
-
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && block "destructive rm -rf" "4.5" loss
|
|
1118
|
+
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE 'rm +(-[A-Za-z]* +|--[a-z-]+ +)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)( +(-[A-Za-z]+|--[a-z-]+))* +.*(/|\*|~)' && { _PV_KIND=rm; _PV_CMD="$CMD"; block "destructive rm -rf" "4.5" loss; }
|
|
1113
1119
|
# ADDED (3.1.0): `.` and `..` as a target carry none of those three characters and are the widest targets there are —
|
|
1114
1120
|
# `rm -rf .` at a project root takes the whole tree, `..` its parent (both passed). Narrower than the test above on
|
|
1115
1121
|
# purpose, so that it adds no noise: `rm` must stand at a command position, `sudo` or not (`git rm -r --cached .`, `npm rm`,
|
|
@@ -1121,7 +1127,7 @@ case "$CMD" in *[Rr][Mm]*)
|
|
|
1121
1127
|
_rmc="$CMD"
|
|
1122
1128
|
# a backslash-newline continues the line (`rm -rf \⏎.`); folded only when one is there and the command is short
|
|
1123
1129
|
case "$CMD" in *\\"$_RMNL"*) [ "${#CMD}" -le 4096 ] && _rmc="${CMD//\\$_RMNL/ }" ;; esac
|
|
1124
|
-
[[ $_rmc =~ $_RMDOT ]] && block "destructive rm -rf" "4.5" loss ;; esac
|
|
1130
|
+
[[ $_rmc =~ $_RMDOT ]] && { _PV_KIND=rm; _PV_CMD="$_rmc"; block "destructive rm -rf" "4.5" loss; } ;; esac
|
|
1125
1131
|
# A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
|
|
1126
1132
|
# undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
|
|
1127
1133
|
# Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
|
|
@@ -1303,15 +1309,20 @@ case "$CMD" in *[Ii][Cc][Aa][Cc][Ll][Ss]*) : ;; *) false ;; esac && _grep "$CMD"
|
|
|
1303
1309
|
_HP_B=$' \t' # blank = space or TAB only; a literal `\t` inside [ ] is a backslash and a t to ERE
|
|
1304
1310
|
_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
1311
|
_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
|
-
|
|
1312
|
+
# THE SAME RULE FOR crewforth.hookschain. When a project's own hook chain lives OUTSIDE the repository, adopt.sh
|
|
1313
|
+
# keeps its path in this clone's git config under that key and the shim reads it on every commit. A write to it
|
|
1314
|
+
# drops the project's chain or points the shim at another directory, so it is judged by the reader above, key for
|
|
1315
|
+
# key: reads pass, every write form is refused, and so is removing or renaming the [crewforth] section.
|
|
1316
|
+
_HP_G='*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*'; _HC_G='*[Hh][Oo][Oo][Kk][Ss][Cc][Hh][Aa][Ii][Nn]*'
|
|
1317
|
+
_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
|
|
1318
|
+
local bsnl=$'\\\n' c="$1" key="$2" kg="$3" sec="$4" t seg r n=0 nc=0
|
|
1319
|
+
local bare="${_HP_PRE}${key}[${_HP_B}]*\$"
|
|
1309
1320
|
local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
|
|
1310
1321
|
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
1322
|
local -a reads=()
|
|
1312
1323
|
case "$c" in *"$bsnl"*) _gsub "$c" '\\'$'\n' ' ' bs; c="$_GS" ;; esac # backslash-newline -> a space
|
|
1313
1324
|
_unquoted "$c"; t="$_GS"
|
|
1314
|
-
|
|
1325
|
+
[[ $t == $kg || $t == *[Ss][Ee][Cc][Tt][Ii][Oo][Nn]* ]] || return 1 # after unquoting: hooks"P"ath
|
|
1315
1326
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1316
1327
|
# The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
|
|
1317
1328
|
# would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
|
|
@@ -1327,7 +1338,7 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
|
|
|
1327
1338
|
case "$raw" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
|
|
1328
1339
|
_split_segs "$c"
|
|
1329
1340
|
for seg in ${_SPL[@]+"${_SPL[@]}"}; do
|
|
1330
|
-
[[ $seg ==
|
|
1341
|
+
[[ $seg == $kg ]] || continue
|
|
1331
1342
|
n=$((n+1)); [ "$n" -gt 64 ] && { reads=(); break; } # past 64, no read is argued: the old rule judges it all
|
|
1332
1343
|
# A write flag anywhere in the segment disproves the read (`--list --unset core.hooksPath`): git 2.54 refuses
|
|
1333
1344
|
# those pairs, but the gate does not lean on another tool's option parser.
|
|
@@ -1339,14 +1350,15 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
|
|
|
1339
1350
|
# Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
|
|
1340
1351
|
for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
|
|
1341
1352
|
_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:]]+)?
|
|
1353
|
+
_ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*'"$key" "$kg" && return 0
|
|
1354
|
+
_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
1355
|
return 1
|
|
1345
1356
|
}
|
|
1346
1357
|
# Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
|
|
1347
1358
|
# that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
|
|
1348
1359
|
[ "$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
|
|
1360
|
+
[ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" 'core\.hooksPath' "$_HP_G" core && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1361
|
+
[ "$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
1362
|
# The same setting by two side doors. `git config include.path f` (or includeIf.<cond>.path) makes git read ANOTHER
|
|
1351
1363
|
# file as configuration, and that file can carry hooksPath; GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
|
|
1352
1364
|
# hand git a setting through the environment (measured: with them a commit skipped its hooks). Both are refused by
|
|
@@ -1359,6 +1371,8 @@ if [ "$HAS_GIT" = 1 ]; then
|
|
|
1359
1371
|
# key right after `-c` (measured, 3.1.0 review).
|
|
1360
1372
|
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' \
|
|
1361
1373
|
&& block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1374
|
+
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+crewforth\.hookschain' "$_HC_G" \
|
|
1375
|
+
&& block "git -c crewforth.hookschain (the project's own hook chain, dropped or pointed elsewhere)" "4.5" tamper
|
|
1362
1376
|
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+include(if)?\.' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
|
|
1363
1377
|
&& block "git config include.path (another file read as configuration)" "4.5" tamper
|
|
1364
1378
|
_ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&][^|;&]*(include\.path|includeif\.)' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
|
|
@@ -1367,6 +1381,7 @@ if [ "$HAS_GIT" = 1 ]; then
|
|
|
1367
1381
|
_ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&]([^|;&]*[[:space:]])?(--edit|-e)([[:space:]]|$)' '*[Cc][Oo][Nn][Ff][Ii][Gg]*' \
|
|
1368
1382
|
&& block "git config --edit (the configuration file opened for writing)" "4.5" tamper
|
|
1369
1383
|
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
|
|
1384
|
+
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
1385
|
fi
|
|
1371
1386
|
# A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
|
|
1372
1387
|
# in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
|
|
@@ -1447,8 +1462,14 @@ fi
|
|
|
1447
1462
|
# ---- the reader of a call (the commit gate further down describes what it is for) ----
|
|
1448
1463
|
_c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span and every escaped character as one marker
|
|
1449
1464
|
# (\001 n \001, its text in _C47_Q[n]); redirections set apart from the words they touch; heredoc bodies gone
|
|
1465
|
+
[ "${_C47_SRC-$'\002'}" = "$1" ] && return 0 # the same text, already read in this call
|
|
1450
1466
|
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=""
|
|
1467
|
+
_C47_M=$'\001'; _C47_Q=(); _C47_QX=""; _C47_SRC="$1"
|
|
1468
|
+
# WHAT THE READER MET, for a caller that accepts only the plainest text (_a44_collapse): b = a backslash that
|
|
1469
|
+
# escapes a character outside quotes · u = a quote that is never closed · x = a double-quoted text holding `$`, a
|
|
1470
|
+
# backtick, a backslash or `!` (other than a message read from a here-document with a quoted delimiter, closed
|
|
1471
|
+
# at once) · a = a $'…' text · c = a comment that was left out. Flags only: what is read does not depend on them.
|
|
1472
|
+
_C47_F=""
|
|
1452
1473
|
_gsub "$s" $'\r' ''; _gsub "$_GS" "$_C47_M" ''; s="$_GS"
|
|
1453
1474
|
while :; do
|
|
1454
1475
|
pre="${s%%[\"\'\\\;\&\|\<\>\#\(\)\`\$$nl]*}"; out="$out$pre"
|
|
@@ -1457,22 +1478,45 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
|
|
|
1457
1478
|
case "$c" in
|
|
1458
1479
|
\\) case "$s" in
|
|
1459
1480
|
"$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
|
|
1481
|
+
'') _C47_F="${_C47_F}b" ;; # the last character of the call: it escapes nothing, and goes
|
|
1482
|
+
*) _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
1483
|
esac ;;
|
|
1463
|
-
\') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s="" ;; esac
|
|
1484
|
+
\') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s=""; _C47_F="${_C47_F}u" ;; esac
|
|
1464
1485
|
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1465
1486
|
\") body=""
|
|
1487
|
+
t=0 # t = 1: the strict form of that message (below)
|
|
1466
1488
|
case "$s" in
|
|
1467
1489
|
'$(cat <<'*) # a message read from a here-document: its body is skipped as a whole, quotes and all
|
|
1490
|
+
# THE STRICT FORM, the only substitution an approved call may hold: `"$(cat <<'WORD'` + newline, a body
|
|
1491
|
+
# that ends at the first line that is WORD, then `)"` with nothing but blanks before it. Its body is
|
|
1492
|
+
# literal. Anything looser (an unquoted delimiter, text after it, something after the body) is flagged x.
|
|
1493
|
+
case "$s" in '$(cat <<'\'*)
|
|
1494
|
+
c2="${s:9}"; w="${c2%%\'*}"
|
|
1495
|
+
case "$w" in ''|*[!A-Za-z0-9_]*) ;; *)
|
|
1496
|
+
c2="${c2:${#w}+1}"
|
|
1497
|
+
case "$c2" in "$nl"*) c2="${c2:1}"
|
|
1498
|
+
case "$c2" in
|
|
1499
|
+
"$w$nl"*) c2="${c2:${#w}+1}"; t=1 ;;
|
|
1500
|
+
*"$nl$w$nl"*) pre="${c2%%"$nl$w$nl"*}"; c2="${c2:${#pre}+${#w}+2}"; t=1 ;;
|
|
1501
|
+
esac
|
|
1502
|
+
if [ "$t" = 1 ]; then
|
|
1503
|
+
while :; do case "$c2" in [$' \t']*) c2="${c2:1}" ;; *) break ;; esac; done
|
|
1504
|
+
case "$c2" in ')"'*) ;; *) t=0 ;; esac
|
|
1505
|
+
fi ;;
|
|
1506
|
+
esac ;;
|
|
1507
|
+
esac ;;
|
|
1508
|
+
esac
|
|
1509
|
+
[ "$t" = 1 ] || _C47_F="${_C47_F}x"
|
|
1468
1510
|
w="${s:8}"; w="${w#-}"; w="${w#[\'\"]}"; w="${w%%[!A-Za-z0-9_]*}"
|
|
1469
1511
|
case "$s" in *"$nl$w$nl"*) [ -n "$w" ] && { pre="${s%%"$nl$w$nl"*}"; s="${s:${#pre}+${#w}+2}"; body='(a here-document)'; } ;; esac ;;
|
|
1470
1512
|
esac
|
|
1471
1513
|
while :; do # to the closing quote that is not escaped
|
|
1472
1514
|
pre="${s%%[\"\\]*}"
|
|
1473
|
-
|
|
1515
|
+
case "$pre" in *[\$\`\!]*) [ "$t" = 1 ] || _C47_F="${_C47_F}x" ;; esac
|
|
1516
|
+
[ "$pre" = "$s" ] && { body="$body$s"; s=""; _C47_F="${_C47_F}u"; break; }
|
|
1474
1517
|
c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
|
|
1475
1518
|
[ "$c2" = '"' ] && break
|
|
1519
|
+
_C47_F="${_C47_F}x"
|
|
1476
1520
|
body="$body${s:0:1}"; s="${s:1}"
|
|
1477
1521
|
done
|
|
1478
1522
|
# "$@" and "${A[@]}" stay quoted and still become SEVERAL words: `set -- -n; git commit -m x "$@"`.
|
|
@@ -1480,7 +1524,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
|
|
|
1480
1524
|
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1481
1525
|
\$) case "$s" in
|
|
1482
1526
|
\'*) # $'…' : a quoted text in which a backslash escapes, so a quote after one does not close it
|
|
1483
|
-
s="${s:1}"; body=""
|
|
1527
|
+
s="${s:1}"; body=""; _C47_F="${_C47_F}a"
|
|
1484
1528
|
while :; do
|
|
1485
1529
|
pre="${s%%[\'\\]*}"
|
|
1486
1530
|
[ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
|
|
@@ -1535,7 +1579,7 @@ _c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span a
|
|
|
1535
1579
|
esac
|
|
1536
1580
|
out="$out$c" ;;
|
|
1537
1581
|
\#) 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
|
|
1582
|
+
''|*[$' \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
1583
|
*) out="$out#" ;;
|
|
1540
1584
|
esac ;;
|
|
1541
1585
|
esac
|
|
@@ -1557,6 +1601,30 @@ _c47_w(){ # $1 = one token of _C47_T -> _W: the word as the command receives it
|
|
|
1557
1601
|
case "$t" in *[\$\*\?\[\{]*) _WX=1 ;; esac
|
|
1558
1602
|
_W="$_W$t"
|
|
1559
1603
|
}
|
|
1604
|
+
_c47_q(){ # _C47_T -> _C47_TQ: the same text with every quoted span and escaped character as the one placeholder Q
|
|
1605
|
+
# Split on the marker once (the fields are text, number, text, number, …) and joined: one pass. A first version
|
|
1606
|
+
# cut the markers off one at a time, and each cut scans the string: 9.6 s for 30 KB of escaped characters.
|
|
1607
|
+
# bash does not split on \001 itself (it is the character bash quotes with, measured: one field), so the marker
|
|
1608
|
+
# is first written as \037. A command that holds a \037 of its own is cut the slow way: its fields would shift.
|
|
1609
|
+
[ "${_C47_TQS-$'\002'}" = "$_C47_T" ] && return 0 # this text, already written so in this call
|
|
1610
|
+
local -a P=(); local f o="" b="" k=0 n=0 u=$'\037' m="$_C47_M" s="$_C47_T"
|
|
1611
|
+
case "$s" in
|
|
1612
|
+
*"$u"*) while :; do
|
|
1613
|
+
case "$s" in *"$m"*"$m"*) ;; *) break ;; esac
|
|
1614
|
+
b="${s%%"$m"*}"; s="${s:${#b}+1}"; s="${s#*"$m"}"; o="$o${b}Q"
|
|
1615
|
+
done
|
|
1616
|
+
o="$o$s" ;;
|
|
1617
|
+
*) # The joined text is gathered 64 fields at a time: appending each field to the whole text copies the whole text.
|
|
1618
|
+
_gsub "$s" "$m" "$u"
|
|
1619
|
+
IFS="$u" read -r -d '' -a P <<< "$_GS" || true
|
|
1620
|
+
for f in ${P[@]+"${P[@]}"}; do
|
|
1621
|
+
if [ "$k" = 0 ]; then b="$b$f"; k=1; else b="${b}Q"; k=0; fi
|
|
1622
|
+
n=$((n+1)); [ "$n" -ge 64 ] && { o="$o$b"; b=""; n=0; }
|
|
1623
|
+
done
|
|
1624
|
+
o="$o$b"; o="${o%$'\n'}" ;; # the newline the here-string added
|
|
1625
|
+
esac
|
|
1626
|
+
_C47_TQ="$o"; _C47_TQS="$_C47_T"
|
|
1627
|
+
}
|
|
1560
1628
|
# 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
1629
|
_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
1630
|
# CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
|
|
@@ -1823,7 +1891,7 @@ _appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a
|
|
|
1823
1891
|
t="$CMD_UQ"; _APN=0
|
|
1824
1892
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1825
1893
|
case "$t" in
|
|
1826
|
-
*crewforth-appr*|*crew-approval-pending*) _APN=1 ;;
|
|
1894
|
+
*crewforth-appr*|*crew-approval-pending*|*crew-approval-asked*) _APN=1 ;;
|
|
1827
1895
|
esac
|
|
1828
1896
|
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
1829
1897
|
# FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
|
|
@@ -1929,6 +1997,12 @@ _mr_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
|
1929
1997
|
fi
|
|
1930
1998
|
[ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
|
|
1931
1999
|
_mr_named && block "a model routing record named in a command (the user and Crewforth's hooks write it)" "4.5" tamper
|
|
2000
|
+
# The refuter ledger (hooks/refuter-ledger.sh) by its file name: the agent's name alone is not refused, so a command
|
|
2001
|
+
# that greps for crew-refuter still runs. The same honest scope as the rule above.
|
|
2002
|
+
_rf_named(){ local nc=0 rc=1; shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
2003
|
+
case "$CMD_UQ" in *crew-refuter.tsv*) rc=0 ;; esac
|
|
2004
|
+
[ "$nc" = 0 ] && shopt -u nocasematch; return "$rc"; }
|
|
2005
|
+
_rf_named && block "the refuter ledger named in a command (only Crewforth's hook writes it)" "4.5" tamper
|
|
1932
2006
|
_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
2007
|
|
|
1934
2008
|
# §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
|
|
@@ -2288,43 +2362,28 @@ _a44_collapse(){ # $1 = command, $2 = commit|push, $3 = tool -> 0 and _A44_S (q
|
|
|
2288
2362
|
local LC_ALL=C
|
|
2289
2363
|
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
2364
|
fi
|
|
2365
|
+
# ONE READER. The pairing of quotes used to be done here a second time; the text is now read by _c47_read, and
|
|
2366
|
+
# this function only says which of the things that reader met an approved call may not hold. Blanks and newlines
|
|
2367
|
+
# around the call are no part of it (a newline INSIDE it is a second line, and is refused below as a separator).
|
|
2291
2368
|
_gsub "$s" $'\r' ''; s="$_GS"
|
|
2369
|
+
while :; do case "$s" in [$' \t\n']*) s="${s:1}" ;; *) break ;; esac; done
|
|
2370
|
+
# A `2>&1` that ends the call goes with those blanks, on a line of its own too: it redirects nothing but the
|
|
2371
|
+
# call's own output (`git commit -m x` + newline + `2>&1` was accepted before the readers were one, and still is).
|
|
2292
2372
|
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 ;;
|
|
2373
|
+
case "$s" in
|
|
2374
|
+
*[$' \t\n']) s="${s%?}" ;;
|
|
2375
|
+
*[$' \t\n']'2>&1') s="${s%'2>&1'}" ;;
|
|
2376
|
+
*) break ;;
|
|
2326
2377
|
esac
|
|
2327
2378
|
done
|
|
2379
|
+
_c47_read "$s"
|
|
2380
|
+
case "$_C47_F" in
|
|
2381
|
+
*u*) _A44_WHY="an unpaired quote"; return 1 ;;
|
|
2382
|
+
*b*) _A44_WHY="a backslash outside quotes"; return 1 ;;
|
|
2383
|
+
*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 ;;
|
|
2384
|
+
*[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 ;;
|
|
2385
|
+
esac
|
|
2386
|
+
_c47_q; out="$_C47_TQ" # a quoted span is one placeholder
|
|
2328
2387
|
out="${out//$'\t'/ }"; out="${out// 2>&1/ }"
|
|
2329
2388
|
while :; do case "$out" in [$' \n']*) out="${out:1}" ;; *) break ;; esac; done
|
|
2330
2389
|
while :; do case "$out" in *[$' \n']) out="${out%?}" ;; *) break ;; esac; done
|
|
@@ -2378,7 +2437,7 @@ _p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u]
|
|
|
2378
2437
|
return 0
|
|
2379
2438
|
}
|
|
2380
2439
|
_approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
|
|
2381
|
-
local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool
|
|
2440
|
+
local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool tag _h2
|
|
2382
2441
|
_APW="no approval from the user is on record"
|
|
2383
2442
|
git_has "$CMD_SEEN" 'commit' && hc=0; git_has "$CMD_SEEN" 'push' && hp=0
|
|
2384
2443
|
if [ "$hc" = 0 ] && [ "$hp" = 0 ]; then
|
|
@@ -2419,7 +2478,17 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
|
|
|
2419
2478
|
_p44_scan "$_A44_S" || { _APW="the push is not in the one form an approval covers, git push <remote> <branch> ($_P44_WHY)"; return 1; }
|
|
2420
2479
|
[ -n "$b" ] && [ -n "$r" ] && [ -n "$u" ] || { _APW="the approval record could not be read"; return 1; }
|
|
2421
2480
|
if [ "$_P44_REMOTE" != "$r" ]; then _APW="the user approved a push to $r, not to $_P44_REMOTE"; return 1; fi
|
|
2422
|
-
|
|
2481
|
+
# A TAG ON THE APPROVED COMMIT is part of that push (a release is tagged on what was approved): `git push <remote>
|
|
2482
|
+
# <tag>` passes when <tag> is a tag and not also a branch, and it points at the commit the approval covers. A tag
|
|
2483
|
+
# on any other commit, --tags, and a refspec are refused as before.
|
|
2484
|
+
tag=""
|
|
2485
|
+
case "$_P44_REF" in
|
|
2486
|
+
"$b"|HEAD) ;;
|
|
2487
|
+
*[!A-Za-z0-9._/-]*|-*|*..*|*/) _APW="the user approved a push of $b, not of $_P44_REF"; return 1 ;;
|
|
2488
|
+
*) if git -C "$dir" show-ref --verify --quiet "refs/tags/$_P44_REF" 2>/dev/null \
|
|
2489
|
+
&& ! git -C "$dir" show-ref --verify --quiet "refs/heads/$_P44_REF" 2>/dev/null; then tag="$_P44_REF"
|
|
2490
|
+
else _APW="the user approved a push of $b, not of $_P44_REF"; return 1; fi ;;
|
|
2491
|
+
esac
|
|
2423
2492
|
cur="$(git -C "$dir" symbolic-ref --short -q HEAD 2>/dev/null || true)"
|
|
2424
2493
|
[ "$cur" = "$b" ] || { _APW="the user approved a push of $b, and the branch checked out now is ${cur:-<detached>}"; return 1; }
|
|
2425
2494
|
# Where the remote pushes TO, with pushurl and pushInsteadOf applied: the name alone can be pointed elsewhere.
|
|
@@ -2432,8 +2501,16 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
|
|
|
2432
2501
|
cur="$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
2433
2502
|
if [ "$op" = push ]; then
|
|
2434
2503
|
[ "$cur" = "$h" ] || { _APW="HEAD moved after the user approved the push (approved ${h:0:7}, now ${cur:0:7})"; return 1; }
|
|
2504
|
+
if [ -n "$tag" ]; then
|
|
2505
|
+
cur="$(git -C "$dir" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || echo NONE)"
|
|
2506
|
+
[ "$cur" = "$h" ] || { _APW="the tag $tag is on ${cur:0:7}, not on the commit the user approved for push (${h:0:7})"; return 1; }
|
|
2507
|
+
fi
|
|
2435
2508
|
return 0
|
|
2436
2509
|
fi
|
|
2510
|
+
if [ -n "$tag" ]; then
|
|
2511
|
+
_h2="$cur"; cur="$(git -C "$dir" rev-parse --verify --quiet "refs/tags/$tag^{commit}" 2>/dev/null || echo NONE)"
|
|
2512
|
+
[ "$cur" = "$_h2" ] || { _APW="the tag $tag is on ${cur:0:7}, not on the commit the user approved for push (${_h2:0:7})"; return 1; }
|
|
2513
|
+
fi
|
|
2437
2514
|
# commit+push: HEAD has to be the commit the approved index became.
|
|
2438
2515
|
cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^' 2>/dev/null || echo NONE)"
|
|
2439
2516
|
if [ "$h" = NONE ] || [ "$cur" != "$h" ]; then
|
|
@@ -2463,12 +2540,130 @@ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file
|
|
|
2463
2540
|
_APRW="read: ${seen:-no settings file found}"
|
|
2464
2541
|
return 1
|
|
2465
2542
|
}
|
|
2543
|
+
# IS THE APPROVAL QUESTION WIRED? The same reading as above, for the two halves on AskUserQuestion (guard-question.sh
|
|
2544
|
+
# on PreToolUse, prompt-approval.sh on PostToolUse). Asked only to choose which way the refusal offers first.
|
|
2545
|
+
_q_wired(){ declare -F _crew_q_wired >/dev/null 2>&1 && _crew_q_wired; } # defined once, in prompt-approval.sh
|
|
2546
|
+
# MAY A PermissionRequest HOOK ANSWER THE PROMPT? One scan, defined in prompt-approval.sh (_crew_prh_scan) and read
|
|
2547
|
+
# from there. Without that file there is no other approval to fall back to, so the answer is "no" and the gate
|
|
2548
|
+
# asks as it always has.
|
|
2549
|
+
_gb_prh(){ # 0 = a PermissionRequest hook may be wired; _PRH names where
|
|
2550
|
+
local here="${BASH_SOURCE%/*}"
|
|
2551
|
+
[ "$here" = "${BASH_SOURCE}" ] && here=.
|
|
2552
|
+
[ -f "$here/prompt-approval.sh" ] || return 1
|
|
2553
|
+
declare -F _crew_prh_scan >/dev/null 2>&1 || . "$here/prompt-approval.sh"
|
|
2554
|
+
declare -F _crew_prh_scan >/dev/null 2>&1 || return 1
|
|
2555
|
+
_crew_prh_scan
|
|
2556
|
+
}
|
|
2466
2557
|
allow_approved(){
|
|
2467
2558
|
gatelog ALLOW 4.4 "the user's own approval message covers this command"
|
|
2468
2559
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
|
|
2469
2560
|
"$(json_escape "§4.4: the user approved this in their own message, and what was approved is what this command does")"
|
|
2470
2561
|
exit 0
|
|
2471
2562
|
}
|
|
2563
|
+
# THE WRITTEN APPROVAL (and, in auto, the click). Reached where no prompt can be shown to a person - auto, dontAsk - and
|
|
2564
|
+
# in default / acceptEdits where a PermissionRequest hook may answer the prompt before the person does (3.2.0).
|
|
2565
|
+
_gate_written(){
|
|
2566
|
+
# No prompt reaches a person here, but their own message does: see _approval_ok above.
|
|
2567
|
+
_approval_ok && allow_approved
|
|
2568
|
+
[ -n "${_PRF:-}" ] || { _PRF=0; _gb_prh && _PRF=1; } # the default branch has already asked
|
|
2569
|
+
gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
|
|
2570
|
+
case "$PERM_MODE" in
|
|
2571
|
+
default|acceptEdits)
|
|
2572
|
+
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 ;;
|
|
2573
|
+
*)
|
|
2574
|
+
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
|
|
2575
|
+
[ "$_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 ;;
|
|
2576
|
+
esac
|
|
2577
|
+
echo "Not allowed now: $_APW." >&2
|
|
2578
|
+
if ! _appr_wired; then
|
|
2579
|
+
# The record is written by one hook. Where it is not wired, a message from the user records nothing, and
|
|
2580
|
+
# telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
|
|
2581
|
+
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
|
|
2582
|
+
if [ "$_PRF" = 1 ]; then
|
|
2583
|
+
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
|
|
2584
|
+
else
|
|
2585
|
+
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
|
|
2586
|
+
fi
|
|
2587
|
+
echo "Do not create an approval any other way." >&2
|
|
2588
|
+
exit 2
|
|
2589
|
+
fi
|
|
2590
|
+
_aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
|
|
2591
|
+
# WHAT WAS REFUSED IS WRITTEN DOWN, so that the user can approve it with `/crew-approve` and nothing after it:
|
|
2592
|
+
# the session, the operation and the second, in .claude/state/crew-approval-pending. The operation is what
|
|
2593
|
+
# THIS gate saw in the command it refused; a commit refused and then a push refused in the same session
|
|
2594
|
+
# within 30 minutes are one thing waiting, commit+push. The file is closed to the session (guard-write.sh,
|
|
2595
|
+
# and the name rule above): a session that could write it would choose what the user's approval covers.
|
|
2596
|
+
_pw=""
|
|
2597
|
+
if ! { git_has "$CMD_SEEN" 'commit' && git_has "$CMD_SEEN" 'push'; }; then
|
|
2598
|
+
_pd="${CLAUDE_PROJECT_DIR:-${_CWD:-.}}"; _pd="${_pd//\\//}"; _pd="${_pd%/}/.claude/state"
|
|
2599
|
+
_json_slice "$INPUT" session_id >/dev/null; _psid="$_JS"
|
|
2600
|
+
case "$_psid" in ''|*[!A-Za-z0-9._-]*) _psid="" ;; esac
|
|
2601
|
+
if [ -n "$_psid" ] && mkdir -p "$_pd" 2>/dev/null; then
|
|
2602
|
+
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
|
|
2603
|
+
_po=""; _ps=""; _pt=""
|
|
2604
|
+
if [ -f "$_pd/crew-approval-pending" ]; then
|
|
2605
|
+
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"
|
|
2606
|
+
fi
|
|
2607
|
+
_pw="$_aps"
|
|
2608
|
+
case "$_pt" in ''|*[!0-9]*) _pt=0 ;; esac
|
|
2609
|
+
if [ "$_ps" = "$_psid" ] && [ $((_pn - _pt)) -ge 0 ] && [ $((_pn - _pt)) -le 1800 ]; then
|
|
2610
|
+
case "$_po:$_aps" in commit:push|push:commit|commit+push:*) _pw="commit+push" ;; esac
|
|
2611
|
+
fi
|
|
2612
|
+
{ printf 'sid=%s\nop=%s\nts=%s\n' "$_psid" "$_pw" "$_pn" > "$_pd/crew-approval-pending"; } 2>/dev/null || _pw=""
|
|
2613
|
+
fi
|
|
2614
|
+
fi
|
|
2615
|
+
# ONE WAY TO ASK. With the question open, the refusal used to print it and then the typed way as an equal choice,
|
|
2616
|
+
# and the session picked the typed one 7 times in 9 (a real `auto` session on rc.3, 2026-10-18): the user was told
|
|
2617
|
+
# to type a command for what one click does. The typed way is now the fallback, said as one.
|
|
2618
|
+
_QOFF=0
|
|
2619
|
+
if [ "$PERM_MODE" = auto ] && [ "$_PRF" = 0 ] && _q_wired && declare -F _crew_q_pre >/dev/null 2>&1 && [ -f "${BASH_SOURCE%/*}/guard-question.sh" ]; then
|
|
2620
|
+
_QOFF=1
|
|
2621
|
+
echo "$_CREW_Q_ASK" >&2
|
|
2622
|
+
echo "{\"questions\":[$_CREW_Q_JSON]}" >&2
|
|
2623
|
+
echo "Their answer is recorded by Crewforth itself (the line it adds says so); then run the command it names. Ask it this way only: do not also give the user a command to type." >&2
|
|
2624
|
+
fi
|
|
2625
|
+
if [ "$_QOFF" = 1 ]; then
|
|
2626
|
+
echo "ONLY IF THAT QUESTION CANNOT BE ASKED (the tool call is refused, or the user dismisses it), ask instead, in their language, in this shape and with nothing after it:" >&2
|
|
2627
|
+
else
|
|
2628
|
+
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
|
|
2629
|
+
fi
|
|
2630
|
+
echo " 1. the commit message, whole, in ONE code block;" >&2
|
|
2631
|
+
if [ -n "$_pw" ]; then
|
|
2632
|
+
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
|
|
2633
|
+
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
|
|
2634
|
+
else
|
|
2635
|
+
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
|
|
2636
|
+
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
|
|
2637
|
+
fi
|
|
2638
|
+
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
|
|
2639
|
+
echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
|
|
2640
|
+
if [ "$_PRF" = 1 ]; then
|
|
2641
|
+
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
|
|
2642
|
+
else
|
|
2643
|
+
echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
|
|
2644
|
+
fi
|
|
2645
|
+
echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
|
|
2646
|
+
exit 2
|
|
2647
|
+
}
|
|
2648
|
+
# WHO WROTE THE MESSAGE. The discipline gives the commit message to crew-commit-agent; for a long time nothing
|
|
2649
|
+
# called it and the main thread wrote every message itself. This says so, and never blocks (K7): the latest
|
|
2650
|
+
# crew-review-agent or crew-commit-agent call in the session's transcript, and when it is not the commit agent the
|
|
2651
|
+
# approval carries one line. A call made by a subagent (agent_id in the payload), a push, or a transcript that
|
|
2652
|
+
# cannot be read says nothing: not known is not "did not run". One grep over the transcript, on a commit only
|
|
2653
|
+
# (measured: 0.52 s on a 107 MB transcript, 0.03 s on a 3 MB one, macOS).
|
|
2654
|
+
_commit_agent_note(){
|
|
2655
|
+
_CAN=""
|
|
2656
|
+
git_has "$CMD_SEEN" 'commit' || return 0
|
|
2657
|
+
case "$INPUT" in *'"agent_id"'*) return 0 ;; esac
|
|
2658
|
+
local tp last
|
|
2659
|
+
_json_slice "$INPUT" transcript_path >/dev/null; _json_unescape "$_JS" >/dev/null; tp="${_JU//\\//}"
|
|
2660
|
+
[ -n "$tp" ] && [ -f "$tp" ] || return 0
|
|
2661
|
+
last="$(grep -E '"subagent_type": ?"([a-z0-9-]+:)?crew-(review|commit)-agent"' "$tp" 2>/dev/null | tail -n 1)"
|
|
2662
|
+
case "$last" in *crew-commit-agent*) return 0 ;; esac
|
|
2663
|
+
_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).
|
|
2664
|
+
|
|
2665
|
+
"
|
|
2666
|
+
}
|
|
2472
2667
|
# Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
|
|
2473
2668
|
ask_user(){
|
|
2474
2669
|
gatelog ASK 4.4 "commit/push approval prompt"
|
|
@@ -2669,7 +2864,7 @@ _c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_A
|
|
|
2669
2864
|
if [ "$_WX" = 1 ]; then _C47_CFG="a -c setting the shell expands first"
|
|
2670
2865
|
else
|
|
2671
2866
|
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
|
|
2867
|
+
case "$_W" in *hookspath*|*hookschain*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
|
|
2673
2868
|
[ "$i" = 0 ] && shopt -u nocasematch
|
|
2674
2869
|
fi ;;
|
|
2675
2870
|
-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix) shift ;;
|
|
@@ -2947,84 +3142,16 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
2947
3142
|
# arguments.
|
|
2948
3143
|
_c46_scan() {
|
|
2949
3144
|
_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"
|
|
3145
|
+
# ONE READER. This scan used to pair the quotes itself (double quotes first, then single, an escaped quote
|
|
3146
|
+
# neutralised by hand, a newline turned into `;`), and so did the approval reader; three readings of the same
|
|
3147
|
+
# text could disagree, and one of them did: `git commit -m 'a"' -a ; echo 'b"'` hid `-a` inside quotes paired
|
|
3148
|
+
# the wrong way. It now walks what _c47_read reads (quotes as the shell pairs them, an escaped character as a
|
|
3149
|
+
# character, a here-document's body gone, every separator as ` ; `, a redirection set apart from its word).
|
|
3150
|
+
# A quoted span or an escaped character becomes the one placeholder `Q`: its CONTENT must not be read as an
|
|
3151
|
+
# option or a path, but the TOKEN has to survive, and it keeps its place (`-m"msg"` is `-mQ`, an attached
|
|
3152
|
+
# value; a quoted pathspec is still a word after `--`).
|
|
3153
|
+
local s
|
|
3154
|
+
_c47_read "$1"; _c47_q; s="$_C47_TQ"
|
|
3028
3155
|
# Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
|
|
3029
3156
|
# commit could be judged on whatever files happen to sit there.
|
|
3030
3157
|
local unglob=0
|
|
@@ -3227,7 +3354,11 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3227
3354
|
# answered by the thing it is gating is not a gate.
|
|
3228
3355
|
case "$PERM_MODE" in
|
|
3229
3356
|
default|acceptEdits)
|
|
3230
|
-
# A prompt provably reaches the user in these modes: ask, and let them approve in one keypress.
|
|
3357
|
+
# A prompt provably reaches the user in these modes: ask, and let them approve in one keypress. UNLESS a
|
|
3358
|
+
# PermissionRequest hook may be wired: it answers that prompt before the person does (measured on 2.1.294:
|
|
3359
|
+
# the Bash prompt this gate raises was approved by such a hook with nobody at the keyboard). Then the typed
|
|
3360
|
+
# approval is the only one left, exactly as in auto.
|
|
3361
|
+
_gb_prh && { _PRF=1; _gate_written; }
|
|
3231
3362
|
SHORT="$CMD"
|
|
3232
3363
|
[ "${#SHORT}" -gt 300 ] && { _utf8_cut "$SHORT" 300; SHORT="$_UC…"; }
|
|
3233
3364
|
# §4.4 branch guard: committing straight onto main/master is not blocked (a fresh project legitimately
|
|
@@ -3235,70 +3366,30 @@ if git_has "$CMD_SEEN" 'commit|push'; then
|
|
|
3235
3366
|
BRANCH_WARN=""
|
|
3236
3367
|
# symbolic-ref names the branch even before its first commit, where rev-parse answers "HEAD" and the very
|
|
3237
3368
|
# first commit onto main went by without this line. A detached HEAD reads empty: not the default branch.
|
|
3238
|
-
|
|
3369
|
+
# Read from HEAD with builtins (the gate log's crew_git_dir finds the git dir, a linked worktree's too): the
|
|
3370
|
+
# preview below pays one subshell to keep this hook's decision out of its reach, and this pays it back.
|
|
3371
|
+
_hb=""; if declare -F crew_git_dir >/dev/null 2>&1 && crew_git_dir . && [ -f "$_CREW_GD/HEAD" ]; then
|
|
3372
|
+
IFS= read -r _hb < "$_CREW_GD/HEAD"; _hb="${_hb%$'\r'}"; case "$_hb" in "ref: refs/heads/"*) _hb="${_hb#ref: refs/heads/}" ;; *) _hb="" ;; esac
|
|
3373
|
+
else _hb="$(git symbolic-ref --short -q HEAD 2>/dev/null)"; fi
|
|
3374
|
+
case "$_hb" in
|
|
3239
3375
|
main|master) BRANCH_WARN="⚠️ This commits DIRECTLY to the default branch. Prefer a feature branch unless you meant to.
|
|
3240
3376
|
|
|
3241
3377
|
" ;;
|
|
3242
3378
|
esac
|
|
3379
|
+
_commit_agent_note
|
|
3380
|
+
# What it takes or sends: the staged change for a commit, the commits for a push (read only).
|
|
3381
|
+
_PVA=""; case "$CMD" in *push*) _PVA="$(_pv_line push-ask "$CMD")" ;; *) _PVA="$(_pv_line commit "$CMD")" ;; esac
|
|
3243
3382
|
ask_user "§4.4 commit/push approval gate. Claude wants to run:
|
|
3244
3383
|
|
|
3245
3384
|
$SHORT
|
|
3246
3385
|
|
|
3247
|
-
${
|
|
3386
|
+
${_PVA:+$_PVA
|
|
3387
|
+
|
|
3388
|
+
}${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
3389
|
;;
|
|
3249
3390
|
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 ;;
|
|
3391
|
+
_commit_agent_note; [ -n "$_CAN" ] && printf '%s' "$_CAN" >&2
|
|
3392
|
+
_gate_written ;;
|
|
3302
3393
|
*)
|
|
3303
3394
|
# bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
|
|
3304
3395
|
# human, so we fail closed rather than let the gate silently evaporate.
|