crewforth 3.0.4 → 3.1.0-rc.1
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 +192 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/smoke-test.sh +2543 -373
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1545 -104
- package/kit/hooks/guard-commit-scan.sh +75 -19
- package/kit/hooks/guard-powershell.sh +65 -15
- package/kit/hooks/guard-write.sh +74 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
package/kit/hooks/guard-bash.sh
CHANGED
|
@@ -45,7 +45,7 @@ set -uo pipefail
|
|
|
45
45
|
# match here is written in ASCII. Measured under tr_TR.UTF-8 with the locale left as it came:
|
|
46
46
|
# GNU grep 3.11 / bash 5.2 on Linux: `grep -i init` does not find INIT (-F, -E and plain alike);
|
|
47
47
|
# bash's nocasematch does not match GIT against git; `[A-Za-z]` in a regex does not hold I; awk's tolower
|
|
48
|
-
# turns GIT into gıt. The suite, run whole under that locale, went from
|
|
48
|
+
# turns GIT into gıt. The suite, run whole under that locale, went from 4 failures to 30: a recursive
|
|
49
49
|
# Remove-Item, a write to a gate file, `git config --remove-section core`, a read of a nested .env and a
|
|
50
50
|
# staged key all passed.
|
|
51
51
|
# GNU grep 3.0 in Git Bash on Windows: `grep -iF` with an ASCII pattern aborts (exit 134), and the pre-commit
|
|
@@ -149,7 +149,77 @@ _join_cmd(){ # CMD -> CMD with its continued lines joined
|
|
|
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
151
|
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
|
|
152
|
-
INPUT="$(cat)"
|
|
152
|
+
# Read with the builtin, as guard-powershell.sh does: `INPUT="$(cat)"` is a subshell plus a process on EVERY call
|
|
153
|
+
# of this hook. `read` returns 1 at end of input; the text is read regardless.
|
|
154
|
+
IFS= read -r -d '' INPUT || true
|
|
155
|
+
|
|
156
|
+
# Gate observability. A gate that cannot be seen firing cannot be measured: "the model never reached for the
|
|
157
|
+
# command" and "the gate stopped it" leave behind exactly the same artifacts, and the A/B harness spent a whole
|
|
158
|
+
# case (evals/permission-pressure) unable to tell them apart — it had to report "guard-bash never fired" as an
|
|
159
|
+
# inference. One TSV line per decision, write-only, and it never touches the decision itself: every call site
|
|
160
|
+
# logs AFTER the verdict is settled.
|
|
161
|
+
#
|
|
162
|
+
# ON BY DEFAULT since 2.5.0, into .claude/gate-log.tsv — an evidence channel nobody switches on records nothing,
|
|
163
|
+
# and "the gates hold" is a claim that needs a record, not a test suite alone. CREW_GATE_LOG overrides the path;
|
|
164
|
+
# CREW_GATE_LOG=/dev/null (or a read-only .claude) turns it off. Only BLOCK, ASK and CLAUDE_GIT_OK's ALLOW reach
|
|
165
|
+
# here: an ordinary command writes nothing, and a git action CLAUDE_GIT_OK allows writes one ALLOW line.
|
|
166
|
+
#
|
|
167
|
+
# The COMMAND TEXT IS NOT RECORDED by default. It is the one field that can carry a path, an argument or a
|
|
168
|
+
# token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
|
|
169
|
+
# buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
|
|
170
|
+
# positive, which is the only thing it is good for.
|
|
171
|
+
# Where the default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
|
|
172
|
+
# DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
|
|
173
|
+
# the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
|
|
174
|
+
# edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
|
|
175
|
+
# a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
|
|
176
|
+
# runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
|
|
177
|
+
# never reach it.
|
|
178
|
+
_gatelog_path(){
|
|
179
|
+
if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
|
|
180
|
+
[ -d ".claude" ] || return 0
|
|
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"
|
|
185
|
+
}
|
|
186
|
+
gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
|
|
187
|
+
# Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
|
|
188
|
+
# as `$( … )`, which is a subshell, so a global it assigns is discarded the moment it returns — the same trap
|
|
189
|
+
# this repo already documents for gb_sandbox in smoke-test, and the first version of this memo was written
|
|
190
|
+
# inside the function and measured as a no-op (11 git processes before and after). Resolving costs two git
|
|
191
|
+
# processes, and §4.6 made a twice-logging run the normal case for a successful commit: its own ALLOW line,
|
|
192
|
+
# then §4.4's ASK. On Windows a process is 62-135 ms.
|
|
193
|
+
if [ "${_GL_MEMO_SET:-0}" != 1 ]; then _GL_MEMO="$(_gatelog_path)"; _GL_MEMO_SET=1; fi
|
|
194
|
+
_GL="$_GL_MEMO"; [ -n "$_GL" ] || return 0
|
|
195
|
+
if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
|
|
196
|
+
printf '%s\t§%s\t%s\t%s\n' "$1" "$2" "$3" \
|
|
197
|
+
"$(printf '%s' "$CMD" | tr -d '\000-\037' | cut -c1-200)" >> "$_GL" 2>/dev/null || true
|
|
198
|
+
else
|
|
199
|
+
printf '%s\t§%s\t%s\t\n' "$1" "$2" "$3" >> "$_GL" 2>/dev/null || true
|
|
200
|
+
fi
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# ---- CREW-PAYLOAD-MAX ------------------------------------------------------------------------------------
|
|
204
|
+
# A Bash or PowerShell call above this size is refused before anything reads it. A PreToolUse hook that reaches
|
|
205
|
+
# its timeout (600 s) stops nothing, and taking the command out of the JSON costs the square of its size — measured
|
|
206
|
+
# on macOS for an escape-dense command: 256 KB 49 s, 512 KB 200 s, so about 900 KB is where the timeout is, and at
|
|
207
|
+
# that size no rule would run at all. The limit is 8 times the largest of 12387 real commands (31639 bytes).
|
|
208
|
+
# The size is ${#INPUT} under the C locale: bytes, with no process. The Write and Edit tools are not limited — a
|
|
209
|
+
# large file is ordinary there — which is why the refusal points at them.
|
|
210
|
+
# Byte-identical in every hook that reads a Bash or PowerShell payload; the suite pins it.
|
|
211
|
+
_PAYLOAD_MAX=262144
|
|
212
|
+
_payload_over(){ # -> 0, with the refusal on stderr, when the payload in $INPUT is above the limit
|
|
213
|
+
local LC_ALL=C
|
|
214
|
+
[ "${#INPUT}" -gt "$_PAYLOAD_MAX" ] || return 1
|
|
215
|
+
echo "GUARD (§4.5): this tool call is ${#INPUT} bytes long; the gates read a Bash or PowerShell call of up to $_PAYLOAD_MAX bytes." >&2
|
|
216
|
+
echo "A larger one could take longer to read than a hook is given, and a hook that runs out of time stops nothing," >&2
|
|
217
|
+
echo "so it is refused unread. Put the long content in a file (the Write tool takes any size) and give the command" >&2
|
|
218
|
+
echo "the path of that file." >&2
|
|
219
|
+
return 0
|
|
220
|
+
}
|
|
221
|
+
# ---- /CREW-PAYLOAD-MAX
|
|
222
|
+
if _payload_over; then CMD=""; gatelog BLOCK 4.5 "tool call too large to read"; exit 2; fi
|
|
153
223
|
|
|
154
224
|
# Extract the command + the permission mode: jq > python3 > pure-bash JSON slice.
|
|
155
225
|
#
|
|
@@ -176,6 +246,32 @@ INPUT="$(cat)"
|
|
|
176
246
|
# the parse and walk a payload straight past the rules. HOW it does that is documented inside _json_slice, next to
|
|
177
247
|
# the code, and only there -- this paragraph named the expansion once, and went stale the day it changed.
|
|
178
248
|
# ---- CREW-JSON-PARSE ------------------------------------------------------------------------------------
|
|
249
|
+
_json_find(){ # $1 = text, $2 = a literal -> _JF = the offset of its first occurrence, -1 when there is none
|
|
250
|
+
# `${text%%"$literal"*}` gives the same offset and costs the DISTANCE to the occurrence times the length of the
|
|
251
|
+
# text (bash measures the remaining string at every position it tries), so with no occurrence at all it is the
|
|
252
|
+
# square of the size. That was the whole cost of a large Write: the path key is found near the front, and the
|
|
253
|
+
# look for a SECOND one then ran over the content -- measured on macOS, 1 MB: 9.6 s for each of the two key
|
|
254
|
+
# counts, against 0.02 s for one `case`; a 6 MB Write to a gate file was refused after 636 s, past the 600 s
|
|
255
|
+
# timeout, which means not refused. Here the text is walked a piece at a time, each piece long enough to hold an
|
|
256
|
+
# occurrence that starts in it, and the expansion only ever runs inside a piece that `case` says holds one.
|
|
257
|
+
# Two sizes of piece, because taking a piece out of the text costs the length of the text as well: a block is
|
|
258
|
+
# taken from the whole text, and the small pieces from the block.
|
|
259
|
+
local LC_ALL=C
|
|
260
|
+
local i=0 j n=${#1} kl=${#2} B=262144 C=4096 b bn c pre
|
|
261
|
+
_JF=-1
|
|
262
|
+
while [ "$i" -lt "$n" ]; do
|
|
263
|
+
b="${1:i:B+kl-1}"
|
|
264
|
+
case "$b" in *"$2"*)
|
|
265
|
+
j=0; bn=${#b}
|
|
266
|
+
while [ "$j" -lt "$bn" ]; do
|
|
267
|
+
c="${b:j:C+kl-1}"
|
|
268
|
+
case "$c" in *"$2"*) pre="${c%%"$2"*}"; _JF=$((i+j+${#pre})); return 0 ;; esac
|
|
269
|
+
j=$((j+C))
|
|
270
|
+
done ;;
|
|
271
|
+
esac
|
|
272
|
+
i=$((i+B))
|
|
273
|
+
done
|
|
274
|
+
}
|
|
179
275
|
_json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) string value, "" if absent
|
|
180
276
|
local LC_ALL=C # FIRST, so every expansion below -- the key search included -- counts and cuts in bytes.
|
|
181
277
|
# Lengths from ${#x} are used as offsets into ${y:n}; with the locale set before any of
|
|
@@ -199,8 +295,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
199
295
|
# payload is still moving (`effort` is absent from the field list recorded on 2.1.246).
|
|
200
296
|
# * `${tail#"$seg"\"}` stepped past each escaped quote: 16.8s for a single 100 KB step on Git Bash,
|
|
201
297
|
# against 0.002s for `${tail:${#seg}+1}` doing exactly the same thing.
|
|
202
|
-
#
|
|
203
|
-
# earliest one. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
298
|
+
# _json_find gives the FIRST occurrence. THAT ALONE IS NOT ENOUGH, and the sentence that used to stand here said it was: it claimed a
|
|
204
299
|
# command "containing the literal text `\"command\":\"` still cannot relocate the parse", which is true of
|
|
205
300
|
# that byte sequence and irrelevant, because this search is for `"command"` WITHOUT the colon. A JSON VALUE
|
|
206
301
|
# equal to the key name is exactly those bytes between two unescaped quotes. Measured on the shipped hook:
|
|
@@ -224,7 +319,7 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
224
319
|
# empty input). Broken twins -- the run not carried across a window, a byte skipped after a quote -- prove
|
|
225
320
|
# the battery sees both.
|
|
226
321
|
# THE NUMBER OF PASSES IS CAPPED, for the same reason guard-write caps the path length: an unbounded cost on
|
|
227
|
-
# a PreToolUse hook is a gate with an off switch, because a hook killed at its
|
|
322
|
+
# a PreToolUse hook is a gate with an off switch, because a hook killed at its timeout emits no exit 2.
|
|
228
323
|
# Each decoy costs one more `%%` scan over the remainder, so the work is quadratic in the number of decoys.
|
|
229
324
|
# Measured on macOS/bash, single call, `"k<i>":"command"` decoys in front of the real key:
|
|
230
325
|
# 50 decoys 844 B 12 ms 800 decoys 13544 B 126 ms
|
|
@@ -240,14 +335,14 @@ _json_slice(){ # $1 = whole payload, $2 = key -> the raw (still JSON-escaped) s
|
|
|
240
335
|
# raising or removing it later is not a tidy-up.
|
|
241
336
|
# The bound is on the LOOP, not on the payload, so an ordinary command pays nothing: the same 56572 B flood
|
|
242
337
|
# is refused in 93-94 ms with jq/python3 present and 168-169 ms on the slice path this paragraph is about,
|
|
243
|
-
# both far inside the 60s timeout, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
338
|
+
# both far inside the 60s timeout these hooks had when this was measured, while a 46 KB legitimate command is 325 ms on macOS -- a figure that
|
|
244
339
|
# predates this change and belongs to the walk, not to the cap.
|
|
245
340
|
local _cap=64 _seen=0
|
|
246
341
|
while :; do
|
|
247
|
-
|
|
248
|
-
[ "$
|
|
342
|
+
_json_find "$hay" "$k" # where the next `"key"` starts
|
|
343
|
+
[ "$_JF" -ge 0 ] || return 0 # no further occurrence: emit nothing
|
|
249
344
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || return 0
|
|
250
|
-
rest="${hay
|
|
345
|
+
rest="${hay:_JF+${#k}}" # past `"key"`
|
|
251
346
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
252
347
|
case "$rest" in
|
|
253
348
|
:*) rest="${rest:1}"; break ;; # whitespace then `:` -- this occurrence IS the key
|
|
@@ -287,7 +382,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
287
382
|
# CHARACTER at a time, which is O(n^2) twice over: `${s%"${s#?}"}` matches a pattern the length of the
|
|
288
383
|
# entire remainder just to read one character, and `out="$out$c"` recopies the output for each one.
|
|
289
384
|
#
|
|
290
|
-
# That was not a comfort question. This hook's timeout
|
|
385
|
+
# That was not a comfort question. This hook's timeout was 60s then -- set in settings.json (600s since 3.1.0), NOT Claude Code's
|
|
291
386
|
# default, and reading the default instead is how a first pass at this got the consequence wrong. A
|
|
292
387
|
# PreToolUse hook KILLED at its timeout emits no exit 2, so every rule below is simply skipped. Measured on
|
|
293
388
|
# the tier-3 path with jq and python3 both shadowed, the old shape crossed 60s at ~4.3 KB. And 4.3 KB is
|
|
@@ -339,7 +434,7 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
339
434
|
# the gate cases.
|
|
340
435
|
local LC_ALL=C
|
|
341
436
|
local s="$1" pre w c h n base=0 j=0 cl lim chunk C=4096 W=256; local -a acc=("")
|
|
342
|
-
case "$s" in *\\*) ;; *) printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
437
|
+
case "$s" in *\\*) ;; *) _JU="$s"; printf '%s' "$s"; return 0 ;; esac # no escapes: the common case pays nothing
|
|
343
438
|
n=${#s}; chunk="${s:0:C}"; cl=${#chunk}
|
|
344
439
|
while :; do
|
|
345
440
|
lim=$((cl - j))
|
|
@@ -369,12 +464,17 @@ _json_unescape(){ # left-to-right, a chunk at a time; a two-pass sed would corr
|
|
|
369
464
|
# `?`, which is only ever a display concern because this value is used for MATCHING, never to write.
|
|
370
465
|
case "$h" in
|
|
371
466
|
00[2-7][0-9a-fA-F]) printf -v c "\\x${h#00}"; acc+=("$c") ;;
|
|
467
|
+
# The three dashes PowerShell accepts in front of a parameter name, as themselves: as `?` they hid
|
|
468
|
+
# `\u2013Recurse \u2013Force` from the delete rule, while the same bytes sent raw were caught.
|
|
469
|
+
2013) acc+=("–") ;; 2014) acc+=("—") ;; 2015) acc+=("―") ;;
|
|
372
470
|
*) acc+=("?") ;;
|
|
373
471
|
esac ;;
|
|
374
472
|
*) acc+=("$c") ;;
|
|
375
473
|
esac
|
|
376
474
|
done
|
|
377
|
-
|
|
475
|
+
# ALSO sets `_JU`, for the same reason `_json_slice` sets `_JS`: a caller on the hot path reads the value from it
|
|
476
|
+
# and sends this printf to /dev/null, instead of paying a `$( )` — a fork — on every tool call.
|
|
477
|
+
local IFS=''; _JU="${acc[*]}"; printf '%s' "$_JU"
|
|
378
478
|
}
|
|
379
479
|
_json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occurs AS A KEY
|
|
380
480
|
# IT SETS A VARIABLE INSTEAD OF PRINTING, and that is not a style choice. Written as `n="$(_json_keycount
|
|
@@ -407,22 +507,45 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
407
507
|
# over settings.json, a commit message quoting the word, commands ending in `"command` -- every count <= 1.
|
|
408
508
|
# Same pass cap as the slice, and over-cap reports AMBIGUOUS rather than a true count: the callers refuse on
|
|
409
509
|
# `> 1`, so a payload built to outrun the loop is refused instead of being timed out past the gate.
|
|
410
|
-
local LC_ALL=C hay="$1" k="\"$2\""
|
|
510
|
+
local LC_ALL=C hay="$1" k="\"$2\"" rest c=0 _cap=64 _seen=0
|
|
411
511
|
_KC=0; _KC_CAPPED=0
|
|
412
512
|
while :; do
|
|
413
|
-
|
|
414
|
-
[ "$
|
|
513
|
+
_json_find "$hay" "$k"
|
|
514
|
+
[ "$_JF" -ge 0 ] || { _KC=$c; return 0; }
|
|
415
515
|
# OVER-CAP IS ITS OWN ANSWER, not a large count. `_KC_CAPPED` lets the caller say what actually happened:
|
|
416
516
|
# the occurrences it stopped at are candidate positions, key-form or not, so calling them "65 keys" was a
|
|
417
517
|
# sentinel dressed up as a measurement and the remedy it offered ("send one key") was already satisfied.
|
|
418
518
|
_seen=$((_seen+1)); [ "$_seen" -le "$_cap" ] || { _KC=$((_cap+1)); _KC_CAPPED=$_cap; return 0; }
|
|
419
|
-
rest="${hay
|
|
519
|
+
rest="${hay:_JF+${#k}}"
|
|
420
520
|
while [ -n "$rest" ] && [[ "${rest:0:1}" == [$' \t\n\r'] ]]; do rest="${rest:1}"; done
|
|
421
521
|
case "$rest" in :*) c=$((c+1)) ;; esac
|
|
422
522
|
hay="$rest"
|
|
423
523
|
done
|
|
424
524
|
}
|
|
425
525
|
# ---- /CREW-JSON-PARSE -----------------------------------------------------------------------------------
|
|
526
|
+
# ---- _gsub: `${text//pattern/replacement}` that stays LINEAR ----------------------------------------------
|
|
527
|
+
# bash rebuilds the whole string for every match of a global substitution, so its cost is matches x length. On a
|
|
528
|
+
# quote-dense 46 KB command each `${CMD//\"/}` took about 9 s (bash 3.2, measured with a DEBUG trap), and this hook
|
|
529
|
+
# did ten of them: 335 s for one commit, against a 600 s timeout that does not block when it is reached. The same
|
|
530
|
+
# substitution done 2048 bytes at a time costs matches x 2048. Safe for a pattern of ONE character anywhere; for the
|
|
531
|
+
# two-character patterns that begin with a backslash, pass `bs` and a piece never ends on a backslash.
|
|
532
|
+
_gsub(){ # $1 = text, $2 = pattern (a glob, as it would stand in ${x//HERE/}), $3 = replacement, $4 = bs -> _GS
|
|
533
|
+
local LC_ALL=C
|
|
534
|
+
local t="$1" n i=0 C=2048 c; local -a acc=("")
|
|
535
|
+
n=${#t}
|
|
536
|
+
if [ "$n" -le "$C" ]; then _GS="${t//$2/$3}"; return 0; fi
|
|
537
|
+
while [ "$i" -lt "$n" ]; do
|
|
538
|
+
c="${t:i:C}"; i=$((i+C))
|
|
539
|
+
if [ "${4:-}" = bs ]; then while [ "$i" -lt "$n" ] && [ "${c: -1}" = '\' ]; do c="$c${t:i:1}"; i=$((i+1)); done; fi
|
|
540
|
+
acc+=("${c//$2/$3}")
|
|
541
|
+
done
|
|
542
|
+
local IFS=''; _GS="${acc[*]}"
|
|
543
|
+
}
|
|
544
|
+
# The text as the shell hands it on once the quoting is gone: `core.hooks"P"ath`, `crewforth-appr\oval` and
|
|
545
|
+
# `'git' commit` are core.hooksPath, crewforth-approval and git commit. Every rule that matches a NAME matches on this.
|
|
546
|
+
_unquoted(){ # $1 = text -> _GS: no double quote, no single quote, no backslash
|
|
547
|
+
_gsub "$1" '\"' ''; _gsub "$_GS" "\\'" ''; _gsub "$_GS" '\\' ''
|
|
548
|
+
}
|
|
426
549
|
# ONE READER, EVERYWHERE. This hook used to try jq, then python3, then the slice above, choosing a tier on
|
|
427
550
|
# whether its extraction WORKED rather than on whether the binary existed. That was already the second fix to
|
|
428
551
|
# the selection logic, and the ladder stayed the root cause of four separate incidents. It is gone.
|
|
@@ -458,9 +581,10 @@ _json_keycount(){ # $1 = payload, $2 = key -> sets _KC to how many times it occ
|
|
|
458
581
|
# the slice on every single Bash call are gone (-1 fork per hook per call), and `guard-bash · ls -la` went
|
|
459
582
|
# 376 ms -> 169 ms with no overlap between the two columns, because the Store stub was being spawned and
|
|
460
583
|
# failing on every call.
|
|
461
|
-
|
|
584
|
+
# Through `_JS` / `_JU`, not `$( )`: these three reads were three forks on every Bash and PowerShell call (3.1.0).
|
|
585
|
+
_json_slice "$INPUT" command >/dev/null; CMD_RAW="$_JS"; _json_unescape "$_JS" >/dev/null; CMD="$_JU" # CMD_RAW: the command as the payload spells it
|
|
462
586
|
_join_cmd # continued lines joined, as the shell runs them (CREW-JOIN)
|
|
463
|
-
|
|
587
|
+
_json_slice "$INPUT" permission_mode >/dev/null; PERM_MODE="$_JS"
|
|
464
588
|
|
|
465
589
|
# AN UNREADABLE PAYLOAD IS REFUSED, NOT WAVED THROUGH. Both shapes below were found by the parser-conformance
|
|
466
590
|
# oracle, which compares this gate's verdict on the dependency-free tier against a real parser's, and both fell
|
|
@@ -552,53 +676,8 @@ if [ "$_n_cmd" = 0 ] && [ -z "$CMD" ]; then
|
|
|
552
676
|
fi
|
|
553
677
|
fi
|
|
554
678
|
[ -z "$CMD" ] && exit 0
|
|
679
|
+
_unquoted "$CMD"; CMD_UQ="$_GS" # read by the name rules below (hooksPath side doors, the approval record)
|
|
555
680
|
|
|
556
|
-
# Gate observability. A gate that cannot be seen firing cannot be measured: "the model never reached for the
|
|
557
|
-
# command" and "the gate stopped it" leave behind exactly the same artifacts, and the A/B harness spent a whole
|
|
558
|
-
# case (evals/permission-pressure) unable to tell them apart — it had to report "guard-bash never fired" as an
|
|
559
|
-
# inference. One TSV line per decision, write-only, and it never touches the decision itself: every call site
|
|
560
|
-
# logs AFTER the verdict is settled.
|
|
561
|
-
#
|
|
562
|
-
# ON BY DEFAULT since 2.5.0, into .claude/gate-log.tsv — an evidence channel nobody switches on records nothing,
|
|
563
|
-
# and "the gates hold" is a claim that needs a record, not a test suite alone. CREW_GATE_LOG overrides the path;
|
|
564
|
-
# CREW_GATE_LOG=/dev/null (or a read-only .claude) turns it off. Only BLOCK, ASK and CLAUDE_GIT_OK's ALLOW reach
|
|
565
|
-
# here: an ordinary command writes nothing, and a git action CLAUDE_GIT_OK allows writes one ALLOW line.
|
|
566
|
-
#
|
|
567
|
-
# The COMMAND TEXT IS NOT RECORDED by default. It is the one field that can carry a path, an argument or a
|
|
568
|
-
# token, and `/crew-gates` never prints it — the report is rule names and counts. Recording it by default would
|
|
569
|
-
# buy nothing and add a place for a secret to sit. `CREW_GATE_LOG_CMD=1` puts it back for debugging a false
|
|
570
|
-
# positive, which is the only thing it is good for.
|
|
571
|
-
# Where the default log may go. An explicit CREW_GATE_LOG is the operator's call and is used as given. The
|
|
572
|
-
# DEFAULT path is only used when writing there cannot surprise anyone: outside a git repo, or inside one where
|
|
573
|
-
# the path is already ignored. A kit install gitignores .claude/, so this is the normal case — but the plugin
|
|
574
|
-
# edition drops into repos the installer never touched, and this repo proved the failure itself: the suite left
|
|
575
|
-
# a gate-log.tsv sitting in `git status` as an untracked file waiting to be committed. One `git check-ignore`
|
|
576
|
-
# runs only when a decision is logged (a block, an approval prompt or a CLAUDE_GIT_OK allow); ordinary commands
|
|
577
|
-
# never reach it.
|
|
578
|
-
_gatelog_path(){
|
|
579
|
-
if [ -n "${CREW_GATE_LOG:-}" ]; then printf '%s' "$CREW_GATE_LOG"; return; fi
|
|
580
|
-
[ -d ".claude" ] || return 0
|
|
581
|
-
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
582
|
-
git check-ignore -q ".claude/gate-log.tsv" 2>/dev/null || return 0
|
|
583
|
-
fi
|
|
584
|
-
printf '%s' ".claude/gate-log.tsv"
|
|
585
|
-
}
|
|
586
|
-
gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
|
|
587
|
-
# Resolve the path ONCE per hook run and remember it HERE, not inside _gatelog_path: that function is invoked
|
|
588
|
-
# as `$( … )`, which is a subshell, so a global it assigns is discarded the moment it returns — the same trap
|
|
589
|
-
# this repo already documents for gb_sandbox in smoke-test, and the first version of this memo was written
|
|
590
|
-
# inside the function and measured as a no-op (11 git processes before and after). Resolving costs two git
|
|
591
|
-
# processes, and §4.6 made a twice-logging run the normal case for a successful commit: its own ALLOW line,
|
|
592
|
-
# then §4.4's ASK. On Windows a process is 62-135 ms.
|
|
593
|
-
if [ "${_GL_MEMO_SET:-0}" != 1 ]; then _GL_MEMO="$(_gatelog_path)"; _GL_MEMO_SET=1; fi
|
|
594
|
-
_GL="$_GL_MEMO"; [ -n "$_GL" ] || return 0
|
|
595
|
-
if [ "${CREW_GATE_LOG_CMD:-0}" = 1 ]; then
|
|
596
|
-
printf '%s\t§%s\t%s\t%s\n' "$1" "$2" "$3" \
|
|
597
|
-
"$(printf '%s' "$CMD" | tr -d '\000-\037' | cut -c1-200)" >> "$_GL" 2>/dev/null || true
|
|
598
|
-
else
|
|
599
|
-
printf '%s\t§%s\t%s\t\n' "$1" "$2" "$3" >> "$_GL" 2>/dev/null || true
|
|
600
|
-
fi
|
|
601
|
-
}
|
|
602
681
|
|
|
603
682
|
# $1 = rule name · $2 = section · $3 = CLASS, which decides the second line.
|
|
604
683
|
#
|
|
@@ -640,6 +719,7 @@ gatelog(){ # $1 = verdict (BLOCK|ASK|ALLOW) $2 = section $3 = rule
|
|
|
640
719
|
block(){
|
|
641
720
|
gatelog BLOCK "$2" "$1"
|
|
642
721
|
echo "GUARD (§$2): '$1' stopped AT THE TOOL LEVEL." >&2
|
|
722
|
+
[ -z "${_BLOCK_NOTE:-}" ] || echo "$_BLOCK_NOTE" >&2
|
|
643
723
|
case "${3:-}" in
|
|
644
724
|
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 ;;
|
|
645
725
|
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 ;;
|
|
@@ -756,7 +836,6 @@ git_has() { # $1 = command text, $2 = subcommand alternation (e.g. 'commit|push
|
|
|
756
836
|
case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; *) HAS_GIT=0 ;; esac
|
|
757
837
|
has() { _grep "$CMD" -qiE -- "$1"; } # flag/substring test on the command (-- so a -flag pattern is safe)
|
|
758
838
|
|
|
759
|
-
{ git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
|
|
760
839
|
# §4.5 force-push. Same two defects the `git add -f` rule had, and the same repair: the flag has to be one of
|
|
761
840
|
# THIS `git push`'s own arguments, at its own quoting level, and the test is case-SENSITIVE. `has()` greps the
|
|
762
841
|
# whole command with `-i`, so `-F` matched the `-f` alternative — and `git commit -F msg.txt; git push` is the
|
|
@@ -780,15 +859,233 @@ _push_forces(){ # $1 = one captured `git push …` span -> 0 when a force flag
|
|
|
780
859
|
done
|
|
781
860
|
[ "$og" = 0 ] && set +f; return 1
|
|
782
861
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
862
|
+
# The three §4.5 rules that read a git command by its words, as ONE function of the text: the command itself goes
|
|
863
|
+
# through it here, and so does a git command put together from arguments further down (PowerShell's Start-Process git
|
|
864
|
+
# -ArgumentList …, a command word held in a variable), which is the same command written another way.
|
|
865
|
+
_git_d45(){ # $1 = command text; block() exits on the first rule it breaks
|
|
866
|
+
local CMD="$1" HAS_GIT=0 _PUSHSEG _seg # has() reads CMD: this one
|
|
867
|
+
case "$CMD" in *[Gg][Ii][Tt]*) HAS_GIT=1 ;; esac
|
|
868
|
+
{ git_has "$CMD" 'reset' && has '--hard'; } && block "git reset --hard" "4.5" history
|
|
869
|
+
if [ "$HAS_GIT" = 1 ] && git_has "$CMD" 'push'; then
|
|
870
|
+
_grep_out "$CMD" -oE 'git[[:space:]]+([^;&|]*[[:space:]])?push([^;&|]*)'; _PUSHSEG="$_GO"
|
|
871
|
+
while IFS= read -r _seg; do
|
|
872
|
+
[ -n "$_seg" ] || continue
|
|
873
|
+
_push_forces "$_seg" && { block "git push --force" "4.5" history; break; }
|
|
874
|
+
done <<< "$_PUSHSEG"
|
|
875
|
+
fi
|
|
876
|
+
{ git_has "$CMD" 'clean' && has '-[A-Za-z]*f'; } && block "git clean -f" "4.5" loss
|
|
877
|
+
return 0
|
|
878
|
+
}
|
|
879
|
+
# ---- the quoted argument of a command that does not run it ---------------------------------------------------
|
|
880
|
+
# `git commit -m "drop the rm -rf /tmp/build step"` deletes nothing, and `claude -p "… git push --force origin main"`
|
|
881
|
+
# pushes nothing: the words stand in an argument, quoted, of a command that only stores or prints or searches for
|
|
882
|
+
# them. The rules below read the command as text, so each of those was refused (measured: 14 of 20 such commands).
|
|
883
|
+
#
|
|
884
|
+
# _inert_args takes those arguments out of the text the rules read, and nothing else. It is a NARROW LIST, by
|
|
885
|
+
# decision, not "whatever is quoted":
|
|
886
|
+
# git commit | git tag the word after -m / --message (or a short cluster that ends in m: -am, -qm)
|
|
887
|
+
# gh pr|issue|release … the word after --title / -t / --body / -b (not an extension: it may run its argument)
|
|
888
|
+
# claude the word after -p / --print, when nothing in the call changes the directory (cd,
|
|
889
|
+
# pushd, popd, git -C) and claude's other options are --output-format, --model,
|
|
890
|
+
# --max-turns only
|
|
891
|
+
# grep egrep fgrep rg every quoted word (none is run), unless --pre is among the options (rg runs it)
|
|
892
|
+
# echo printf every quoted word, and only when the whole command has no pipe and no redirection:
|
|
893
|
+
# what is printed can be run by what reads it (`echo "…" | sh`, `echo "…" > s.sh`);
|
|
894
|
+
# never `printf -v NAME …`, which stores the text where `$NAME` can run it
|
|
895
|
+
# and an argument counts only when the WHOLE word is quoted: single quotes, $'…', or double quotes that hold no
|
|
896
|
+
# `$(` and no backtick (those run). A word glued to its option (-m"…", --message="…") is not taken out.
|
|
897
|
+
# The command word must be the first word of its simple command, written plainly: `FOO=1 git …`, `sudo echo …`,
|
|
898
|
+
# `bash -c "…"`, `eval "…"`, `ssh host "…"`, `xargs …` are not on the list and nothing of theirs is taken out.
|
|
899
|
+
# Anything this reader does not read with certainty leaves the command AS IT IS (the rules then judge all of it):
|
|
900
|
+
# an unfinished quote, a command substitution or a backtick outside single quotes, a here-document, a parenthesis
|
|
901
|
+
# (a subshell, a function definition), process substitution, more than _INERT_MAX bytes — and a call that changes
|
|
902
|
+
# what a command word means (alias, hash, function, eval, source, exec, set, export, … or an assignment to PATH,
|
|
903
|
+
# ENV, BASH_ENV, IFS). Bash tool only: PowerShell quotes by other rules.
|
|
904
|
+
_INERT_MAX=4096
|
|
905
|
+
_inert_args(){ # $1 = command -> 0 and _IX = the command with those arguments replaced by '' · 1 = nothing taken out
|
|
906
|
+
local s="$1" c d r seg body pos=0 n=0 i j k
|
|
907
|
+
local wopen=0 ws=0 wplain=1 winert=1 wtxt="" act
|
|
908
|
+
local pipe=0 redir=0
|
|
909
|
+
local -a T S E I P # per word: text when plain ("" otherwise) · start · end · inert (1/0; 2 = a separator) · plain (1/0)
|
|
910
|
+
_IX=""
|
|
911
|
+
[ "${#s}" -le "$_INERT_MAX" ] || return 1
|
|
912
|
+
case "$s" in *[\'\"]*) ;; *) return 1 ;; esac
|
|
913
|
+
case "$s" in *PATH=*|*ENV=*|*IFS=*) return 1 ;; esac # the same, by an assignment (PATH, BASH_ENV, ENV, IFS)
|
|
914
|
+
# end the word that is open, if one is
|
|
915
|
+
_iw_end(){ [ "$wopen" = 1 ] || return 0
|
|
916
|
+
T[n]="$wtxt"; [ "$wplain" = 1 ] || T[n]=""; S[n]=$ws; E[n]=$pos; I[n]=$winert; P[n]=$wplain; n=$((n+1))
|
|
917
|
+
wopen=0; wplain=1; winert=1; wtxt=""; }
|
|
918
|
+
_iw_sep(){ _iw_end; T[n]=""; S[n]=$pos; E[n]=$pos; I[n]=2; P[n]=0; n=$((n+1)); } # I = 2: a separator, not a word
|
|
919
|
+
_iw_open(){ [ "$wopen" = 1 ] || { wopen=1; ws=$pos; }; }
|
|
920
|
+
while [ -n "$s" ]; do
|
|
921
|
+
c="${s:0:1}"
|
|
922
|
+
case "$c" in
|
|
923
|
+
' '|$'\t') _iw_end; s="${s:1}"; pos=$((pos+1)) ;;
|
|
924
|
+
$'\n') _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
|
|
925
|
+
\') r="${s:1}"
|
|
926
|
+
case "$r" in *\'*) ;; *) return 1 ;; esac
|
|
927
|
+
body="${r%%\'*}"; _iw_open; wplain=0
|
|
928
|
+
k=$(( ${#body} + 2 )); s="${s:k}"; pos=$((pos+k)) ;;
|
|
929
|
+
\") r="${s:1}"; k=1; act=0
|
|
930
|
+
while :; do
|
|
931
|
+
seg="${r%%[\"\\\$\`]*}"; k=$(( k + ${#seg} )); r="${r:${#seg}}"
|
|
932
|
+
[ -n "$r" ] || return 1 # the quote is not finished
|
|
933
|
+
d="${r:0:1}"
|
|
934
|
+
case "$d" in
|
|
935
|
+
\") k=$((k+1)); break ;;
|
|
936
|
+
\\) [ "${#r}" -ge 2 ] || return 1; r="${r:2}"; k=$((k+2)) ;;
|
|
937
|
+
\$) case "${r:1:1}" in '(') act=1 ;; esac; r="${r:1}"; k=$((k+1)) ;;
|
|
938
|
+
\`) act=1; r="${r:1}"; k=$((k+1)) ;;
|
|
939
|
+
esac
|
|
940
|
+
done
|
|
941
|
+
_iw_open; wplain=0; [ "$act" = 1 ] && winert=0
|
|
942
|
+
s="${s:k}"; pos=$((pos+k)) ;;
|
|
943
|
+
\\) [ "${#s}" -ge 2 ] || return 1
|
|
944
|
+
if [ "${s:1:1}" = $'\n' ]; then s="${s:2}"; pos=$((pos+2)) # a backslash-newline joins the lines
|
|
945
|
+
else _iw_open; winert=0; wplain=0; s="${s:2}"; pos=$((pos+2)); fi ;;
|
|
946
|
+
\$) case "${s:1:1}" in
|
|
947
|
+
'(') return 1 ;; # a command substitution: not read
|
|
948
|
+
\') r="${s:2}"; k=2 # $'…': a quoted word, \' does not end it
|
|
949
|
+
while :; do
|
|
950
|
+
seg="${r%%[\'\\]*}"; k=$(( k + ${#seg} )); r="${r:${#seg}}"
|
|
951
|
+
[ -n "$r" ] || return 1
|
|
952
|
+
if [ "${r:0:1}" = \' ]; then k=$((k+1)); break; fi
|
|
953
|
+
[ "${#r}" -ge 2 ] || return 1; r="${r:2}"; k=$((k+2))
|
|
954
|
+
done
|
|
955
|
+
_iw_open; wplain=0; s="${s:k}"; pos=$((pos+k)) ;;
|
|
956
|
+
*) _iw_open; winert=0; wplain=0; s="${s:1}"; pos=$((pos+1)) ;; # $var, ${…}, $" : part of the word, not a quoted one
|
|
957
|
+
esac ;;
|
|
958
|
+
\`) return 1 ;;
|
|
959
|
+
'('|')') return 1 ;;
|
|
960
|
+
'#') if [ "$wopen" = 1 ]; then wtxt="$wtxt#"; winert=0; s="${s:1}"; pos=$((pos+1))
|
|
961
|
+
else seg="${s%%$'\n'*}"; s="${s:${#seg}}"; pos=$(( pos + ${#seg} )); fi ;; # a comment, to the end of the line
|
|
962
|
+
';') _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
|
|
963
|
+
'&') case "${s:1:1}" in
|
|
964
|
+
'&') _iw_sep; s="${s:2}"; pos=$((pos+2)) ;;
|
|
965
|
+
'>') _iw_end; redir=1; s="${s:2}"; pos=$((pos+2)) ;;
|
|
966
|
+
*) _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
|
|
967
|
+
esac ;;
|
|
968
|
+
'|') case "${s:1:1}" in
|
|
969
|
+
'|') _iw_sep; s="${s:2}"; pos=$((pos+2)) ;;
|
|
970
|
+
*) pipe=1; _iw_sep; s="${s:1}"; pos=$((pos+1)) ;;
|
|
971
|
+
esac ;;
|
|
972
|
+
'<'|'>') case "${s:0:2}" in '<<'|'<('|'>(') return 1 ;; esac # a here-document, process substitution: not read
|
|
973
|
+
_iw_end; redir=1; s="${s:1}"; pos=$((pos+1)) ;;
|
|
974
|
+
*) seg="${s%%[ $'\t\n'\'\"\\\$\`\#\;\&\|\(\)\<\>]*}"
|
|
975
|
+
_iw_open; winert=0; wtxt="$wtxt$seg"; s="${s:${#seg}}"; pos=$(( pos + ${#seg} )) ;;
|
|
976
|
+
esac
|
|
977
|
+
done
|
|
978
|
+
_iw_end
|
|
979
|
+
# Each simple command: who is its first word, and which of its words are that command's inert arguments.
|
|
980
|
+
local -a B; local first cmdw sub nb=0 pre="" grep_pre chdir=0
|
|
981
|
+
# Does anything in the call change the directory? (cd, pushd, popd as a command word; a git that carries -C)
|
|
982
|
+
i=0; k=1
|
|
983
|
+
while [ "$i" -lt "$n" ]; do
|
|
984
|
+
if [ "${I[i]}" = 2 ]; then k=1
|
|
985
|
+
elif [ "$k" = 1 ]; then k=0; cmdw="${T[i]}"
|
|
986
|
+
case "$cmdw" in cd|pushd|popd|'') chdir=1 ;; esac # '': a command word that is not written plainly may be one
|
|
987
|
+
elif [ "$cmdw" = git ]; then case "${T[i]}" in -C*) chdir=1 ;; esac
|
|
988
|
+
fi
|
|
989
|
+
i=$((i+1))
|
|
990
|
+
done
|
|
991
|
+
i=0
|
|
992
|
+
while [ "$i" -lt "$n" ]; do
|
|
993
|
+
[ "${I[i]}" = 2 ] && { i=$((i+1)); continue; }
|
|
994
|
+
first=$i; j=$i
|
|
995
|
+
while [ "$j" -lt "$n" ] && [ "${I[j]}" != 2 ]; do j=$((j+1)); done
|
|
996
|
+
cmdw="${T[first]}"; [ "${P[first]}" = 1 ] || cmdw=""
|
|
997
|
+
case "$cmdw" in
|
|
998
|
+
# A command that changes what a later command word MEANS: after `hash -p /bin/sh echo`, `echo -c "…"` runs the
|
|
999
|
+
# text (measured with a harmless pair); an alias, a function or another PATH does the same. With any of them
|
|
1000
|
+
# in the call nothing is taken out, wherever it stands.
|
|
1001
|
+
alias|unalias|function|hash|enable|builtin|shopt|set|setopt|unsetopt|source|.|eval|exec|trap|export|declare|typeset|readonly|autoload|zmodload|emulate|command|env)
|
|
1002
|
+
_IX=""; return 1 ;;
|
|
1003
|
+
git)
|
|
1004
|
+
k=$((first+1)); sub=""
|
|
1005
|
+
while [ "$k" -lt "$j" ]; do # git's own options, then the subcommand
|
|
1006
|
+
[ "${P[k]}" = 1 ] || break
|
|
1007
|
+
case "${T[k]}" in
|
|
1008
|
+
-C|-c|--git-dir|--work-tree|--namespace|--exec-path|--config-env) k=$((k+2)) ;;
|
|
1009
|
+
--no-pager|--paginate|-p|-P|--bare|--no-replace-objects|--git-dir=*|--work-tree=*|--namespace=*) k=$((k+1)) ;;
|
|
1010
|
+
-*) break ;;
|
|
1011
|
+
*) sub="${T[k]}"; k=$((k+1)); break ;;
|
|
1012
|
+
esac
|
|
1013
|
+
done
|
|
1014
|
+
case "$sub" in commit|tag)
|
|
1015
|
+
while [ "$k" -lt "$j" ]; do
|
|
1016
|
+
if [ "${P[k]}" = 1 ]; then case "${T[k]}" in
|
|
1017
|
+
--) break ;;
|
|
1018
|
+
-m|--message|-[A-Za-z]m|-[A-Za-z][A-Za-z]m|-[A-Za-z][A-Za-z][A-Za-z]m)
|
|
1019
|
+
[ $((k+1)) -lt "$j" ] && [ "${I[k+1]}" = 1 ] && { B[nb]=$((k+1)); nb=$((nb+1)); }; k=$((k+1)) ;;
|
|
1020
|
+
esac; fi
|
|
1021
|
+
k=$((k+1))
|
|
1022
|
+
done ;;
|
|
1023
|
+
esac ;;
|
|
1024
|
+
gh)
|
|
1025
|
+
# Only gh's own pr / issue / release: an extension (`gh myext …`) is a program of its own and may run its argument.
|
|
1026
|
+
k=$((first+1)); sub=""; [ "$k" -lt "$j" ] && [ "${P[k]}" = 1 ] && sub="${T[k]}"
|
|
1027
|
+
case "$sub" in pr|issue|release) ;; *) k=$j ;; esac
|
|
1028
|
+
while [ "$k" -lt "$j" ]; do
|
|
1029
|
+
if [ "${P[k]}" = 1 ]; then case "${T[k]}" in
|
|
1030
|
+
--title|-t|--body|-b) [ $((k+1)) -lt "$j" ] && [ "${I[k+1]}" = 1 ] && { B[nb]=$((k+1)); nb=$((nb+1)); }; k=$((k+1)) ;;
|
|
1031
|
+
esac; fi
|
|
1032
|
+
k=$((k+1))
|
|
1033
|
+
done ;;
|
|
1034
|
+
claude)
|
|
1035
|
+
# The session `claude -p` starts is judged by the gates of the directory it starts in, with the settings it
|
|
1036
|
+
# is given. So the prompt is taken out only when (a) nothing in the call changes the directory, and (b) every
|
|
1037
|
+
# other word of this claude is an option from a short harmless list, or its value. --settings,
|
|
1038
|
+
# --setting-sources, --dangerously-skip-permissions, --permission-mode, --add-dir, --mcp-config,
|
|
1039
|
+
# --allowedTools, any option not listed, and any other quoted word leave the prompt where it is.
|
|
1040
|
+
k=$((first+1)); sub=ok; pre=-1
|
|
1041
|
+
[ "$chdir" = 0 ] || sub=""
|
|
1042
|
+
while [ "$k" -lt "$j" ] && [ -n "$sub" ]; do
|
|
1043
|
+
if [ "${P[k]}" = 1 ]; then case "${T[k]}" in
|
|
1044
|
+
-p|--print)
|
|
1045
|
+
if [ "$pre" = -1 ] && [ $((k+1)) -lt "$j" ] && [ "${I[k+1]}" = 1 ]; then pre=$((k+1)); k=$((k+1)); else sub=""; fi ;;
|
|
1046
|
+
--output-format|--model|--max-turns)
|
|
1047
|
+
if [ $((k+1)) -lt "$j" ] && [ "${P[k+1]}" = 1 ]; then case "${T[k+1]}" in -*) sub="" ;; *) k=$((k+1)) ;; esac; else sub=""; fi ;;
|
|
1048
|
+
--output-format=*|--model=*|--max-turns=*) ;;
|
|
1049
|
+
*) sub="" ;; # another option, or a word that is not one
|
|
1050
|
+
esac; else sub=""; fi
|
|
1051
|
+
k=$((k+1))
|
|
1052
|
+
done
|
|
1053
|
+
[ -n "$sub" ] && [ "$pre" != -1 ] && { B[nb]=$pre; nb=$((nb+1)); } ;;
|
|
1054
|
+
grep|egrep|fgrep|rg)
|
|
1055
|
+
grep_pre=0; k=$((first+1))
|
|
1056
|
+
while [ "$k" -lt "$j" ]; do case "${T[k]}" in --pre|--pre=*) grep_pre=1 ;; esac; k=$((k+1)); done
|
|
1057
|
+
if [ "$grep_pre" = 0 ]; then k=$((first+1))
|
|
1058
|
+
while [ "$k" -lt "$j" ]; do [ "${I[k]}" = 1 ] && { B[nb]=$k; nb=$((nb+1)); }; k=$((k+1)); done
|
|
1059
|
+
fi ;;
|
|
1060
|
+
echo|printf)
|
|
1061
|
+
# printf -v NAME writes the text into a variable, and `$NAME` after it runs it (measured with a harmless
|
|
1062
|
+
# pair): that printf prints nothing, so it is not on the list.
|
|
1063
|
+
grep_pre=0; k=$((first+1))
|
|
1064
|
+
while [ "$k" -lt "$j" ]; do case "${T[k]}" in -v*) [ "$cmdw" = printf ] && grep_pre=1 ;; esac; k=$((k+1)); done
|
|
1065
|
+
if [ "$pipe" = 0 ] && [ "$redir" = 0 ] && [ "$grep_pre" = 0 ]; then k=$((first+1))
|
|
1066
|
+
while [ "$k" -lt "$j" ]; do [ "${I[k]}" = 1 ] && { B[nb]=$k; nb=$((nb+1)); }; k=$((k+1)); done
|
|
1067
|
+
fi ;;
|
|
1068
|
+
esac
|
|
1069
|
+
i=$j
|
|
1070
|
+
done
|
|
1071
|
+
[ "$nb" -gt 0 ] || return 1
|
|
1072
|
+
s="$1"; pos=0; i=0
|
|
1073
|
+
while [ "$i" -lt "$nb" ]; do
|
|
1074
|
+
k="${B[i]}"; _IX="$_IX${s:pos:$(( S[k] - pos ))}''"; pos="${E[k]}"; i=$((i+1))
|
|
1075
|
+
done
|
|
1076
|
+
_IX="$_IX${s:pos}"
|
|
1077
|
+
return 0
|
|
1078
|
+
}
|
|
1079
|
+
# From here to the §4.4 gate the rules read the command WITHOUT those arguments; CMD_REAL keeps it whole, and the
|
|
1080
|
+
# commit and push gates below read that. A PowerShell call is never changed.
|
|
1081
|
+
CMD_REAL="$CMD"; CMD_UQ_REAL="$CMD_UQ"; _INERT=0
|
|
1082
|
+
case "$INPUT" in *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*) ;; *)
|
|
1083
|
+
if _inert_args "$CMD"; then _INERT=1; CMD="$_IX"; _unquoted "$CMD"; CMD_UQ="$_GS"; fi ;;
|
|
1084
|
+
esac
|
|
1085
|
+
_git_d45 "$CMD"
|
|
1086
|
+
# `no-veri`, not `no-verify`: git takes any unambiguous abbreviation, and `git push --no-verif` / `git merge --no-veri`
|
|
1087
|
+
# passed the rule that looked for the whole word (3.1.0 review; `--no-ver` and shorter are ambiguous to git itself).
|
|
1088
|
+
case "$CMD" in *[Nn][Oo]-[Vv][Ee][Rr][Ii]*) : ;; *) false ;; esac && block "hook skip (--no-verify)" "4.5" tamper
|
|
792
1089
|
git_has "$CMD" 'rebase' && block "git rebase" "4.5" history
|
|
793
1090
|
git_has "$CMD" 'filter-branch|filter-repo' && block "git filter-branch/filter-repo" "4.5" history
|
|
794
1091
|
{ git_has "$CMD" 'commit' && has '--amend'; } && block "git commit --amend" "4.5" history
|
|
@@ -813,6 +1110,18 @@ case "$CMD" in *branch*) _HAS_BRANCH=1 ;; *) _HAS_BRANCH=0 ;; esac
|
|
|
813
1110
|
# `rm -rf /` was blocked. Same class as the chmod hole found in evals/permission-pressure: one spelling gated,
|
|
814
1111
|
# another reaching the identical state. Case, flag order and the long form are all the same command.
|
|
815
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
|
|
1113
|
+
# ADDED (3.1.0): `.` and `..` as a target carry none of those three characters and are the widest targets there are —
|
|
1114
|
+
# `rm -rf .` at a project root takes the whole tree, `..` its parent (both passed). Narrower than the test above on
|
|
1115
|
+
# purpose, so that it adds no noise: `rm` must stand at a command position, `sudo` or not (`git rm -r --cached .`, `npm rm`,
|
|
1116
|
+
# `docker run --rm` and a quoted "rm -r ." are not it), the dot must be an argument of its own in that rm's segment
|
|
1117
|
+
# (`.git`, `./x`, `a.` are not), and a `#` ends the look (a comment is not a target), and so does a line break (`rm -rf dist⏎npx tsc -p .`). A builtin match: no process.
|
|
1118
|
+
_RMNL=$'\n'
|
|
1119
|
+
_RMDOT='(^|[;&|({'"$_RMNL"'])[[:blank:]]*(sudo[[:blank:]]+)?rm[[:space:]]+(-[A-Za-z]*[[:space:]]+|--[a-z-]*[[:space:]]+)*(-[A-Za-z]*[rR][A-Za-z]*|--recursive)([[:space:]]+(-[A-Za-z]+|--[a-z-]*))*[[:blank:]]+([^;&|#'"$_RMNL"']*[[:blank:]])?["'"'"']?\.\.?["'"'"']?([[:space:];&|)}]|$)'
|
|
1120
|
+
case "$CMD" in *[Rr][Mm]*)
|
|
1121
|
+
_rmc="$CMD"
|
|
1122
|
+
# a backslash-newline continues the line (`rm -rf \⏎.`); folded only when one is there and the command is short
|
|
1123
|
+
case "$CMD" in *\\"$_RMNL"*) [ "${#CMD}" -le 4096 ] && _rmc="${CMD//\\$_RMNL/ }" ;; esac
|
|
1124
|
+
[[ $_rmc =~ $_RMDOT ]] && block "destructive rm -rf" "4.5" loss ;; esac
|
|
816
1125
|
# A whole-tree `git checkout -- .` / `git restore .` destroys every uncommitted change with no reflog and no
|
|
817
1126
|
# undo — the same loss as `reset --hard`, which has been gated since the beginning, by a command that was not.
|
|
818
1127
|
# Not hypothetical: a verification subagent ran exactly this over uncommitted work in this repo and took the
|
|
@@ -914,12 +1223,54 @@ case "$CMD" in *[Cc][Hh][Mm][Oo][Dd]*) : ;; *) false ;; esac && _grep "$CMD" -qE
|
|
|
914
1223
|
# * the destructive verbs have short aliases (rm/del/erase/rd/ri for Remove-Item), and `rm` there is
|
|
915
1224
|
# Remove-Item, not POSIX rm — the same word with different flags, which is why the POSIX rule misses it.
|
|
916
1225
|
PS_RM='(remove-item|ri|rm|rmdir|rd|del|erase)'
|
|
917
|
-
|
|
918
|
-
|
|
1226
|
+
# A parameter is not only `-Name<space>`. PowerShell takes a value after a colon (`-Recurse:$true`), and it takes an
|
|
1227
|
+
# en dash, an em dash or a horizontal bar where the hyphen stands — what a word processor or a chat window turns `-`
|
|
1228
|
+
# into. Both spellings were measured on PowerShell 5.1 to delete the tree, and both walked past these two patterns,
|
|
1229
|
+
# which wanted a hyphen and then whitespace (3.1.0, field). The dashes are an alternation, not a bracket: a bracket
|
|
1230
|
+
# of multi-byte characters is read byte by byte in a C locale.
|
|
1231
|
+
PS_DASH='(-|–|—|―)'
|
|
1232
|
+
PS_RECURSE="${PS_DASH}"'r(e(c(u(r(s(e)?)?)?)?)?)?([[:space:]:]|$)'
|
|
1233
|
+
PS_FORCE="${PS_DASH}"'f(o(r(c(e)?)?)?)?([[:space:]:]|$)'
|
|
1234
|
+
_ps_has_r(){ case "$1" in *-[Rr]*|*–[Rr]*|*—[Rr]*|*―[Rr]*) return 0 ;; esac; return 1; } # the cheap precondition, same dashes
|
|
919
1235
|
# Recursive+forced removal aimed at a glob, a drive root, a UNC path, or $HOME — the shapes that take a tree out.
|
|
920
|
-
|
|
1236
|
+
# This line-wide test is the FLOOR and is kept exactly as it was: the markers are looked for anywhere on the line.
|
|
1237
|
+
# That over-blocks (one field command was stopped by an unrelated `Set-Location C:\…` AFTER the delete), and reading
|
|
1238
|
+
# the target from the removal's own statement instead was tried and measured: it opened 29 shapes this test stops —
|
|
1239
|
+
# a backtick or trailing-pipe line break, a `cd` through a variable, a splat, a function called with the path later.
|
|
1240
|
+
# A hook cannot tell "unrelated" from "arrives another way", so the floor stays and the refusal says how to go on.
|
|
1241
|
+
{ _ps_has_r "$CMD" && has "(^|[^A-Za-z0-9_-])$PS_RM[[:space:]]" && has "$PS_RECURSE" && has "$PS_FORCE" \
|
|
921
1242
|
&& has '(\*|[A-Za-z]:\\|\\\\|\$HOME|\$env:USERPROFILE|~)'; } \
|
|
922
1243
|
&& block "PowerShell recursive force delete (Remove-Item -Recurse -Force)" "4.5" loss
|
|
1244
|
+
# ADDED on top of the floor (3.1.0, field) — these can only stop more. Read from the removal's OWN statement, so an
|
|
1245
|
+
# everyday command elsewhere on the line does not trigger them:
|
|
1246
|
+
# * a target with a path separator: `Remove-Item -Recurse -Force src\app` and `"$env:TEMP\x"` passed while POSIX
|
|
1247
|
+
# `rm -rf src/app` was stopped;
|
|
1248
|
+
# * `.` or `..` standing as an argument — the widest targets there are, and they carry no marker at all
|
|
1249
|
+
# (`Remove-Item -Recurse -Force .` passed; it is the form this rule was first written for);
|
|
1250
|
+
# * a removal fed by a pipeline: what the pipe delivers is its target.
|
|
1251
|
+
# A single bare name (`Remove-Item -Recurse -Force build`) and a variable stay a routine local delete, as with POSIX
|
|
1252
|
+
# `rm -rf build`. Statements are split on `;` and newline with `read` — linear, and no process: this runs on every
|
|
1253
|
+
# command that carries `-r`. A split that is wrong for some exotic line costs a miss here, never an opening: the
|
|
1254
|
+
# floor above has already had its say.
|
|
1255
|
+
PS_RM_RE='(^|[^A-Za-z0-9_-])(remove-item|ri|rm|rmdir|rd|del|erase)([[:space:]]|$)'
|
|
1256
|
+
PS_TGT_RE='([\\/]|(^|[[:space:]"'"'"'(,:])\.\.?(["'"'"'),]|[[:space:]]|$))'
|
|
1257
|
+
_ps_rm_tree(){ # $1 = command -> 0 when a recursive forced removal in it is aimed at a path, at . / .., or fed by a pipe
|
|
1258
|
+
local ln st nc=0 rc=1
|
|
1259
|
+
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1260
|
+
if [[ $1 =~ $PS_RM_RE ]] && [[ $1 =~ $PS_RECURSE ]] && [[ $1 =~ $PS_FORCE ]]; then
|
|
1261
|
+
while IFS= read -r ln || [ -n "$ln" ]; do
|
|
1262
|
+
while IFS= read -r -d ';' st || [ -n "$st" ]; do
|
|
1263
|
+
_ps_has_r "$st" || continue
|
|
1264
|
+
[[ $st =~ $PS_RM_RE ]] && [[ $st =~ $PS_RECURSE ]] && [[ $st =~ $PS_FORCE ]] || continue
|
|
1265
|
+
if [[ $st =~ $PS_TGT_RE ]] || [[ $st =~ \|.*$PS_RM_RE ]]; then rc=0; break 2; fi
|
|
1266
|
+
done <<< "$ln"
|
|
1267
|
+
done <<< "$1"
|
|
1268
|
+
fi
|
|
1269
|
+
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
1270
|
+
return $rc
|
|
1271
|
+
}
|
|
1272
|
+
{ _ps_has_r "$CMD" && _ps_rm_tree "$CMD"; } \
|
|
1273
|
+
&& block "PowerShell recursive force delete (Remove-Item -Recurse -Force)" "4.5" loss
|
|
923
1274
|
# Download-and-execute, the PowerShell shape of curl|bash: any fetcher piped into Invoke-Expression.
|
|
924
1275
|
case "$CMD" in *[Ii][Ee][Xx]*|*[Ii][Nn][Vv][Oo][Kk][Ee]-[Ee][Xx][Pp][Rr][Ee][Ss][Ss][Ii][Oo][Nn]*) : ;; *) false ;; esac && _grep "$CMD" -qiE '(invoke-webrequest|iwr|invoke-restmethod|irm|curl|wget)[^|]*\|[[:space:]]*(invoke-expression|iex)([[:space:]]|$)' \
|
|
925
1276
|
&& block "PowerShell download-and-execute (… | iex)" "4.5" exec
|
|
@@ -958,14 +1309,22 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
|
|
|
958
1309
|
local verb="${_HP_PRE}(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|get|list)([${_HP_B}]|\$)"
|
|
959
1310
|
local wr="[${_HP_B}](--unset|--unset-all|--add|--replace-all|--edit|-e|set|unset|--rename-section|--remove-section|rename-section|remove-section)([${_HP_B}]|\$)"
|
|
960
1311
|
local -a reads=()
|
|
961
|
-
case "$c" in *"$bsnl"*) c
|
|
962
|
-
|
|
1312
|
+
case "$c" in *"$bsnl"*) _gsub "$c" '\\'$'\n' ' ' bs; c="$_GS" ;; esac # backslash-newline -> a space
|
|
1313
|
+
_unquoted "$c"; t="$_GS"
|
|
963
1314
|
case "$t" in *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*|*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*) ;; *) return 1 ;; esac # after unquoting: hooks"P"ath
|
|
964
1315
|
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
965
1316
|
# The payload reader decodes JSON escapes lossily — `\r` arrives as nothing, so `git config core.hooksPath <CR>`
|
|
966
1317
|
# would look value-less here while git sets the path to a CR byte (verified in review). With any such escape in
|
|
967
1318
|
# the raw payload the decoded text is not the command that will run, and no read is proven.
|
|
968
|
-
|
|
1319
|
+
# It is asked of the COMMAND as the payload spells it, with every escaped backslash (`\\`) taken out first. It
|
|
1320
|
+
# used to be asked of the whole payload: on Windows `"cwd":"C:\\repos\\app"` holds a backslash and an r, a `\\Users`
|
|
1321
|
+
# a backslash and a u in lower case, and so on, so in such a directory NO read was ever proven and `git config
|
|
1322
|
+
# --get core.hooksPath` — how a person checks that the gate is armed — was refused as tampering (measured on
|
|
1323
|
+
# Windows). An escaped backslash is a backslash, not the start of an escape: `\\r` is those two characters, and
|
|
1324
|
+
# only an odd one in front of the letter (`\r`, `\\\r`) is the escape the reader drops.
|
|
1325
|
+
local raw="${CMD_RAW-}"
|
|
1326
|
+
if [ -n "$raw" ]; then raw="${raw//\\\\/}"; else raw="$INPUT"; fi
|
|
1327
|
+
case "$raw" in *'\r'*|*'\f'*|*'\b'*|*'\v'*|*'\u'*) ;; *)
|
|
969
1328
|
_split_segs "$c"
|
|
970
1329
|
for seg in ${_SPL[@]+"${_SPL[@]}"}; do
|
|
971
1330
|
[[ $seg == *[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]* ]] || continue
|
|
@@ -979,23 +1338,54 @@ _hp_blocks() { # $1 = command -> 0 when it writes core.hooksPath or drops [core
|
|
|
979
1338
|
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
980
1339
|
# Leftover text of a read still matches the rule below, so cutting the wrong copy can only over-block.
|
|
981
1340
|
for r in ${reads[@]+"${reads[@]}"}; do c="${c/"$r"/ }"; done
|
|
982
|
-
|
|
1341
|
+
_unquoted "$c"; t="$_GS"
|
|
983
1342
|
_ere i "$t" 'git([^|]*[^[:alnum:]_|])?config[^[:alnum:]_|][^|]*core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' && return 0
|
|
984
|
-
_ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?core([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0
|
|
1343
|
+
_ere i "$t" 'config[^;&|]*[[:space:]](--remove-section|--rename-section|remove-section|rename-section)[[:space:]]+(--[[:space:]]+)?([^;&|[:space:]]+[[:space:]]+)?core([^A-Za-z0-9_.-]|$)' '*[Ss][Ee][Cc][Tt][Ii][Oo][Nn]*' && return 0 # [core] as the old name OR the new one
|
|
985
1344
|
return 1
|
|
986
1345
|
}
|
|
987
1346
|
# Inline config override: `git -c core.hooksPath=…` / `git --config-env core.hooksPath=…` turns the hooks off for
|
|
988
1347
|
# that one command WITHOUT the word `config` (so the rule above misses it) — the exact equivalent of --no-verify.
|
|
989
1348
|
[ "$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
|
|
990
1349
|
[ "$HAS_GIT" = 1 ] && _hp_blocks "$CMD" && block "git config core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1350
|
+
# The same setting by two side doors. `git config include.path f` (or includeIf.<cond>.path) makes git read ANOTHER
|
|
1351
|
+
# file as configuration, and that file can carry hooksPath; GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
|
|
1352
|
+
# hand git a setting through the environment (measured: with them a commit skipped its hooks). Both are refused by
|
|
1353
|
+
# name, reads included: a rule that argued which `git config include.path` is a read would be the reader that
|
|
1354
|
+
# _hp_blocks needed three review rounds to get right, for a key nobody reads in ordinary work.
|
|
1355
|
+
if [ "$HAS_GIT" = 1 ]; then
|
|
1356
|
+
_t="$CMD_UQ"
|
|
1357
|
+
# ...and the inline form of both, with the quoting taken off first: `git -c 'core.hooksPath=/dev/null' commit`,
|
|
1358
|
+
# `-c core.hooks''Path=…` and `-c include.path=f` all skipped the hooks, because the rule above wants the bare
|
|
1359
|
+
# key right after `-c` (measured, 3.1.0 review).
|
|
1360
|
+
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+core\.hooksPath' '*[Hh][Oo][Oo][Kk][Ss][Pp][Aa][Tt][Hh]*' \
|
|
1361
|
+
&& block "git -c core.hooksPath (disarms the git hooks)" "4.5" tamper
|
|
1362
|
+
_ere i "$_t" 'git[[:space:]]+([^;&|]*[[:space:]])?(-c|--config-env)[[:space:]=]+include(if)?\.' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
|
|
1363
|
+
&& block "git config include.path (another file read as configuration)" "4.5" tamper
|
|
1364
|
+
_ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&][^|;&]*(include\.path|includeif\.)' '*[Ii][Nn][Cc][Ll][Uu][Dd][Ee]*' \
|
|
1365
|
+
&& block "git config include.path (another file read as configuration)" "4.5" tamper
|
|
1366
|
+
# `git config --edit` opens the file itself in an editor, and the editor is whatever GIT_EDITOR says: no key is named.
|
|
1367
|
+
_ere i "$_t" 'git([^|;&]*[^[:alnum:]_|;&])?config[^[:alnum:]_|;&]([^|;&]*[[:space:]])?(--edit|-e)([[:space:]]|$)' '*[Cc][Oo][Nn][Ff][Ii][Gg]*' \
|
|
1368
|
+
&& block "git config --edit (the configuration file opened for writing)" "4.5" tamper
|
|
1369
|
+
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
|
|
1370
|
+
fi
|
|
991
1371
|
# A write to a gate path (hook script, settings.json, or .git/hooks) via ANY common mechanism — writer verbs, the
|
|
992
1372
|
# in-place editors, and the interpreters an evasion reaches for (perl/python/ruby/node/ed) — plus the variable-
|
|
993
1373
|
# indirected redirect (VAR=.claude/hooks; … > $VAR). Reading a gate file stays allowed, and `chmod +x` is NOT
|
|
994
1374
|
# blocked so doctor's re-arm fix still works (a chmod -x disable is caught by doctor, not here). Honest scope:
|
|
995
1375
|
# the shell is Turing-complete, so this is defence-in-depth — guard-write.sh covers the Write/Edit tools (the
|
|
996
1376
|
# model's natural path to a file), and install-time read-only hook files would be the airtight layer.
|
|
997
|
-
#
|
|
998
|
-
GATE='\.(claude
|
|
1377
|
+
_GP='[/\\]+(\.[/\\]+)*' # a path separator as the shell and the filesystem take it: `/`, `\`, doubled, with `/./` between
|
|
1378
|
+
GATE='(\.(claude'"$_GP"'(hooks|git-shim|settings\.json|DISCIPLINE\.md|eval'"$_GP"'lib'"$_GP"'crew-env\.sh)|git'"$_GP"'hooks|git'"$_GP"'(config|worktrees'"$_GP"'[^/\\[:space:]]+'"$_GP"'config|modules'"$_GP"'[^[:space:]]+'"$_GP"'config))|\.gitconfig([^A-Za-z0-9_.-]|$)|\.config'"$_GP"'git'"$_GP"'config([^A-Za-z0-9_.-]|$))'
|
|
1379
|
+
# .git/config (with a worktree's and a submodule's own) is on the list because core.hooksPath LIVES there: the rules
|
|
1380
|
+
# above stop `git config core.hooksPath …`, and a plain `printf '[core]\n\thooksPath = /dev/null\n' >> .git/config`
|
|
1381
|
+
# walked past them — after it a commit from the user's own terminal skips the trace and secret scans (measured, 3.1.0
|
|
1382
|
+
# review; inside a session guard-commit-scan.sh still scans). Reading it stays allowed. Measured before choosing this
|
|
1383
|
+
# over checking the setting at commit time: 12,422 real commands in 671 transcripts name .git/config 9 times, all 9 in
|
|
1384
|
+
# Crewforth's own development; and a check at commit time would refuse every commit of an install whose hooks were never
|
|
1385
|
+
# wired. The user's own files hold the same key for every repository at once: ~/.gitconfig and
|
|
1386
|
+
# $XDG_CONFIG_HOME/git/config (~/.config/git/config) are on the list by their names, wherever HOME is.
|
|
1387
|
+
# .claude/git-shim is the same thing one step removed: it is where core.hooksPath points when Crewforth shares
|
|
1388
|
+
# the hooks with a project's own chain.
|
|
999
1389
|
# eval/lib/crew-env.sh is on the list because the gates SOURCE it on every call (guard-bash, guard-write, the board
|
|
1000
1390
|
# hooks): a file a gate executes is part of the gate. Measured before it was added: overwrite it with `exit 0` and
|
|
1001
1391
|
# `rm -rf /` passed guard-bash with rc 0, in both editions (3.0.1 review).
|
|
@@ -1054,15 +1444,352 @@ fi
|
|
|
1054
1444
|
# to route around. A verb in one command and a path in another was never evidence of anything: the two forms
|
|
1055
1445
|
# that matter — `rm .claude/hooks/x` and `x > .claude/hooks/y` — both put them in the SAME segment, and both
|
|
1056
1446
|
# are still blocked (asserted in smoke-test, in both directions).
|
|
1447
|
+
# ---- the reader of a call (the commit gate further down describes what it is for) ----
|
|
1448
|
+
_c47_read(){ # $1 = command -> _C47_T: separators as ` ; `; every quoted span and every escaped character as one marker
|
|
1449
|
+
# (\001 n \001, its text in _C47_Q[n]); redirections set apart from the words they touch; heredoc bodies gone
|
|
1450
|
+
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=""
|
|
1452
|
+
_gsub "$s" $'\r' ''; _gsub "$_GS" "$_C47_M" ''; s="$_GS"
|
|
1453
|
+
while :; do
|
|
1454
|
+
pre="${s%%[\"\'\\\;\&\|\<\>\#\(\)\`\$$nl]*}"; out="$out$pre"
|
|
1455
|
+
[ "$pre" = "$s" ] && break
|
|
1456
|
+
c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
|
|
1457
|
+
case "$c" in
|
|
1458
|
+
\\) case "$s" in
|
|
1459
|
+
"$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
|
|
1462
|
+
esac ;;
|
|
1463
|
+
\') case "$s" in *\'*) body="${s%%\'*}"; s="${s:${#body}+1}" ;; *) body="$s"; s="" ;; esac
|
|
1464
|
+
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1465
|
+
\") body=""
|
|
1466
|
+
case "$s" in
|
|
1467
|
+
'$(cat <<'*) # a message read from a here-document: its body is skipped as a whole, quotes and all
|
|
1468
|
+
w="${s:8}"; w="${w#-}"; w="${w#[\'\"]}"; w="${w%%[!A-Za-z0-9_]*}"
|
|
1469
|
+
case "$s" in *"$nl$w$nl"*) [ -n "$w" ] && { pre="${s%%"$nl$w$nl"*}"; s="${s:${#pre}+${#w}+2}"; body='(a here-document)'; } ;; esac ;;
|
|
1470
|
+
esac
|
|
1471
|
+
while :; do # to the closing quote that is not escaped
|
|
1472
|
+
pre="${s%%[\"\\]*}"
|
|
1473
|
+
[ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
|
|
1474
|
+
c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
|
|
1475
|
+
[ "$c2" = '"' ] && break
|
|
1476
|
+
body="$body${s:0:1}"; s="${s:1}"
|
|
1477
|
+
done
|
|
1478
|
+
# "$@" and "${A[@]}" stay quoted and still become SEVERAL words: `set -- -n; git commit -m x "$@"`.
|
|
1479
|
+
case "$body" in *'$@'*|*'[@]'*) _C47_QX="$_C47_QX $n " ;; esac
|
|
1480
|
+
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1481
|
+
\$) case "$s" in
|
|
1482
|
+
\'*) # $'…' : a quoted text in which a backslash escapes, so a quote after one does not close it
|
|
1483
|
+
s="${s:1}"; body=""
|
|
1484
|
+
while :; do
|
|
1485
|
+
pre="${s%%[\'\\]*}"
|
|
1486
|
+
[ "$pre" = "$s" ] && { body="$body$s"; s=""; break; }
|
|
1487
|
+
c2="${s:${#pre}:1}"; body="$body$pre"; s="${s:${#pre}+1}"
|
|
1488
|
+
[ "$c2" = "'" ] && break
|
|
1489
|
+
body="$body\\${s:0:1}"; s="${s:1}"
|
|
1490
|
+
done
|
|
1491
|
+
_C47_Q[n]="$body"; out="$out$_C47_M$n$_C47_M"; n=$((n+1)) ;;
|
|
1492
|
+
'(('*) case "$s" in *'))'*) pre="${s%%'))'*}"; s="${s:${#pre}+2}" ;; *) s="" ;; esac; out="$out\$" ;; # $(( … )): arithmetic, `<<` in it is a shift
|
|
1493
|
+
'{'*) case "$s" in *'}'*) pre="${s%%'}'*}"; s="${s:${#pre}+1}" ;; *) s="" ;; esac; out="$out\$" ;; # ${ … }: one expansion, `#` in it is no comment
|
|
1494
|
+
*) out="$out\$" ;;
|
|
1495
|
+
esac ;;
|
|
1496
|
+
"$nl") if [ -n "$hd" ]; then # the lines up to the delimiter are the here-document
|
|
1497
|
+
case "$s" in
|
|
1498
|
+
"$hd") s="" ;;
|
|
1499
|
+
"$hd$nl"*) s="${s:${#hd}+1}" ;;
|
|
1500
|
+
*"$nl$hd$nl"*) pre="${s%%"$nl$hd$nl"*}"; s="${s:${#pre}+${#hd}+2}" ;;
|
|
1501
|
+
*"$nl$hd") s="" ;;
|
|
1502
|
+
esac # no such line: it was no here-document, read on
|
|
1503
|
+
hd=""
|
|
1504
|
+
fi
|
|
1505
|
+
out="$out ; " ;;
|
|
1506
|
+
\|) case "$out" in *\>) out="$out|" ;; *) out="$out ; " ;; esac ;; # >| is a redirection, not a pipe
|
|
1507
|
+
\;|\(|\)) out="$out ; " ;;
|
|
1508
|
+
# A backtick opens a command substitution as `$(` does, so the word it starts reads as an expansion too:
|
|
1509
|
+
# `git \`printf commit\` -m x` is a git call whose subcommand the shell fills in. The closing one only separates.
|
|
1510
|
+
\`) if [ "$bt" = 0 ]; then bt=1; out="$out\$ ; "; else bt=0; out="$out ; "; fi ;;
|
|
1511
|
+
\&) case "$out" in
|
|
1512
|
+
*[\<\>]) out="$out&" ;; # 2>&1, >&, and the input forms 0<&-, <&2
|
|
1513
|
+
*) case "$s" in
|
|
1514
|
+
\>*) out="$out &>"; s="${s:1}" ;; # &>
|
|
1515
|
+
*) out="$out ; " ;;
|
|
1516
|
+
esac ;;
|
|
1517
|
+
esac ;;
|
|
1518
|
+
\<|\>)
|
|
1519
|
+
if [ "$c" = '<' ]; then
|
|
1520
|
+
case "$s" in
|
|
1521
|
+
\<\<*) out="$out <<< "; s="${s:2}"; continue ;; # a here-string: one word follows, on this line
|
|
1522
|
+
\<*) s="${s:1}"; s="${s#-}"
|
|
1523
|
+
while :; do case "$s" in [$' \t']*) s="${s:1}" ;; *) break ;; esac; done
|
|
1524
|
+
t="${s%%[$' \t'\;\&\|\<\>\(\)$nl]*}"; s="${s:${#t}}" # the delimiter word, then without its quoting
|
|
1525
|
+
hd="${t//\"/}"; hd="${hd//\'/}"; hd="${hd//\\/}"
|
|
1526
|
+
out="$out <<H "; continue ;;
|
|
1527
|
+
esac
|
|
1528
|
+
fi
|
|
1529
|
+
# A redirection is its own word: `-a>/dev/null` is `-a` and `>/dev/null`. Only a number directly in front
|
|
1530
|
+
# of it belongs to it (2>…), and a `>` or `&` already there (>>, &>, >&).
|
|
1531
|
+
case "$out" in
|
|
1532
|
+
*[\<\>\&]) ;;
|
|
1533
|
+
*[0-9]) t="${out##*[!0-9]}"; pre="${out%"$t"}"; case "$pre" in ''|*[$' \t']) ;; *) out="$out " ;; esac ;;
|
|
1534
|
+
*) out="$out " ;;
|
|
1535
|
+
esac
|
|
1536
|
+
out="$out$c" ;;
|
|
1537
|
+
\#) 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
|
|
1539
|
+
*) out="$out#" ;;
|
|
1540
|
+
esac ;;
|
|
1541
|
+
esac
|
|
1542
|
+
done
|
|
1543
|
+
_C47_T="$out"
|
|
1544
|
+
}
|
|
1545
|
+
_c47_w(){ # $1 = one token of _C47_T -> _W: the word as the command receives it (expansions left as written);
|
|
1546
|
+
# _WQ = 1 when part of it was quoted; _WX = 1 when an UNQUOTED part holds $ * ? [ or {, i.e. the shell
|
|
1547
|
+
# will make something else of it — possibly more than one word
|
|
1548
|
+
local t="$1" m="$_C47_M" pre i
|
|
1549
|
+
_W=""; _WQ=0; _WX=0; _WAT=0
|
|
1550
|
+
while :; do
|
|
1551
|
+
case "$t" in *"$m"*) ;; *) break ;; esac
|
|
1552
|
+
pre="${t%%"$m"*}"; t="${t#*"$m"}"; i="${t%%"$m"*}"; t="${t#*"$m"}"
|
|
1553
|
+
case "$pre" in *[\$\*\?\[\{]*) _WX=1 ;; esac
|
|
1554
|
+
case "$i" in ''|*[!0-9]*) ;; *) _W="$_W$pre${_C47_Q[i]:-}"; _WQ=1; case "$_C47_QX" in *" $i "*) _WX=1; _WAT=1 ;; esac; continue ;; esac
|
|
1555
|
+
_W="$_W$pre"
|
|
1556
|
+
done
|
|
1557
|
+
case "$t" in *[\$\*\?\[\{]*) _WX=1 ;; esac
|
|
1558
|
+
_W="$_W$t"
|
|
1559
|
+
}
|
|
1560
|
+
# 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
|
+
_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; }
|
|
1057
1562
|
# CREW-NOT-A-RUNG: same — `perl`, `python3`, `ruby`, `node` here are names the gate REFUSES when they are
|
|
1058
1563
|
# pointed at a gate file, not readers this hook uses.
|
|
1059
|
-
|
|
1060
|
-
|
|
1564
|
+
# A COMMAND THAT NAMES A GATE FILE IS ON A SHORT LIST, OR IT IS REFUSED. This rule used to look for a write VERB
|
|
1565
|
+
# (`rm|mv|cp|tee|…`) before the gate path, and a list of verbs is never complete: of 39 ways to write or delete a
|
|
1566
|
+
# gate file, 32 passed (measured on 3.0.3: `curl -o`, `wget -O`, `sort -o`, `git log --output=`, `openssl -out`,
|
|
1567
|
+
# `tar -C`, `unzip -d`, `unlink`, `shred`, `rmdir`, `touch`, `patch`, `vim -c wq`, `find … -delete`,
|
|
1568
|
+
# `git checkout <rev> -- <gate file>`, `git restore -s`). It also refused readers whose arguments held such a word
|
|
1569
|
+
# (`grep -n rm <gate file>`, `cat /Users/ed/p/.claude/hooks/x`). So the question is turned round: a simple command
|
|
1570
|
+
# that names a gate path passes only when its command word is
|
|
1571
|
+
# * a READER: grep egrep fgrep rg (no --pre) cat head tail wc ls less (no log file) diff cmp stat sha256sum shasum
|
|
1572
|
+
# md5 md5sum du basename dirname realpath readlink jq echo printf test [ [[ mkdir pwd, sed without -i, sort
|
|
1573
|
+
# without -o, find without -delete / -exec / -fprint;
|
|
1574
|
+
# * a RUNNER of a hook script: `bash|sh <a .sh file, pre-commit or commit-msg under .claude/hooks>`, or that file
|
|
1575
|
+
# itself — never `bash -c`, never a script that is not one; and `chmod +x` / `755` on one;
|
|
1576
|
+
# * git status | log | diff | show | add | ls-files | blame | check-ignore | rev-parse | cat-file | ls-tree |
|
|
1577
|
+
# commit, and a `git config` that reads, with nothing before the subcommand but --no-pager / -P, and without
|
|
1578
|
+
# --output / --ext-diff.
|
|
1579
|
+
# Everything else that names one is refused, a program nobody listed included. What a reader does with a
|
|
1580
|
+
# redirection is the redirect rule's, below.
|
|
1581
|
+
# A PATH THAT IS HIDDEN is followed where the text allows it: quotes and backslashes are taken out first
|
|
1582
|
+
# (`.clau\de/hooks`), `.claude` itself counts (`mv .claude x`), a glob with a dot component is expanded in the
|
|
1583
|
+
# call's directory (`.claude/hoo*/guard*`), and a call that BINDS a gate path — `D=.claude/hooks`, `for f in
|
|
1584
|
+
# .claude/hooks/*`, `cd .claude/hooks`, or a call that already stands in such a directory — must have every one of
|
|
1585
|
+
# its commands on the list, since any of them may reach the file by the short name.
|
|
1586
|
+
# The call is read by _c47_read, the reader the commit gate uses: quotes paired, a here-document's body set
|
|
1587
|
+
# apart, every separator the same. A gate path named only in a here-document makes every command of the call
|
|
1588
|
+
# answer to the list: the body is text for `cat` and a program for `python3 -`.
|
|
1589
|
+
# Bash tool only. A PowerShell call keeps the verb rule.
|
|
1590
|
+
_GANC='(^|[[:space:]=:/\\])\.claude[/\\.]*([[:space:];&|)]|$)'
|
|
1591
|
+
_gt_named(){ # $1 = text -> 0 = it names a gate path, or the directory that holds them
|
|
1592
|
+
local r=1; shopt -s nocasematch
|
|
1593
|
+
if [[ "$1" =~ $GATE ]] || [[ "$1" =~ $_GANC ]]; then r=0; fi
|
|
1594
|
+
shopt -u nocasematch; return "$r"
|
|
1595
|
+
}
|
|
1596
|
+
_gt_script(){ # $1 = a word -> 0 = a file under a hooks directory of the gate (a hook script to run)
|
|
1597
|
+
local r=1; shopt -s nocasematch
|
|
1598
|
+
if [[ "$1" =~ \.claude[/\\]+hooks[/\\]+([^/\\[:space:]]+\.sh|pre-commit|commit-msg)$ ]] || [[ "$1" =~ \.claude[/\\]+git-shim[/\\]+git$ ]] || { [ -n "${_PGATE:-}" ] && [[ "$1" =~ $_PGATE ]]; }; then r=0; fi
|
|
1599
|
+
shopt -u nocasematch; return "$r"
|
|
1600
|
+
}
|
|
1601
|
+
_gt_ok(){ # $1 = one simple command, unquoted; $2 = 1: the call binds a gate path -> 0 = on the list · 1 = not (_GTW says why)
|
|
1602
|
+
local seg="${1//[$'\t\n']/ }" taint="${2:-0}" k=0 a w base sub scr
|
|
1603
|
+
local -a W=()
|
|
1604
|
+
read -r -a W <<< "$seg"
|
|
1605
|
+
_GTW=""
|
|
1606
|
+
a=0 # a = 1: an assignment stands in front of the command word
|
|
1607
|
+
while [ "$k" -lt "${#W[@]}" ]; do
|
|
1608
|
+
case "${W[k]}" in
|
|
1609
|
+
then|do|else|elif|if|while|until|'!'|'{'|time) k=$((k+1)) ;;
|
|
1610
|
+
[A-Za-z_]*=*) case "${W[k]%%=*}" in *[!A-Za-z0-9_]*) break ;; *) a=1; k=$((k+1)) ;; esac ;;
|
|
1611
|
+
*) break ;;
|
|
1612
|
+
esac
|
|
1613
|
+
done
|
|
1614
|
+
[ "$k" -lt "${#W[@]}" ] || return 0
|
|
1615
|
+
w="${W[k]}"
|
|
1616
|
+
case "$w" in for|case|select|fi|done|esac|'}'|in) return 0 ;; esac
|
|
1617
|
+
# `PATH=/x cat <gate file>` runs another cat, `PAGER=… git log` another pager: an assignment in front changes
|
|
1618
|
+
# what the word runs, so the word is not vouched for.
|
|
1619
|
+
[ "$a" = 0 ] || { _GTW="an assignment stands in front of '$w', and it can change what that word runs"; return 1; }
|
|
1620
|
+
base="$w"
|
|
1621
|
+
case "$w" in */*|*\\*)
|
|
1622
|
+
case "${w%/*}" in
|
|
1623
|
+
/bin|/usr/bin|/usr/local/bin|/opt/homebrew/bin) base="${w##*/}" ;;
|
|
1624
|
+
*) _gt_script "$w" && return 0
|
|
1625
|
+
_GTW="'$w' is a program given by its path, not a command on the list"; return 1 ;;
|
|
1626
|
+
esac ;;
|
|
1627
|
+
esac
|
|
1628
|
+
case "$base" in
|
|
1629
|
+
bash|sh|zsh|dash|ksh)
|
|
1630
|
+
a=$((k+1)); scr=""
|
|
1631
|
+
while [ "$a" -lt "${#W[@]}" ]; do
|
|
1632
|
+
case "${W[a]}" in
|
|
1633
|
+
-o|+o|-O|+O) a=$((a+1)) ;;
|
|
1634
|
+
--*) ;;
|
|
1635
|
+
-*[cis]*) _GTW="'$base ${W[a]}' takes its commands from an argument or from its input"; return 1 ;;
|
|
1636
|
+
-*|+*) ;;
|
|
1637
|
+
*) scr="${W[a]}"; break ;;
|
|
1638
|
+
esac
|
|
1639
|
+
a=$((a+1))
|
|
1640
|
+
done
|
|
1641
|
+
[ -n "$scr" ] || { _GTW="'$base' with no script takes its commands from its input"; return 1; }
|
|
1642
|
+
_gt_script "$scr" && return 0
|
|
1643
|
+
case "$taint" in
|
|
1644
|
+
1) case "$scr" in *'$'*) return 0 ;; esac ;;
|
|
1645
|
+
2) case "$scr" in */*|*\\*) ;; *) return 0 ;; esac ;; # in a gate directory, a script named without a path is one of its files
|
|
1646
|
+
esac
|
|
1647
|
+
_GTW="'$base $scr' runs a script that is not a hook script"; return 1 ;;
|
|
1648
|
+
git)
|
|
1649
|
+
a=$((k+1))
|
|
1650
|
+
while [ "$a" -lt "${#W[@]}" ]; do case "${W[a]}" in --no-pager|-P) a=$((a+1)) ;; *) break ;; esac; done
|
|
1651
|
+
sub="${W[a]:-}"
|
|
1652
|
+
case "$sub" in
|
|
1653
|
+
status|log|diff|show|add|ls-files|blame|check-ignore|rev-parse|cat-file|ls-tree|commit) ;;
|
|
1654
|
+
config) # a READ of the configuration: --get / --list, or a name and no value
|
|
1655
|
+
scr=0; a=$((a+1))
|
|
1656
|
+
while [ "$a" -lt "${#W[@]}" ]; do
|
|
1657
|
+
case "${W[a]}" in
|
|
1658
|
+
--get|--get-all|--get-regexp|--get-urlmatch|--list|-l) return 0 ;;
|
|
1659
|
+
-f|--file|--blob|--type|--default) a=$((a+1)) ;;
|
|
1660
|
+
-*) ;;
|
|
1661
|
+
*) scr=$((scr+1)) ;;
|
|
1662
|
+
esac
|
|
1663
|
+
a=$((a+1))
|
|
1664
|
+
done
|
|
1665
|
+
[ "$scr" = 1 ] && return 0
|
|
1666
|
+
_GTW="'git config' with a value writes the configuration"; return 1 ;;
|
|
1667
|
+
*) _GTW="'git ${sub:-…}' is not one of the git commands that only read or stage"; return 1 ;;
|
|
1668
|
+
esac
|
|
1669
|
+
for w in ${W[@]+"${W[@]:a}"}; do case "$w" in --ou*|--ext*) _GTW="'git $sub $w' writes a file or runs a program"; return 1 ;; esac; done
|
|
1670
|
+
return 0 ;;
|
|
1671
|
+
rg) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --pr*) _GTW="'rg $w' runs a program on every file"; return 1 ;; esac; done; return 0 ;;
|
|
1672
|
+
sed) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --i*|-[!-]*[iI]*|-[iI]*) _GTW="'sed $w' edits the file in place"; return 1 ;; esac; done; return 0 ;;
|
|
1673
|
+
sort) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --o*|-[!-]*o*|-o*) _GTW="'sort $w' writes a file"; return 1 ;; esac; done; return 0 ;;
|
|
1674
|
+
less) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --log*|--LOG*|-[!-]*[oO]*|-[oO]*) _GTW="'less $w' writes a log file"; return 1 ;; esac; done; return 0 ;;
|
|
1675
|
+
chmod) # making a hook runnable again is how the doctor's advice is followed; any other mode is not
|
|
1676
|
+
case "${W[k+1]:-}" in [ugoa]+x|[ugoa][ugoa]+x|+x|+rx|755|0755) return 0 ;; esac
|
|
1677
|
+
_GTW="'chmod ${W[k+1]:-}' is not the mode that makes a hook runnable (+x, 755)"; return 1 ;;
|
|
1678
|
+
find) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in -delete|-exec|-execdir|-ok|-okdir|-fprint*|-fls) _GTW="'find $w' deletes, runs or writes"; return 1 ;; esac; done; return 0 ;;
|
|
1679
|
+
grep|egrep|fgrep|cat|head|tail|wc|ls|diff|cmp|stat|sha256sum|shasum|md5|md5sum|du|basename|dirname|realpath|readlink|echo|printf|test|'['|'[['|true|:|mkdir|pwd|cd|pushd|popd|read|mapfile|readarray) return 0 ;;
|
|
1680
|
+
jq) for w in ${W[@]+"${W[@]:k}"}; do case "$w" in --ou*) _GTW="'jq $w' names an output"; return 1 ;; esac; done; return 0 ;;
|
|
1681
|
+
esac
|
|
1682
|
+
_GTW="'$base' is not on the list of commands that read a gate file or run a hook script"; return 1
|
|
1683
|
+
}
|
|
1684
|
+
_GT_CWD1='/\.claude(/+(hooks|git-shim)(/.*)?)?/*$'
|
|
1685
|
+
_GT_CWD2='/\.git(/+hooks(/.*)?)?/*$'
|
|
1686
|
+
_GT_GLOB1='(^|[[:space:]/=])\.[^/[:space:]]*[*?[{]'
|
|
1687
|
+
_GT_GLOB2='\.(claude|git)[/\\][^[:space:]]*[*?[{]'
|
|
1688
|
+
_GT_GLOB3='(^|[[:space:]/=])\{[^}[:space:]]*\.'
|
|
1689
|
+
_GT_DOTGIT='(^|[[:space:];&|(])(cd|pushd)[[:space:]]+[^[:space:];&|]*\.git[/\\]*([[:space:];&|)]|$)'
|
|
1690
|
+
# _GT_MEAN: a word that changes what a later command word runs. _GT_BIND: one that hands names on to a command.
|
|
1691
|
+
_GT_MEAN='(^|[;&|[:space:](])(alias|function|hash|enable|builtin|source|eval|exec|trap|export|declare|typeset|readonly)([[:space:]]|$)|(PATH|ENV|IFS)=|\(\)'
|
|
1692
|
+
_GT_BIND='(^|[;&|[:space:](])(xargs|parallel|read|mapfile|readarray)([[:space:]]|$)'
|
|
1693
|
+
# A shell with no script reads its commands from the pipe in front of it: `… | sh`.
|
|
1694
|
+
_GT_PIPESH='[|][[:space:]]*(ba|z|da|k)?sh[[:space:]]*($|[;&|<>])'
|
|
1695
|
+
_gt_judge(){ # the call -> returns 1 with _GTW set when one of its commands is refused; 0 otherwise
|
|
1696
|
+
local cwd="" taint=0 named=0 globby=0 any=0 here=0 seg w x t i sp=$'\002'
|
|
1697
|
+
local -a SEG=() W=() # set, not only declared: bash 4.4 and later call a declared, empty array unbound under `set -u`
|
|
1698
|
+
_GTW=""
|
|
1699
|
+
if _gate_named && _gt_named "$CMD_UQ"; then named=1; fi
|
|
1700
|
+
case "$CMD_UQ" in *.git*) [[ "$CMD_UQ" =~ $_GT_DOTGIT ]] && named=1 ;; esac
|
|
1701
|
+
case "$INPUT" in *.claude*|*.git*)
|
|
1702
|
+
_json_slice "$INPUT" cwd >/dev/null; cwd="${_JS//\\\\//}"
|
|
1703
|
+
shopt -s nocasematch
|
|
1704
|
+
if [[ "$cwd" =~ $_GT_CWD1 ]] || [[ "$cwd" =~ $_GT_CWD2 ]]; then taint=2; fi
|
|
1705
|
+
shopt -u nocasematch ;;
|
|
1706
|
+
esac
|
|
1707
|
+
case "$CMD_UQ" in *[*?[{]*)
|
|
1708
|
+
if [[ "$CMD_UQ" =~ $_GT_GLOB1 ]] || [[ "$CMD_UQ" =~ $_GT_GLOB2 ]] || [[ "$CMD_UQ" =~ $_GT_GLOB3 ]]; then globby=1; fi ;;
|
|
1709
|
+
esac
|
|
1710
|
+
[ "$named$taint$globby" != 000 ] || return 0
|
|
1711
|
+
# The simple commands of the call, as _c47_read reads it (quotes paired, a here-document's body gone, every
|
|
1712
|
+
# separator the same), each word without its quoting.
|
|
1713
|
+
_c47_read "$CMD"; _gsub "$_C47_T" ' ; ' $'\n'
|
|
1714
|
+
while IFS= read -r seg; do
|
|
1715
|
+
case "$seg" in *[![:space:]]*) ;; *) continue ;; esac
|
|
1716
|
+
read -r -a W <<< "$seg"; t=""
|
|
1717
|
+
for w in ${W[@]+"${W[@]}"}; do _c47_w "$w"; t="$t ${_W//[$' \t\n']/$sp}"; done
|
|
1718
|
+
SEG[${#SEG[@]}]="${t# }"
|
|
1719
|
+
{ _gt_named "$t" || [[ "$t" =~ $_GT_DOTGIT ]]; } && any=1
|
|
1720
|
+
case " $t " in *' <<H '*) here=1 ;; esac
|
|
1721
|
+
done <<< "$_GS"
|
|
1722
|
+
[ "${#SEG[@]}" -gt 0 ] || return 0
|
|
1723
|
+
if [ "$taint" = 0 ] && [ "$named" = 1 ]; then
|
|
1724
|
+
if [ "$any" = 0 ]; then
|
|
1725
|
+
# Named, but by none of the commands: in a comment, or in the body of a here-document. A comment runs
|
|
1726
|
+
# nothing. A here-document is text for `cat` or `git commit -F -`, and a program for an interpreter or a
|
|
1727
|
+
# shell — and it can be printed into one — so with one in the call every command must be on the list.
|
|
1728
|
+
[ "$here" = 1 ] || return 0
|
|
1729
|
+
taint=2
|
|
1730
|
+
else
|
|
1731
|
+
# does the call BIND a gate path (an assignment, a for list, a cd), hand names on (a substitution, xargs),
|
|
1732
|
+
# or change what a command word runs?
|
|
1733
|
+
# A call that changes what a command word RUNS vouches for none of its words: `PATH=/x; cat <gate file>`.
|
|
1734
|
+
if [[ "$CMD_UQ" =~ $_GT_MEAN ]]; then _GTW="the call holds '${BASH_REMATCH[0]# }', which can change what a command word runs"; return 1; fi
|
|
1735
|
+
if [[ "$CMD_UQ" =~ $_GT_PIPESH ]]; then _GTW="the call pipes text into a shell, which runs it"; return 1; fi
|
|
1736
|
+
[[ "$CMD_UQ" =~ $_GT_BIND ]] && taint=1
|
|
1737
|
+
case "$CMD" in *'$('*|*'`'*) taint=1 ;; esac
|
|
1738
|
+
for seg in "${SEG[@]}"; do
|
|
1739
|
+
read -r -a W <<< "$seg"; i=0
|
|
1740
|
+
while [ "$i" -lt "${#W[@]}" ]; do
|
|
1741
|
+
case "${W[i]}" in
|
|
1742
|
+
then|do|else|elif|if|while|until|'!'|'{'|time) ;;
|
|
1743
|
+
[A-Za-z_]*=*) [ "$taint" != 0 ] || { _gt_named "${W[i]#*=}" && taint=1; } ;;
|
|
1744
|
+
for) _gt_named "$seg" && [ "$taint" = 0 ] && taint=1; break ;;
|
|
1745
|
+
cd|pushd) { _gt_named "$seg" || [[ "$seg" =~ $_GT_DOTGIT ]]; } && taint=2; break ;;
|
|
1746
|
+
*) break ;;
|
|
1747
|
+
esac
|
|
1748
|
+
i=$((i+1))
|
|
1749
|
+
done
|
|
1750
|
+
done
|
|
1751
|
+
fi
|
|
1752
|
+
fi
|
|
1753
|
+
for seg in "${SEG[@]}"; do
|
|
1754
|
+
x=0
|
|
1755
|
+
if [ "$taint" = 1 ]; then # by a name: only a command that expands something, or holds a binding word, can reach it
|
|
1756
|
+
case "$seg" in *'$'*) x=1 ;; *) [[ "$seg" =~ $_GT_BIND ]] && x=1 ;; esac
|
|
1757
|
+
fi
|
|
1758
|
+
if [ "$taint" = 2 ] || [ "$x" = 1 ]; then
|
|
1759
|
+
_gt_ok "$seg" "$taint" || { _GTW="$_GTW, in a call that can reach a gate file by another name (a variable, a loop, a cd, a substitution, a here-document, xargs, or a word that changes what a command runs)"; return 1; }
|
|
1760
|
+
continue
|
|
1761
|
+
fi
|
|
1762
|
+
_gt_named "$seg" && x=1
|
|
1763
|
+
if [ "$x" = 0 ] && [ "$globby" = 1 ]; then
|
|
1764
|
+
read -r -a W <<< "$seg"
|
|
1765
|
+
for w in ${W[@]+"${W[@]}"}; do
|
|
1766
|
+
case "$w" in
|
|
1767
|
+
*'{'*.*|*.*'{'*) case "$w" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*|*.[Gg][Ii][Tt]*) x=1 ;; esac ;;
|
|
1768
|
+
*.*[*?[]*) t="$( cd "${cwd:-.}" 2>/dev/null && compgen -G "$w" )"; _gt_named "$t" && x=1 ;;
|
|
1769
|
+
esac
|
|
1770
|
+
done
|
|
1771
|
+
fi
|
|
1772
|
+
[ "$x" = 1 ] || continue
|
|
1773
|
+
_gt_ok "$seg" 0 || return 1
|
|
1774
|
+
done
|
|
1775
|
+
return 0
|
|
1776
|
+
}
|
|
1777
|
+
_gate_verb_ps(){ _grep "$CMD" -qiE "(^|[^A-Za-z0-9_-])(rm|mv|cp|rsync|sponge|truncate|tee|install|ln|perl|python[0-9.]*|ruby|node|ex|ed|set-content|add-content|clear-content|out-file|new-item|rename-item|copy-item|move-item|remove-item)([^A-Za-z0-9_/\\\\;&|]|\\\\[^A-Za-z0-9;&|])[^;&|]*$GATE"; }
|
|
1778
|
+
case "$INPUT" in
|
|
1779
|
+
*'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*)
|
|
1780
|
+
_gate_named && _gate_verb_ps && block "write/tamper of a gate file (hook/settings/.git-hooks)" "4.5" tamper ;;
|
|
1781
|
+
*)
|
|
1782
|
+
if ! _gt_judge; then
|
|
1783
|
+
_BLOCK_NOTE="A command that names a gate file passes only when it reads the file or runs a hook script: $_GTW."
|
|
1784
|
+
block "write/tamper of a gate file (hook/settings/.git-hooks)" "4.5" tamper
|
|
1785
|
+
fi ;;
|
|
1786
|
+
esac
|
|
1787
|
+
_gate_named && _grep "$CMD" -qiE "(sed|perl|awk|ruby)[[:space:]]+(-[^[:space:]]+[[:space:]]+)*-i[^;&|]*$GATE" && block "in-place edit of a gate file" "4.5" tamper
|
|
1061
1788
|
# /CREW-NOT-A-RUNG
|
|
1062
1789
|
# The redirect TARGET must be the gate path, not merely something later on the line: a target is one token, so
|
|
1063
1790
|
# it cannot contain whitespace or a command separator.
|
|
1064
|
-
|
|
1065
|
-
{
|
|
1791
|
+
_gate_named && _grep "$CMD" -qiE ">[|]?[[:space:]]*['\"]?[^[:space:];&|<>]*$GATE" && block "redirect over a gate file" "4.5" tamper
|
|
1792
|
+
{ _gate_named && has "=[^;&|]*$GATE" && has '>>?[[:space:]]*\$'; } && block "indirected write to a gate path (variable + redirect)" "4.5" tamper
|
|
1066
1793
|
# A symlink whose TARGET is the config directory itself is the two-step form of editing a hook, and step one
|
|
1067
1794
|
# names no gate path at all: `ln -sfn .claude cfg` passed every rule above, and then `cfg/hooks/guard-bash.sh`
|
|
1068
1795
|
# is an ordinary-looking path that lands on the real gate script — measured, both steps rc=0, file overwritten.
|
|
@@ -1075,6 +1802,47 @@ case "$CMD" in *[Cc][Ll][Aa][Uu][Dd][Ee]*|*[Hh][Oo][Oo][Kk][Ss]*) : ;; *) false
|
|
|
1075
1802
|
_LNT='([^;&|[:space:]]*/)?\.(claude|git)'; [ -n "$_PLNK" ] && _LNT="(${_LNT}|${_PLNK})"
|
|
1076
1803
|
case "$CMD" in *[Ll][Nn][[:space:]]*|*[Mm][Kk][Ll][Ii][Nn][Kk]*) : ;; *) false ;; esac && _grep "$CMD" -qiE "(^|[;&|[:space:]])(ln|mklink)[^;&|]*[[:space:]]${_LNT}([[:space:]]|\$)" && block "symlink pointing at the config directory (a gate path in two steps)" "4.5" tamper
|
|
1077
1804
|
|
|
1805
|
+
# §4.4: THE APPROVAL RECORD IS WRITTEN BY ONE THING, the UserPromptSubmit hook, from the user's own message
|
|
1806
|
+
# (hooks/prompt-approval.sh). A session that could produce that record itself would be approving its own commit,
|
|
1807
|
+
# and review found three ways it could, each closed here:
|
|
1808
|
+
# 1. write the file -> a shell command that NAMES `crewforth-approval` is refused, reading included:
|
|
1809
|
+
# nothing a session does needs it. guard-write.sh has the same rule for the file tools.
|
|
1810
|
+
# 2. run the hook itself -> `printf '{…"prompt":"approve: commit"}' | bash .claude/hooks/prompt-approval.sh`
|
|
1811
|
+
# wrote a record nobody typed. A command that names the hook AND feeds it (a pipe or
|
|
1812
|
+
# an input redirection: the hook reads its payload from stdin) is refused.
|
|
1813
|
+
# 3. start a session that continues this one, with the approval as its prompt (`claude -p --continue "approve: …"`).
|
|
1814
|
+
# The record carries the session it was given in, so a NEW session's approval is
|
|
1815
|
+
# worth nothing here; one that continues or names this session is refused in the two
|
|
1816
|
+
# modes where a record counts.
|
|
1817
|
+
# Quotes and backslashes are removed first (`crewforth-appr"oval"` is the same file to the shell), and case is
|
|
1818
|
+
# folded because the two filesystems people use most fold it. Builtins only: this is on every Bash call.
|
|
1819
|
+
# HONEST SCOPE, as for the gate files above: these rules match NAMES. A command that reaches the same thing without
|
|
1820
|
+
# spelling it (a glob, a concatenation, a script written first) is past them.
|
|
1821
|
+
_appr_named(){ # sets _APN: 1 = the record is named, 2 = the hook is fed, 3 = a session that continues this one, 0 = none
|
|
1822
|
+
local t nc=0
|
|
1823
|
+
t="$CMD_UQ"; _APN=0
|
|
1824
|
+
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
1825
|
+
case "$t" in
|
|
1826
|
+
*crewforth-appr*) _APN=1 ;;
|
|
1827
|
+
esac
|
|
1828
|
+
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
1829
|
+
# FED means the hook is what the input goes INTO: it stands after a pipe, or shares its command with an input
|
|
1830
|
+
# redirection. `cat …/prompt-approval.sh | grep -n x` and `grep -rn prompt-approval . | head` only read it, and a
|
|
1831
|
+
# first version that looked for a pipe anywhere on the line refused both (review).
|
|
1832
|
+
if [ "$_APN" = 0 ]; then case "$t" in *[Pp][Rr][Oo][Mm][Pp][Tt]-[Aa][Pp][Pp][Rr][Oo][Vv][Aa][Ll]*)
|
|
1833
|
+
{ _ere i "$t" '\|[^|;&]*prompt-approval' '*[Pp][Rr][Oo][Mm][Pp][Tt]-*' \
|
|
1834
|
+
|| _ere i "$t" '(prompt-approval[^|;&]*<|<[^|;&]*prompt-approval)' '*[Pp][Rr][Oo][Mm][Pp][Tt]-*'; } && _APN=2 ;;
|
|
1835
|
+
esac; fi
|
|
1836
|
+
if [ "$_APN" = 0 ]; then case "$PERM_MODE" in auto|dontAsk)
|
|
1837
|
+
_ere i "$t" '(^|[^A-Za-z0-9_./-])claude(\.exe|\.cmd)?[[:space:]]+([^;&|]*[[:space:]])?(--continue|--resume|--session-id|--fork-session|-c|-r)([[:space:]=]|$)' '*[Cc][Ll][Aa][Uu][Dd][Ee]*' \
|
|
1838
|
+
&& _APN=3 ;;
|
|
1839
|
+
esac; fi
|
|
1840
|
+
}
|
|
1841
|
+
_appr_named
|
|
1842
|
+
[ "$_APN" = 1 ] && block "the approval record named in a command (only the user's own message writes it)" "4.4" tamper
|
|
1843
|
+
[ "$_APN" = 2 ] && block "the approval hook fed by a command (only Claude Code runs it, with the user's message)" "4.4" tamper
|
|
1844
|
+
[ "$_APN" = 3 ] && block "a Claude Code session started from a command to continue this one (it could approve for it)" "4.4" tamper
|
|
1845
|
+
|
|
1078
1846
|
# §4.5-adjacent: a .env file holds secrets. The settings.json Read-tool deny does NOT cover the Bash tool, so a
|
|
1079
1847
|
# `cat .env` would surface them. Block the direct-file readers/copiers and a `< .env` input redirect on a
|
|
1080
1848
|
# .env / .env.<env> file; the templates (.env.example/.sample/.template/.dist) stay readable. Arg-taking readers
|
|
@@ -1328,6 +2096,12 @@ fi
|
|
|
1328
2096
|
&& block "git update-index --add (bypasses .gitignore, same as git add -f)" "4.5" bypass
|
|
1329
2097
|
case "$CMD" in *[Rr][Mm]*) : ;; *) false ;; esac && _grep "$CMD" -qE '(rm|git[[:space:]]+rm)\b[^|]*(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Gemfile\.lock|poetry\.lock|Pipfile\.lock|Cargo\.lock|composer\.lock|go\.sum|packages\.lock\.json)' && block "lockfile deletion" "4.5" loss
|
|
1330
2098
|
|
|
2099
|
+
# The rules that read the command as text end here: what follows judges a commit or a push, and reads all of it.
|
|
2100
|
+
# WHETHER there is a commit or a push in the call is still asked of the text without those arguments (CMD_SEEN):
|
|
2101
|
+
# `echo "git push origin main"` pushes nothing, and asking the user to approve an echo is the same mistake as
|
|
2102
|
+
# refusing it. What a commit carries (its options, its message, the approval it needs) is read from the whole call.
|
|
2103
|
+
CMD_SEEN="$CMD"; CMD="$CMD_REAL"; CMD_UQ="$CMD_UQ_REAL"
|
|
2104
|
+
|
|
1331
2105
|
# --- §4.4 commit/push approval gate ---
|
|
1332
2106
|
# Escape a shell string into a JSON string body. A raw control character inside a JSON string is a parse
|
|
1333
2107
|
# error, and the reason text is attacker-adjacent (it is the model's own command line), so:
|
|
@@ -1361,6 +2135,232 @@ json_escape(){
|
|
|
1361
2135
|
| sed 's/\\/\\\\/g; s/"/\\"/g' \
|
|
1362
2136
|
| awk 'NR>1{printf "\\n"} {printf "%s", $0}'
|
|
1363
2137
|
}
|
|
2138
|
+
# ---- CREW-APPROVAL-PATH (one definition, carried by prompt-approval.sh and guard-bash.sh; smoke-test pins it) ----
|
|
2139
|
+
_crew_appr_path(){ # $1 = a directory -> _AP: the record's path in that worktree's git directory, "" outside one
|
|
2140
|
+
local d="${1//\\//}" l g
|
|
2141
|
+
_AP=""; d="${d%/}"
|
|
2142
|
+
while :; do
|
|
2143
|
+
if [ -f "$d/.git/HEAD" ]; then _AP="$d/.git/crewforth-approval"; return 0; fi # a git directory, not a folder named .git
|
|
2144
|
+
if [ -f "$d/.git" ]; then # a linked worktree or a submodule: `.git` is a one-line pointer
|
|
2145
|
+
l=""; IFS= read -r l < "$d/.git" || true; l="${l%$'\r'}"
|
|
2146
|
+
case "$l" in "gitdir: "*)
|
|
2147
|
+
g="${l#gitdir: }"; g="${g//\\//}"
|
|
2148
|
+
case "$g" in /*|[A-Za-z]:*) ;; *) g="$d/$g" ;; esac
|
|
2149
|
+
[ -f "$g/HEAD" ] && _AP="$g/crewforth-approval" ;;
|
|
2150
|
+
esac
|
|
2151
|
+
return 0
|
|
2152
|
+
fi
|
|
2153
|
+
case "$d" in */*) d="${d%/*}" ;; *) return 0 ;; esac
|
|
2154
|
+
done
|
|
2155
|
+
}
|
|
2156
|
+
# ---- /CREW-APPROVAL-PATH ---------------------------------------------------------------------------------
|
|
2157
|
+
# §4.4 IN `auto` AND `dontAsk`: THE USER'S OWN MESSAGE IS THE APPROVAL. hooks/prompt-approval.sh records it when the
|
|
2158
|
+
# whole message is `approve: commit` / `push` / `commit+push` (or `onay: …`), with what git reported at that moment.
|
|
2159
|
+
# This reads the record back and answers one question: does it cover THIS call, and nothing else?
|
|
2160
|
+
# commit the index writes the recorded tree and HEAD is the recorded one. A commit moves HEAD, so the same
|
|
2161
|
+
# record cannot allow a second one; a commit that FAILED (a hook refused it) moved nothing and may be retried.
|
|
2162
|
+
# push `git push <remote> <branch>`: the recorded remote, still pushing to the recorded URL, the recorded
|
|
2163
|
+
# branch, HEAD on it. Pushing one commit to one branch twice changes nothing, so this needs no counter.
|
|
2164
|
+
# commit+push the push is checked against the commit the approved index BECAME: its parent is the recorded HEAD
|
|
2165
|
+
# and its tree is the recorded tree.
|
|
2166
|
+
# and in every case the session is the one the user wrote in. The record is never written here. It ends 30 minutes
|
|
2167
|
+
# after the message, or at the user's next message (the other hook empties it). §4.5 ran above and §4.6 runs before
|
|
2168
|
+
# this, so neither is opened by an approval.
|
|
2169
|
+
#
|
|
2170
|
+
# THE TREE, NOT THE DIFF. §4.6 hashes the text of `git diff --cached`, and what that prints is configurable:
|
|
2171
|
+
# `git config diff.external true` made every staged diff print nothing, so every diff got one id (measured in
|
|
2172
|
+
# review; §4.6 now passes --no-ext-diff --no-textconv). The id of the tree the index writes depends on the content
|
|
2173
|
+
# alone, whatever else is configured.
|
|
2174
|
+
#
|
|
2175
|
+
# THE CALL HAS TO BE THE GIT COMMAND AND NOTHING ELSE. Where a person sees the prompt they also see the command; here
|
|
2176
|
+
# nobody does. Each of these was allowed by a matching record in a first version, and each does something the user
|
|
2177
|
+
# did not approve (the rows are in smoke-test):
|
|
2178
|
+
# cd ../other && git commit -m x commits what is staged in another repository
|
|
2179
|
+
# GIT_INDEX_FILE=/tmp/i git commit -m x commits another index than the one that was read
|
|
2180
|
+
# git commit -m "$(git add -A; echo msg)" stages everything, then commits it
|
|
2181
|
+
# git commit -m 'a"' ; touch x ; echo 'b"' two more commands, hidden by pairing the wrong quotes
|
|
2182
|
+
# git commit -m x 2>&1 -a git commit -mxm b.txt the working tree, past §4.6's scan
|
|
2183
|
+
# git commit -n -m x git commit --amen -m x --no-verify and --amend, as git abbreviates them
|
|
2184
|
+
# So the text is read the way the shell reads it, left to right, and anything that cannot be judged is refused:
|
|
2185
|
+
# a single-quoted span is literal; a double-quoted one may hold no `$`, backtick, backslash or `!`; the one
|
|
2186
|
+
# substitution let through is a message read from a here-document whose delimiter is quoted,
|
|
2187
|
+
# `-m "$(cat <<'EOF' … EOF)"`, because its body is literal too. What is left must hold no separator, substitution,
|
|
2188
|
+
# redirection, comment, glob or second line, must begin with `git commit` / `git push`, and its arguments are a
|
|
2189
|
+
# short list: a wrong option is refused here whatever §4.5 and §4.6 make of it.
|
|
2190
|
+
# The PowerShell tool reads quotes by other rules (a backslash escapes nothing there, a backtick does), so for it
|
|
2191
|
+
# only single quotes are accepted.
|
|
2192
|
+
_a44_collapse(){ # $1 = command, $2 = commit|push, $3 = tool -> 0 and _A44_S (quoted spans as Q); otherwise _A44_WHY
|
|
2193
|
+
local s="$1" out="" pre c rest w body
|
|
2194
|
+
_A44_WHY=""; _A44_S=""
|
|
2195
|
+
if [ "$3" != Bash ]; then
|
|
2196
|
+
case "$s" in *[\"\`\\\$@]*) _A44_WHY="outside the Bash tool an approved call uses single quotes only (no double quote, backtick, backslash, \$ or @)"; return 1 ;; esac
|
|
2197
|
+
# ...and plain ASCII on one line. PowerShell reads the typographic quotes (U+2018, U+2019 and their kin) as
|
|
2198
|
+
# single quotes, so `'a ’ ; cmd ; ‘ b'` is one quoted text here and three commands there; and a `\u` escape in the
|
|
2199
|
+
# payload reaches this code as `?`. Neither can be told apart from text inside a quoted span, so both are refused.
|
|
2200
|
+
local LC_ALL=C
|
|
2201
|
+
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
|
|
2202
|
+
fi
|
|
2203
|
+
_gsub "$s" $'\r' ''; s="$_GS"
|
|
2204
|
+
while :; do
|
|
2205
|
+
pre="${s%%[\"\'\\]*}"; out="$out$pre"
|
|
2206
|
+
[ "$pre" = "$s" ] && break
|
|
2207
|
+
c="${s:${#pre}:1}"; s="${s:${#pre}+1}"
|
|
2208
|
+
case "$c" in
|
|
2209
|
+
\\) case "$s" in
|
|
2210
|
+
$'\n'*) out="$out "; s="${s:1}" ;; # a backslash-newline joins two lines
|
|
2211
|
+
*) _A44_WHY="a backslash outside quotes"; return 1 ;;
|
|
2212
|
+
esac ;;
|
|
2213
|
+
\') case "$s" in *\'*) ;; *) _A44_WHY="an unpaired quote"; return 1 ;; esac
|
|
2214
|
+
body="${s%%\'*}"; s="${s:${#body}+1}"; out="${out}Q" ;;
|
|
2215
|
+
*) case "$s" in
|
|
2216
|
+
'$(cat <<'\'*)
|
|
2217
|
+
rest="${s:9}"; w="${rest%%\'*}"
|
|
2218
|
+
case "$w" in ''|*[!A-Za-z0-9_]*) _A44_WHY="a here-document whose delimiter is not a plain quoted word"; return 1 ;; esac
|
|
2219
|
+
rest="${rest:${#w}+1}"
|
|
2220
|
+
case "$rest" in $'\n'*) rest="${rest:1}" ;; *) _A44_WHY="text on the line that opens the here-document"; return 1 ;; esac
|
|
2221
|
+
# The body ends at the FIRST line that is the delimiter, as it does for the shell.
|
|
2222
|
+
case "$rest" in
|
|
2223
|
+
"$w"$'\n'*) rest="${rest:${#w}+1}" ;;
|
|
2224
|
+
*$'\n'"$w"$'\n'*) body="${rest%%$'\n'"$w"$'\n'*}"; rest="${rest:${#body}+${#w}+2}" ;;
|
|
2225
|
+
*) _A44_WHY="a here-document that is not closed on its own line"; return 1 ;;
|
|
2226
|
+
esac
|
|
2227
|
+
while :; do case "$rest" in [$' \t']*) rest="${rest:1}" ;; *) break ;; esac; done
|
|
2228
|
+
case "$rest" in
|
|
2229
|
+
')"'*) s="${rest:2}"; out="${out}Q" ;;
|
|
2230
|
+
*) _A44_WHY="something follows the here-document inside the substitution"; return 1 ;;
|
|
2231
|
+
esac ;;
|
|
2232
|
+
*\"*)
|
|
2233
|
+
body="${s%%\"*}"
|
|
2234
|
+
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
|
|
2235
|
+
s="${s:${#body}+1}"; out="${out}Q" ;;
|
|
2236
|
+
*) _A44_WHY="an unpaired quote"; return 1 ;;
|
|
2237
|
+
esac ;;
|
|
2238
|
+
esac
|
|
2239
|
+
done
|
|
2240
|
+
out="${out//$'\t'/ }"; out="${out// 2>&1/ }"
|
|
2241
|
+
while :; do case "$out" in [$' \n']*) out="${out:1}" ;; *) break ;; esac; done
|
|
2242
|
+
while :; do case "$out" in *[$' \n']) out="${out%?}" ;; *) break ;; esac; done
|
|
2243
|
+
case "$out" in *[$'\n'\;\&\|\`\$\(\)\{\}\<\>\#\*\?\[\]\~\!\%\^]*)
|
|
2244
|
+
_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 ;; esac
|
|
2245
|
+
case "$out" in "git $2"|"git $2 "*|"git.exe $2"|"git.exe $2 "*) ;;
|
|
2246
|
+
*) _A44_WHY="the call does not begin with 'git $2' (a cd, a variable or an option comes first)"; return 1 ;; esac
|
|
2247
|
+
_A44_S="$out"; return 0
|
|
2248
|
+
}
|
|
2249
|
+
_c44_scan(){ # $1 = the collapsed `git commit …` -> 0 when every argument is a message or one of -q -s -v; else _C44_WHY
|
|
2250
|
+
local tok body pre unglob=0
|
|
2251
|
+
_C44_WHY=""
|
|
2252
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
2253
|
+
set -- $1
|
|
2254
|
+
[ "$unglob" = 1 ] && set +f
|
|
2255
|
+
shift 2
|
|
2256
|
+
while [ $# -gt 0 ]; do
|
|
2257
|
+
tok="$1"; shift
|
|
2258
|
+
case "$tok" in
|
|
2259
|
+
--message) [ $# -gt 0 ] || { _C44_WHY="--message with no text"; return 1; }; shift ;;
|
|
2260
|
+
--message=?*|--quiet|--signoff|--verbose) ;;
|
|
2261
|
+
--*) _C44_WHY="the option $tok"; return 1 ;;
|
|
2262
|
+
-?*) # a cluster: -q -s -v in any order, then at most one -m, whose text is the rest of the token or the next one
|
|
2263
|
+
body="${tok#-}"; pre="${body%%m*}"
|
|
2264
|
+
case "$pre" in *[!qsv]*) _C44_WHY="the option $tok"; return 1 ;; esac
|
|
2265
|
+
if [ "$pre" != "$body" ] && [ -z "${body#*m}" ]; then
|
|
2266
|
+
[ $# -gt 0 ] || { _C44_WHY="-m with no text"; return 1; }; shift
|
|
2267
|
+
fi ;;
|
|
2268
|
+
*) _C44_WHY="a path or an argument that is not a message ($tok)"; return 1 ;;
|
|
2269
|
+
esac
|
|
2270
|
+
done
|
|
2271
|
+
return 0
|
|
2272
|
+
}
|
|
2273
|
+
_p44_scan(){ # $1 = the collapsed `git push …` -> 0 when it is `git push [-u] <remote> <ref>`; sets _P44_REMOTE _P44_REF, or _P44_WHY
|
|
2274
|
+
local tok npos=0 unglob=0
|
|
2275
|
+
_P44_REMOTE=""; _P44_REF=""; _P44_WHY=""
|
|
2276
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
2277
|
+
set -- $1
|
|
2278
|
+
[ "$unglob" = 1 ] && set +f
|
|
2279
|
+
shift 2
|
|
2280
|
+
while [ $# -gt 0 ]; do
|
|
2281
|
+
tok="$1"; shift
|
|
2282
|
+
case "$tok" in
|
|
2283
|
+
-u|--set-upstream|-q|--quiet|-v|--verbose|--progress|--no-progress) ;;
|
|
2284
|
+
-*) _P44_WHY="the option $tok"; return 1 ;;
|
|
2285
|
+
*) npos=$((npos+1))
|
|
2286
|
+
case "$npos" in 1) _P44_REMOTE="$tok" ;; 2) _P44_REF="$tok" ;; *) _P44_WHY="more than one refspec"; return 1 ;; esac ;;
|
|
2287
|
+
esac
|
|
2288
|
+
done
|
|
2289
|
+
[ "$npos" = 2 ] || { _P44_WHY="the remote and the branch are not both written out"; return 1; }
|
|
2290
|
+
return 0
|
|
2291
|
+
}
|
|
2292
|
+
_approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise _APW says why not, for the message.
|
|
2293
|
+
local k v op="" tr="" h="" b="" r="" u="" sid="" ts="" now age dir="${_CWD:-.}" hc=1 hp=1 cur tool
|
|
2294
|
+
_APW="no approval from the user is on record"
|
|
2295
|
+
git_has "$CMD_SEEN" 'commit' && hc=0; git_has "$CMD_SEEN" 'push' && hp=0
|
|
2296
|
+
if [ "$hc" = 0 ] && [ "$hp" = 0 ]; then
|
|
2297
|
+
_APW="this call holds a commit and a push; each is checked against the approval on its own, so run them as two calls"; return 1
|
|
2298
|
+
fi
|
|
2299
|
+
_crew_appr_path "$dir"
|
|
2300
|
+
[ -n "$_AP" ] && [ -s "$_AP" ] || return 1
|
|
2301
|
+
while IFS='=' read -r k v || [ -n "$k" ]; do
|
|
2302
|
+
v="${v%$'\r'}"
|
|
2303
|
+
case "$k" in op) op="$v" ;; tree) tr="$v" ;; head) h="$v" ;; branch) b="$v" ;; remote) r="$v" ;; url) u="$v" ;; sid) sid="$v" ;; ts) ts="$v" ;; esac
|
|
2304
|
+
done < "$_AP"
|
|
2305
|
+
case "$ts" in ''|*[!0-9]*) _APW="the approval record could not be read"; return 1 ;; esac
|
|
2306
|
+
if [ "${BASH_VERSINFO[0]}" -ge 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then
|
|
2307
|
+
printf -v now '%(%s)T' -1
|
|
2308
|
+
else now="$(date +%s)"; fi
|
|
2309
|
+
case "$now" in ''|*[!0-9]*) _APW="the clock could not be read"; return 1 ;; esac
|
|
2310
|
+
age=$((now - ts))
|
|
2311
|
+
if [ "$age" -lt 0 ] || [ "$age" -gt 1800 ]; then _APW="the user's approval is more than 30 minutes old"; return 1; fi
|
|
2312
|
+
# The session the user wrote in. A session started from a command here is another one, whatever it is told.
|
|
2313
|
+
_json_slice "$INPUT" session_id >/dev/null
|
|
2314
|
+
if [ -z "$sid" ] || [ "$sid" != "$_JS" ]; then _APW="the approval on record was given in another session"; return 1; fi
|
|
2315
|
+
_json_slice "$INPUT" tool_name >/dev/null; tool="$_JS"
|
|
2316
|
+
if [ "$hc" = 0 ]; then
|
|
2317
|
+
case "$op" in commit|commit+push) ;; *) _APW="the approval on record is for a push, not for a commit"; return 1 ;; esac
|
|
2318
|
+
_a44_collapse "$CMD" commit "$tool" || { _APW="$_A44_WHY. Run the commit alone: git commit -m '…'"; return 1; }
|
|
2319
|
+
_c44_scan "$_A44_S" || { _APW="an approved commit takes its message and -q, -s or -v, nothing else ($_C44_WHY)"; return 1; }
|
|
2320
|
+
cur="$(git -C "$dir" write-tree 2>/dev/null)"
|
|
2321
|
+
# HAVE_H is §4.6's own reading of HEAD, taken a few lines above for this call.
|
|
2322
|
+
if [ -z "$tr" ] || [ "$tr" != "$cur" ] || [ "$h" != "${HAVE_H:-}" ]; then
|
|
2323
|
+
_APW="what is staged, or HEAD, is not what the user approved (approved tree ${tr:0:7} on ${h:0:7}; now ${cur:0:7} on ${HAVE_H:0:7})"; return 1
|
|
2324
|
+
fi
|
|
2325
|
+
return 0
|
|
2326
|
+
fi
|
|
2327
|
+
case "$op" in push|commit+push) ;; *) _APW="the approval on record is for a commit, not for a push"; return 1 ;; esac
|
|
2328
|
+
# No quoted argument in a push: a quoted span is compared as a placeholder, and a branch may be named like one.
|
|
2329
|
+
case "$CMD" in *[\"\']*) _APW="a quote in the push command. Run the push alone and unquoted: git push <remote> <branch>"; return 1 ;; esac
|
|
2330
|
+
_a44_collapse "$CMD" push "$tool" || { _APW="$_A44_WHY. Run the push alone: git push <remote> <branch>"; return 1; }
|
|
2331
|
+
_p44_scan "$_A44_S" || { _APW="the push is not in the one form an approval covers, git push <remote> <branch> ($_P44_WHY)"; return 1; }
|
|
2332
|
+
[ -n "$b" ] && [ -n "$r" ] && [ -n "$u" ] || { _APW="the approval record could not be read"; return 1; }
|
|
2333
|
+
if [ "$_P44_REMOTE" != "$r" ]; then _APW="the user approved a push to $r, not to $_P44_REMOTE"; return 1; fi
|
|
2334
|
+
case "$_P44_REF" in "$b"|HEAD) ;; *) _APW="the user approved a push of $b, not of $_P44_REF"; return 1 ;; esac
|
|
2335
|
+
cur="$(git -C "$dir" symbolic-ref --short -q HEAD 2>/dev/null || true)"
|
|
2336
|
+
[ "$cur" = "$b" ] || { _APW="the user approved a push of $b, and the branch checked out now is ${cur:-<detached>}"; return 1; }
|
|
2337
|
+
# Where the remote pushes TO, with pushurl and pushInsteadOf applied: the name alone can be pointed elsewhere.
|
|
2338
|
+
cur="$(git -C "$dir" remote get-url --push "$r" 2>/dev/null)"
|
|
2339
|
+
[ "$cur" = "$u" ] || { _APW="the remote $r does not push to the address it pushed to when the user approved"; return 1; }
|
|
2340
|
+
# ...and to the branch of the same NAME. `git push origin feat/x` names no destination, and with a
|
|
2341
|
+
# `remote.origin.push` mapping git picks it from there: measured in review, `feat/x -> main`.
|
|
2342
|
+
cur="$(git -C "$dir" config --get-all "remote.$r.push" 2>/dev/null || true)"
|
|
2343
|
+
[ -z "$cur" ] || { _APW="the remote $r has a push mapping (remote.$r.push), so the branch this push lands on is not the one named"; return 1; }
|
|
2344
|
+
cur="$(git -C "$dir" rev-parse --verify --quiet HEAD 2>/dev/null || echo NONE)"
|
|
2345
|
+
if [ "$op" = push ]; then
|
|
2346
|
+
[ "$cur" = "$h" ] || { _APW="HEAD moved after the user approved the push (approved ${h:0:7}, now ${cur:0:7})"; return 1; }
|
|
2347
|
+
return 0
|
|
2348
|
+
fi
|
|
2349
|
+
# commit+push: HEAD has to be the commit the approved index became.
|
|
2350
|
+
cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^' 2>/dev/null || echo NONE)"
|
|
2351
|
+
if [ "$h" = NONE ] || [ "$cur" != "$h" ]; then
|
|
2352
|
+
_APW="HEAD is not the commit the user approved (its parent is ${cur:0:7}, the approval was given on ${h:0:7})"; return 1
|
|
2353
|
+
fi
|
|
2354
|
+
cur="$(git -C "$dir" rev-parse --verify --quiet 'HEAD^{tree}' 2>/dev/null)"
|
|
2355
|
+
[ -n "$tr" ] && [ "$cur" = "$tr" ] || { _APW="the commit on HEAD does not carry what the user approved"; return 1; }
|
|
2356
|
+
return 0
|
|
2357
|
+
}
|
|
2358
|
+
allow_approved(){
|
|
2359
|
+
gatelog ALLOW 4.4 "the user's own approval message covers this command"
|
|
2360
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
|
|
2361
|
+
"$(json_escape "§4.4: the user approved this in their own message, and what was approved is what this command does")"
|
|
2362
|
+
exit 0
|
|
2363
|
+
}
|
|
1364
2364
|
# Escalate to a permission prompt only the user can answer, then let Claude run the command itself.
|
|
1365
2365
|
ask_user(){
|
|
1366
2366
|
gatelog ASK 4.4 "commit/push approval prompt"
|
|
@@ -1391,7 +2391,402 @@ allow_preauthorised(){
|
|
|
1391
2391
|
# one and never runs when the key is set. A pre-authorised session commits WITHOUT a review record. The
|
|
1392
2392
|
# payload CLAUDE.md §4.6 states it ("Deliberate skip: … CLAUDE_GIT_OK (headless/CI) bypasses this too")
|
|
1393
2393
|
# and it is written here as well, because the person reading the hook is not reading that file.
|
|
1394
|
-
|
|
2394
|
+
# §4.5 / §4.6 — WHAT A `git commit` REALLY CARRIES, READ THE WAY THE SHELL AND GIT READ IT.
|
|
2395
|
+
# The rules above and the scan of §4.6 below judge the command as text: a literal `--no-verify`, a literal `--amend`,
|
|
2396
|
+
# a token walk that pairs every double quote before any single one and stops at the first `&`. A review of the
|
|
2397
|
+
# approval route (3.1.0) measured what that lets through in the modes where the gate ASKS, and with CLAUDE_GIT_OK
|
|
2398
|
+
# where nobody is asked at all — each line below reached the prompt with no rule firing, and each was run for real:
|
|
2399
|
+
# git commit -n -m x --no-verif / --no-veri hooks skipped (-n IS --no-verify; git takes abbreviations)
|
|
2400
|
+
# git commit --amen -m x --am the last commit rewritten
|
|
2401
|
+
# git commit -m x 2>&1 -a &> log -a >& log -a the working tree committed (the walk stopped at `&`)
|
|
2402
|
+
# git commit -mxm b.txt b.txt committed (-m took `xm`; the walk swallowed b.txt)
|
|
2403
|
+
# git commit -m 'a"' -a ; echo 'b"' -a hidden inside quotes paired the wrong way
|
|
2404
|
+
# git commit -F - <<END -a -a after a here-document operator
|
|
2405
|
+
# git commit -m a\;b -a an escaped `;` read as a separator
|
|
2406
|
+
# bash -c 'git commit -am x' eval "git commit -a …" a commit this gate cannot read at all
|
|
2407
|
+
# cd ../other && git commit … pushd … env -C … another repository than the one the record is for
|
|
2408
|
+
# GIT_INDEX_FILE=… git commit GIT_DIR=… export GIT_… another index, another repository
|
|
2409
|
+
# GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath … the hooks switched off through the environment
|
|
2410
|
+
# So the command is read left to right: quotes as the shell pairs them, an escaped character as a character, a
|
|
2411
|
+
# here-document's body as data, `2>&1` / `&>` / `>&` as redirections and not as separators. Then every `git commit`
|
|
2412
|
+
# in the call is read with git's own option table: a cluster letter by letter, a value where git takes one, a long
|
|
2413
|
+
# option only when it is written out in full.
|
|
2414
|
+
# THIS ONLY EVER ADDS A REFUSAL. The older rules and `_c46_scan` still run and still decide; nothing they refuse is
|
|
2415
|
+
# let through here. An option this table does not know is refused, with the reason: an abbreviation is how the nine
|
|
2416
|
+
# lines above got in, and guessing which option it stands for is the same mistake again.
|
|
2417
|
+
# _c47_read and _c47_w, which read it, stand above the gate-file rule: that rule reads a call with them too.
|
|
2418
|
+
_c47_exp(){ # $1 = a raw token of a commit, $2 = the word it reads as -> sets _C47_EXP when the shell would change it
|
|
2419
|
+
# What the shell will make of an argument has to be readable. An unquoted `$NAME` is, when NAME was given one
|
|
2420
|
+
# literal word earlier in the call; a glob, a brace list, or any other expansion is not: measured,
|
|
2421
|
+
# `o=' -a'; git commit -m x$o`, `git commit -m {x,-a}` and `git commit -m ?.txt` all committed the working tree.
|
|
2422
|
+
local v="$1" m="$_C47_M" bad="${_WAT:-0}" c
|
|
2423
|
+
while :; do case "$v" in *"$m"*) v="${v%%"$m"*}${v#*"$m"*"$m"}" ;; *) break ;; esac; done # the unquoted part
|
|
2424
|
+
case "$v" in *[\*\?\[\{]*) bad=1 ;; esac
|
|
2425
|
+
while [ "$bad" = 0 ]; do
|
|
2426
|
+
case "$v" in *\$*) ;; *) break ;; esac
|
|
2427
|
+
v="${v#*\$}"; c="${v%%[!A-Za-z0-9_]*}"
|
|
2428
|
+
case "$_C47_VARS" in *" $c "*) [ -n "$c" ] || bad=1 ;; *) bad=1 ;; esac
|
|
2429
|
+
done
|
|
2430
|
+
[ "$bad" = 1 ] && _C47_EXP="${2:0:60}"
|
|
2431
|
+
return 0
|
|
2432
|
+
}
|
|
2433
|
+
_c47_val(){ # $1 = the raw token an option takes as its value: the same question
|
|
2434
|
+
case "$1" in *[\$\*\?\[\{]*|*"$_C47_M"*) _c47_w "$1"; [ "$_WX" = 1 ] && _c47_exp "$1" "$_W" ;; esac
|
|
2435
|
+
return 0
|
|
2436
|
+
}
|
|
2437
|
+
_c47_args(){ # $1 = 1: the words after `git` (Start-Process git …) | 0: all of them (a command word held in a variable)
|
|
2438
|
+
# $2… = the tokens up to the next separator. Appends one line to _C47_ARGS: those words written as the
|
|
2439
|
+
# git command they are, `git push --force origin main`, so the rules that judge a git command can read it.
|
|
2440
|
+
# PowerShell hands the arguments over as a list ('push','--force') or as one string ("push --force"):
|
|
2441
|
+
# both come out as the same words. Start-Process's own parameters are left out; they are not git's.
|
|
2442
|
+
local v w out="" on=1 nc=0
|
|
2443
|
+
[ "$1" = 1 ] && on=0
|
|
2444
|
+
shift
|
|
2445
|
+
shopt -q nocasematch && nc=1; shopt -s nocasematch
|
|
2446
|
+
for v in "$@"; do
|
|
2447
|
+
[ "$v" = ";" ] && break
|
|
2448
|
+
_c47_w "$v"; w="${_W//,/ }"
|
|
2449
|
+
if [ "$on" = 0 ]; then case "$w" in git|git.exe) on=1 ;; esac; continue; fi
|
|
2450
|
+
[ "$_WQ" = 0 ] && case "$w" in
|
|
2451
|
+
-FilePath|-ArgumentList|-Args|-Credential|-WorkingDirectory|-LoadUserProfile|-NoNewWindow|-PassThru|-RedirectStandardError|-RedirectStandardInput|-RedirectStandardOutput|-WindowStyle|-Wait|-UseNewEnvironment|-Verb|-Confirm*|-WhatIf) continue ;;
|
|
2452
|
+
esac
|
|
2453
|
+
out="$out $w"
|
|
2454
|
+
done
|
|
2455
|
+
[ "$nc" = 0 ] && shopt -u nocasematch
|
|
2456
|
+
[ "$on" = 1 ] && [ -n "$out" ] && _C47_ARGS="$_C47_ARGS${_C47_ARGS:+$'\n'}git$out"
|
|
2457
|
+
return 0
|
|
2458
|
+
}
|
|
2459
|
+
_c47_scan(){ # $1 = command. Sets: _C47_N (commits read) _C47_WT _C47_NV _C47_AM _C47_UNK _C47_EXP _C47_ENV _C47_SH _C47_CFG
|
|
2460
|
+
# _C47_CD (0 none · 1 one plain target, in _C47_CDT · 2 a target that cannot be read)
|
|
2461
|
+
local tok ph=cmd unglob=0 body c v m i raw rcd=0 rcdt="" envf=0 wrapped=0 novars=0 vc=0
|
|
2462
|
+
_C47_VARS=" "
|
|
2463
|
+
_C47_SHRE='(^|[^A-Za-z0-9_-])git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*commit([[:space:]]|$)'
|
|
2464
|
+
_C47_N=0; _C47_WT=""; _C47_NV=""; _C47_AM=""; _C47_UNK=""; _C47_EXP=""; _C47_ENV=""; _C47_SH=""; _C47_CFG=""; _C47_UNR=""; _C47_SUB=""; _C47_ARGS=""; _C47_CD=0; _C47_CDT=""
|
|
2465
|
+
_c47_read "$1"; m="$_C47_M"
|
|
2466
|
+
case "$-" in *f*) ;; *) unglob=1; set -f ;; esac
|
|
2467
|
+
set -- $_C47_T
|
|
2468
|
+
[ "$unglob" = 1 ] && set +f
|
|
2469
|
+
while [ $# -gt 0 ]; do
|
|
2470
|
+
raw="$1"; shift
|
|
2471
|
+
[ "$raw" = ";" ] && { ph=cmd; wrapped=0; vc=0; continue; }
|
|
2472
|
+
_WAT=0
|
|
2473
|
+
case "$raw" in "$m"*|*"$m"*) _c47_w "$raw" ;; *) _W="$raw"; _WQ=0; case "$raw" in *[\$\*\?\[\{]*) _WX=1 ;; *) _WX=0 ;; esac ;; esac
|
|
2474
|
+
tok="$_W"
|
|
2475
|
+
# THE VARIABLES THAT CHANGE WHAT GIT READS — the repository, the index, the object store, the configuration —
|
|
2476
|
+
# wherever in the call they are set or exported: in front of the commit, in an earlier command, as an argument
|
|
2477
|
+
# of export or env, quoted or not. HOME and XDG_CONFIG_HOME are among them: they decide which global
|
|
2478
|
+
# configuration git reads. GIT_AUTHOR_* / GIT_COMMITTER_* only describe the commit and are let through.
|
|
2479
|
+
v="$tok"; case "$v" in '$'[Ee][Nn][Vv]:*) v="${v:5}" ;; esac # PowerShell: $env:GIT_DIR='…'
|
|
2480
|
+
case "${v%%=*}" in
|
|
2481
|
+
GIT_DIR|GIT_WORK_TREE|GIT_INDEX_FILE|GIT_COMMON_DIR|GIT_OBJECT_DIRECTORY|GIT_ALTERNATE_OBJECT_DIRECTORIES|GIT_NAMESPACE|GIT_EXEC_PATH|GIT_CONFIG*)
|
|
2482
|
+
[ "$ph" = commit ] || envf=1 ;; # named (export GIT_DIR) or assigned
|
|
2483
|
+
HOME|XDG_CONFIG_HOME)
|
|
2484
|
+
[ "$ph" = commit ] || case "$v" in *=*) envf=1 ;; esac ;; # only when assigned
|
|
2485
|
+
esac
|
|
2486
|
+
case "$ph" in
|
|
2487
|
+
skip) # Not a command this table knows. A bare `git` further along the same command is still git (timeout 10 git …,
|
|
2488
|
+
# xargs git …, nice git …), so the reading goes on from there.
|
|
2489
|
+
case "$tok" in git|git.exe|*/git|*/git.exe) [ "$_WX" = 0 ] && ph=git ;; esac
|
|
2490
|
+
# The command word was a variable (`$g commit -n`, PowerShell's `& $g commit -n`): what runs cannot be read.
|
|
2491
|
+
[ "$vc" = 1 ] && [ "$tok" = commit ] && _C47_UNR="a command held in a variable, followed by commit"
|
|
2492
|
+
continue ;;
|
|
2493
|
+
cmd)
|
|
2494
|
+
case "$tok" in
|
|
2495
|
+
[A-Za-z_]*=*|[A-Za-z_]*+=*)
|
|
2496
|
+
v="${tok%%=*}"; v="${v%+}"
|
|
2497
|
+
case "$v" in *[!A-Za-z0-9_]*) ph=skip ;; *)
|
|
2498
|
+
# A plain assignment. Its name is remembered when the value is one literal word: only then may a later
|
|
2499
|
+
# `$NAME` stand inside a commit argument (the shell cannot split or expand it into something else).
|
|
2500
|
+
# IFS changes how every one of them is split, and after `read`, `unset`, `export` and
|
|
2501
|
+
# their kin (below) a name no longer holds what it was given: all of those forget instead of remember.
|
|
2502
|
+
c="${tok#*=}"
|
|
2503
|
+
[ "$v" = IFS ] && novars=1
|
|
2504
|
+
case "$novars$_WX$c" in 1*|01*|*[$' \t\n']*|*[\*\?\[\{\$\`]*|00) _C47_VARS="${_C47_VARS/ $v / }" ;; *) _C47_VARS="$_C47_VARS$v " ;; esac
|
|
2505
|
+
[ "$novars" = 1 ] && _C47_VARS=" " ;;
|
|
2506
|
+
esac ;;
|
|
2507
|
+
'{'|'}'|'!'|if|then|else|elif|fi|do|done|while|until|time|sudo|command|builtin|exec|nohup|nice) wrapped=1 ;; # the command follows
|
|
2508
|
+
-*) [ "$wrapped" = 1 ] || ph=skip ;; # an option of that wrapper
|
|
2509
|
+
export|declare|typeset|readonly|local|read|unset|let|source|.|for|select|getopts|mapfile|readarray) novars=1; _C47_VARS=" "; ph=skip ;;
|
|
2510
|
+
set|shift) ph=skip ;;
|
|
2511
|
+
printf) case " $* " in *" -v "*) novars=1; _C47_VARS=" " ;; esac; ph=skip ;;
|
|
2512
|
+
env) ph=env ;;
|
|
2513
|
+
cd|pushd|chdir|Set-Location|sl|Push-Location)
|
|
2514
|
+
# Where the commit will run. One plain target can be resolved and compared; anything else cannot.
|
|
2515
|
+
while :; do case "${1:-}" in -[!-]*|--) shift ;; *) break ;; esac; done
|
|
2516
|
+
v="${1:-}"
|
|
2517
|
+
case "$v" in ''|\;) v="" ;; *) _c47_w "$v"; v="$_W"; [ "$_WX" = 1 ] && v=""; [ "$v" = - ] && v="" ;; esac
|
|
2518
|
+
# `cd "$(git rev-parse --show-toplevel)" && git commit …` goes to the top of THIS repository: no change.
|
|
2519
|
+
[ "$_W" = '$(git rev-parse --show-toplevel)' ] && { ph=skip; continue; }
|
|
2520
|
+
if [ -n "$v" ] && [ "$rcd" = 0 ]; then rcd=1; rcdt="$v"; else rcd=2; fi
|
|
2521
|
+
ph=skip ;;
|
|
2522
|
+
Start-Process|saps|start)
|
|
2523
|
+
# PowerShell: `Start-Process git -ArgumentList 'commit','-n','-m','x'` (measured: committed, hooks skipped).
|
|
2524
|
+
c=0
|
|
2525
|
+
for v in "$@"; do
|
|
2526
|
+
[ "$v" = ";" ] && break
|
|
2527
|
+
_c47_w "$v"; case "$_W" in git|git.exe) c=1 ;; *commit*) [ "$c" = 1 ] && _C47_SH="$tok" ;; esac
|
|
2528
|
+
done
|
|
2529
|
+
_c47_args 1 "$@"
|
|
2530
|
+
ph=skip ;;
|
|
2531
|
+
bash|sh|zsh|dash|ksh|eval|xargs|trap|pwsh|powershell|powershell.exe|pwsh.exe|cmd|cmd.exe|Invoke-Expression|iex|*/bash|*/sh|*/zsh)
|
|
2532
|
+
# A shell handed a QUOTED script that holds a commit: `bash -c 'git commit -am x'`, `eval "git commit -a"`.
|
|
2533
|
+
# Its arguments cannot be read from here. `bash build.sh` beside a here-document that merely mentions
|
|
2534
|
+
# git commit is not that, and a first version that looked only at the word `bash` refused 40 such calls.
|
|
2535
|
+
for v in "$@"; do
|
|
2536
|
+
# Invoke-Expression takes an expression: `Invoke-Expression ('git commit -' + 'n -m x')` puts the script
|
|
2537
|
+
# behind a parenthesis, which reads as a separator here, so for it the look goes on to the end of the call.
|
|
2538
|
+
[ "$v" = ";" ] && { case "$tok" in Invoke-Expression|iex) continue ;; esac; break; }
|
|
2539
|
+
case "$v" in *"$m"*) _c47_w "$v"; [[ $_W =~ $_C47_SHRE ]] && _C47_SH="$tok" ;; esac
|
|
2540
|
+
done
|
|
2541
|
+
case "$tok" in eval|xargs) wrapped=1 ;; *) ph=skip ;; esac ;; # after eval / xargs the command is read on
|
|
2542
|
+
git|git.exe|*/git|*/git.exe) ph=git ;;
|
|
2543
|
+
*) [ "$_WX" = 1 ] && { vc=1; _c47_args 0 "$@"; }; ph=skip ;;
|
|
2544
|
+
esac ;;
|
|
2545
|
+
env)
|
|
2546
|
+
case "$tok" in
|
|
2547
|
+
-C|--chdir) rcd=2; shift ;;
|
|
2548
|
+
--chdir=*|-C?*) rcd=2 ;;
|
|
2549
|
+
-u|--unset|-S|--split-string) shift ;;
|
|
2550
|
+
-*|[A-Za-z_]*=*) ;;
|
|
2551
|
+
git|git.exe|*/git|*/git.exe) ph=git ;;
|
|
2552
|
+
*) ph=skip ;;
|
|
2553
|
+
esac ;;
|
|
2554
|
+
git)
|
|
2555
|
+
case "$tok" in
|
|
2556
|
+
-c|--config-env)
|
|
2557
|
+
# A setting for this one command. The ones that change what a commit does are refused whatever the
|
|
2558
|
+
# quoting: `-c 'core.hooksPath=/dev/null'`, `-c include.path=f`, `-c alias.ci='commit -a'`.
|
|
2559
|
+
v="${1:-}"; [ $# -gt 0 ] && shift
|
|
2560
|
+
_c47_w "$v"
|
|
2561
|
+
if [ "$_WX" = 1 ]; then _C47_CFG="a -c setting the shell expands first"
|
|
2562
|
+
else
|
|
2563
|
+
shopt -q nocasematch && i=1 || i=0; shopt -s nocasematch
|
|
2564
|
+
case "$_W" in *hookspath*|include.*|includeif.*|alias.*|core.worktree*|core.fsmonitor*) _C47_CFG="-c ${_W%%=*}" ;; esac
|
|
2565
|
+
[ "$i" = 0 ] && shopt -u nocasematch
|
|
2566
|
+
fi ;;
|
|
2567
|
+
-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix) shift ;;
|
|
2568
|
+
-*) ;;
|
|
2569
|
+
@*|*\$*) # (a backtick arrives here as `$`: the reader writes one for the substitution it opens)
|
|
2570
|
+
# The subcommand is filled in by the shell: `git $c -n` after c=commit, PowerShell's splat `git @a`
|
|
2571
|
+
# after $a = @('commit','-n') (measured on PowerShell 5.1: both committed with the hooks skipped) —
|
|
2572
|
+
# and a subcommand with an expansion INSIDE it, quoted or not: `git com${z}mit`, `git "$c"`,
|
|
2573
|
+
# `git $(printf com)mit`, `git pu${z}sh --force`. No rule that looks for a git command by its
|
|
2574
|
+
# name sees these (measured: each passed every gate, and git ran the command).
|
|
2575
|
+
_C47_SUB="${tok:0:40}"; ph=skip ;;
|
|
2576
|
+
commit) ph=commit; _C47_N=$((_C47_N+1))
|
|
2577
|
+
if [ "$rcd" != 0 ] && [ "$_C47_CD" = 0 ]; then _C47_CD=$rcd; _C47_CDT="$rcdt"; fi ;;
|
|
2578
|
+
*) ph=skip ;;
|
|
2579
|
+
esac ;;
|
|
2580
|
+
commit)
|
|
2581
|
+
case "$raw" in
|
|
2582
|
+
'<<H') continue ;; # a here-document; arguments may follow it
|
|
2583
|
+
'<<<') shift; continue ;;
|
|
2584
|
+
[\<\>]*|[0-9][\<\>]*|[0-9][0-9][\<\>]*|'&>'*) case "$raw" in *[\<\>\&]) case "${1:-}" in ''|\;) ;; *) shift ;; esac ;; esac; continue ;;
|
|
2585
|
+
esac
|
|
2586
|
+
[ "$_WX" = 1 ] && _c47_exp "$raw" "$tok"
|
|
2587
|
+
case "$tok" in
|
|
2588
|
+
--) case "${1:-}" in ''|\;) ;; *) _C47_WT="a pathspec after --" ;; esac; ph=skip ;;
|
|
2589
|
+
--message|--file|--author|--date|--reedit-message|--reuse-message|--fixup|--squash|--cleanup|--template|--trailer|--unified|--inter-hunk-context)
|
|
2590
|
+
case "${1:-}" in ''|\;) ;; *) _c47_val "$1"; shift ;; esac ;;
|
|
2591
|
+
--message=*|--file=*|--author=*|--date=*|--reedit-message=*|--reuse-message=*|--fixup=*|--squash=*|--cleanup=*|--template=*|--trailer=*|--unified=*|--inter-hunk-context=*|--gpg-sign|--gpg-sign=*|--untracked-files|--untracked-files=*) ;;
|
|
2592
|
+
--signoff|--quiet|--verbose|--dry-run|--short|--branch|--porcelain|--long|--null|--edit|--allow-empty|--allow-empty-message|--post-rewrite|--status|--reset-author|--ahead-behind|--verify|--pathspec-file-nul|--help) ;;
|
|
2593
|
+
--no-signoff|--no-quiet|--no-verbose|--no-dry-run|--no-short|--no-branch|--no-porcelain|--no-long|--no-null|--no-edit|--no-allow-empty|--no-allow-empty-message|--no-post-rewrite|--no-status|--no-reset-author|--no-ahead-behind|--no-all|--no-only|--no-include|--no-interactive|--no-patch|--no-amend|--no-untracked-files) ;;
|
|
2594
|
+
--all) _C47_WT="--all" ;;
|
|
2595
|
+
--only) _C47_WT="--only" ;;
|
|
2596
|
+
--include) _C47_WT="--include" ;;
|
|
2597
|
+
--interactive|--patch) _C47_WT="$tok" ;;
|
|
2598
|
+
--pathspec-from-file|--pathspec-from-file=*) _C47_WT="--pathspec-from-file" ;;
|
|
2599
|
+
--no-verify) _C47_NV="--no-verify" ;;
|
|
2600
|
+
--amend) _C47_AM="--amend" ;;
|
|
2601
|
+
--no-gpg-sign) ;; # §4.5 above refuses it by name
|
|
2602
|
+
--*) # Not an option git commit has under that exact name: an abbreviation, or something newer than this table.
|
|
2603
|
+
case "--no-verify" in "$tok"*) _C47_NV="$tok (git reads it as --no-verify)" ;; esac
|
|
2604
|
+
case "--amend" in "$tok"*) _C47_AM="$tok (git reads it as --amend)" ;; esac
|
|
2605
|
+
_C47_UNK="$tok" ;;
|
|
2606
|
+
-?*) # A cluster. Letter by letter, as git does: a flag, or a letter that takes a value — the rest of the
|
|
2607
|
+
# token when there is a rest, the next token when there is none (-m -F -t -C -c -U), never the next one
|
|
2608
|
+
# for -S and -u, whose value is optional and attached.
|
|
2609
|
+
body="${tok#-}"
|
|
2610
|
+
while [ -n "$body" ]; do
|
|
2611
|
+
c="${body:0:1}"; body="${body:1}"
|
|
2612
|
+
case "$c" in
|
|
2613
|
+
q|v|s|e|z|h) ;;
|
|
2614
|
+
n) _C47_NV="-n in ${tok:0:40} (git reads it as --no-verify)" ;;
|
|
2615
|
+
a) _C47_WT="-a in ${tok:0:40}" ;;
|
|
2616
|
+
o) _C47_WT="-o in ${tok:0:40}" ;;
|
|
2617
|
+
i) _C47_WT="-i in ${tok:0:40}" ;;
|
|
2618
|
+
p) _C47_WT="-p in ${tok:0:40}" ;;
|
|
2619
|
+
m|F|t|C|c|U) [ -z "$body" ] && case "${1:-}" in ''|\;) ;; *) _c47_val "$1"; shift ;; esac; body="" ;;
|
|
2620
|
+
S|u) body="" ;;
|
|
2621
|
+
*) _C47_UNK="${tok:0:40}"; body="" ;;
|
|
2622
|
+
esac
|
|
2623
|
+
done ;;
|
|
2624
|
+
*) _C47_WT="a pathspec" ;;
|
|
2625
|
+
esac ;;
|
|
2626
|
+
esac
|
|
2627
|
+
done
|
|
2628
|
+
[ "$envf" = 1 ] && [ "$_C47_N" != 0 ] && _C47_ENV="a variable that changes what git reads"
|
|
2629
|
+
return 0
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
# The §4.5 half of what that reading finds is judged HERE, before the pre-authorised branch below: with CLAUDE_GIT_OK
|
|
2633
|
+
# nobody is asked, so a commit that skips its hooks or rewrites the last commit must not reach that allow. (§4.6 is
|
|
2634
|
+
# not judged for such a session, by design: it commits without a review record.)
|
|
2635
|
+
# The reading runs for a commit this hook recognises — and for a call that merely holds the words `git` and `commit`,
|
|
2636
|
+
# because `'git' commit -n`, `git 'commit' -n` and `git -c alias.ci=commit ci -n` are commits the recognition above
|
|
2637
|
+
# (a pattern on the text) does not see at all: measured, each ran with every commit gate silent.
|
|
2638
|
+
_C47_N=0; _C47_WT=""; _C47_CD=0; _C47_SUB=""; _c47_seen=0
|
|
2639
|
+
_C47_MAX=32768
|
|
2640
|
+
_cmd_bytes(){ local LC_ALL=C; _CB=${#1}; } # $1 = text -> _CB: its length in bytes, whatever the session's locale
|
|
2641
|
+
if git_has "$CMD_SEEN" 'commit'; then _c47_seen=1; fi
|
|
2642
|
+
_c47_try=0
|
|
2643
|
+
case "$CMD_SEEN" in *[Mm][Ii][Tt]*) # with quotes, backslashes and joined lines taken out: `g\it`, `com\<newline>mit`
|
|
2644
|
+
_gsub "$CMD_SEEN" '\\'$'\n' '' bs; _unquoted "$_GS"; _t="$_GS"
|
|
2645
|
+
# Both words, in either order: `$a = @('commit','-n'); git @a` names commit first.
|
|
2646
|
+
case "$_t" in *[Gg][Ii][Tt]*) case "$_t" in *[Cc][Oo][Mm][Mm][Ii][Tt]*) _c47_try=1 ;; esac ;; esac ;;
|
|
2647
|
+
esac
|
|
2648
|
+
# The same reading for a git call whose words the shell fills in: a `git` with a `$` or a backtick after it. Which
|
|
2649
|
+
# word that is — the subcommand, or an ordinary argument such as `-C "$dir"` — only the reading can tell.
|
|
2650
|
+
_c47_sub=0
|
|
2651
|
+
case "$CMD" in *[Gg][Ii][Tt]*[\$\`]*) _c47_sub=1 ;; esac
|
|
2652
|
+
_c47_no(){ # the lines for the session; the rule is logged by the caller, by its literal name
|
|
2653
|
+
echo "GUARD (§4.5): $1" >&2; shift
|
|
2654
|
+
while [ $# -gt 0 ]; do echo "$1" >&2; shift; done
|
|
2655
|
+
exit 2
|
|
2656
|
+
}
|
|
2657
|
+
# PowerShell can start a program without writing it as a command: [Diagnostics.Process]::Start('git','commit -n -m x'),
|
|
2658
|
+
# or a ProcessStartInfo handed to it. The arguments are one string that no rule here reads as a git command
|
|
2659
|
+
# (measured on Windows: the gate was silent). So git and that class in one PowerShell call are refused; the class with
|
|
2660
|
+
# any other program, and git as a command, are not touched. Through the PowerShell tool only: in a Bash call the two
|
|
2661
|
+
# words together were, on 4986 real commands, a here-document that mentions them (2 of 2).
|
|
2662
|
+
case "$INPUT" in *'"tool_name":"PowerShell"'*|*'"tool_name": "PowerShell"'*) _c47_ps=1 ;; *) _c47_ps=0 ;; esac
|
|
2663
|
+
[ "$_c47_ps" = 1 ] && case "$CMD_UQ" in *[Pp][Rr][Oo][Cc][Ee][Ss][Ss]*)
|
|
2664
|
+
if _ere i "$CMD_UQ" '(diagnostics\.process|\[process(startinfo)?\])' '*' \
|
|
2665
|
+
&& _ere i "$CMD_UQ" '(^|[^A-Za-z0-9_-])git(\.exe)?([^A-Za-z0-9_-]|$)' '*[Gg][Ii][Tt]*'; then
|
|
2666
|
+
gatelog BLOCK 4.5 "git started through System.Diagnostics.Process"
|
|
2667
|
+
_c47_no \
|
|
2668
|
+
"this call starts git through System.Diagnostics.Process, where its arguments are a string no rule reads." \
|
|
2669
|
+
"Run git as a command, so the gate can see which one it is: git commit …, git push …"
|
|
2670
|
+
fi ;;
|
|
2671
|
+
esac
|
|
2672
|
+
# PowerShell can also hand git its arguments instead of writing them after it: Start-Process git -ArgumentList
|
|
2673
|
+
# 'push','--force', or a command word held in a variable (`& $g push --force`). A commit written that way is refused
|
|
2674
|
+
# below; push --force, reset --hard and clean -f were not read at all (measured: 21 of 23 such forms passed; the two
|
|
2675
|
+
# that did not hold core.hooksPath, which its own rule finds as one word wherever it stands). So a PowerShell call
|
|
2676
|
+
# that holds one of those three words is read too, and its arguments are judged as the git command they make.
|
|
2677
|
+
_c47_psd=0
|
|
2678
|
+
[ "$_c47_ps" = 1 ] && case "$CMD_UQ" in *[Pp][Uu][Ss][Hh]*|*[Rr][Ee][Ss][Ee][Tt]*|*[Cc][Ll][Ee][Aa][Nn]*) _c47_psd=1 ;; esac
|
|
2679
|
+
if [ "$_c47_seen" = 1 ] || [ "$_c47_try" = 1 ] || [ "$_c47_sub" = 1 ] || [ "$_c47_psd" = 1 ]; then
|
|
2680
|
+
# A SIZE THIS READING IS NOT ASKED TO EXCEED. A PreToolUse hook that reaches its timeout (600 s) stops nothing, and
|
|
2681
|
+
# the reading below costs the square of the size for some shapes — measured on macOS, a commit followed by `2>&1`
|
|
2682
|
+
# repeated: 16 KB 10 s, 32 KB 39 s, 64 KB 154 s, so about 128 KB is where the timeout is. A command without a commit
|
|
2683
|
+
# is not read this way (64 KB of the same shape: 0.4 s) and is not limited. The limit is where the worst measured
|
|
2684
|
+
# shape is 15 times under the timeout; of 12387 distinct real commands the largest is 31639 bytes, the largest that
|
|
2685
|
+
# holds a commit 28880.
|
|
2686
|
+
_cmd_bytes "$CMD"
|
|
2687
|
+
if [ "$_CB" -gt "$_C47_MAX" ] && { [ "$_c47_seen" = 1 ] || [ "$_c47_try" = 1 ]; }; then
|
|
2688
|
+
gatelog BLOCK 4.5 "git commit in a command too large to read"
|
|
2689
|
+
_c47_no \
|
|
2690
|
+
"this command holds a git commit and is $_CB bytes long; the gate reads a commit command of up to $_C47_MAX bytes." \
|
|
2691
|
+
"A larger one could take longer to read than the hook is given, and a hook that runs out of time stops nothing," \
|
|
2692
|
+
"so it is refused unread. Write the message to a file and run 'git commit -F <file>'; run the other steps as" \
|
|
2693
|
+
"commands of their own."
|
|
2694
|
+
fi
|
|
2695
|
+
if [ "$_CB" -gt "$_C47_MAX" ] && [ "$_c47_sub" = 0 ]; then
|
|
2696
|
+
gatelog BLOCK 4.5 "PowerShell call too large to read"
|
|
2697
|
+
_c47_no \
|
|
2698
|
+
"this PowerShell call names a git command the gate judges by its arguments (push, reset, clean) and is $_CB bytes long; the gate reads such a call of up to $_C47_MAX bytes." \
|
|
2699
|
+
"A larger one could take longer to read than the hook is given, and a hook that runs out of time stops nothing," \
|
|
2700
|
+
"so it is refused unread. Put the long content in a file with the Write tool and run the command on its own."
|
|
2701
|
+
fi
|
|
2702
|
+
if [ "$_CB" -gt "$_C47_MAX" ]; then
|
|
2703
|
+
gatelog BLOCK 4.5 "git call with an expansion in a command too large to read"
|
|
2704
|
+
_c47_no \
|
|
2705
|
+
"this command runs git with words the shell fills in and is $_CB bytes long; the gate reads such a command of up to $_C47_MAX bytes." \
|
|
2706
|
+
"A larger one could take longer to read than the hook is given, and a hook that runs out of time stops nothing," \
|
|
2707
|
+
"so it is refused unread. Put the long content in a file with the Write tool and run the git command on its own."
|
|
2708
|
+
fi
|
|
2709
|
+
_c47_scan "$CMD"
|
|
2710
|
+
# git itself redefined for this call: `git(){ command git "$@" -a; }; git commit -m x` committed the working tree.
|
|
2711
|
+
_ere s "$CMD" '(^|[;&|[:space:]])(function[[:space:]]+)?git[[:space:]]*\([[:space:]]*\)|alias[[:space:]]+git=' '*git*' \
|
|
2712
|
+
&& _C47_CFG="git redefined as a function or an alias"
|
|
2713
|
+
if [ -n "$_C47_SUB" ]; then
|
|
2714
|
+
gatelog BLOCK 4.5 "git subcommand the shell fills in"
|
|
2715
|
+
_c47_no \
|
|
2716
|
+
"this call runs git with a subcommand the shell fills in ($_C47_SUB), so which git command it is cannot be read." \
|
|
2717
|
+
"Every rule about a git command looks for it by name. Write the subcommand out: git commit …, git push …"
|
|
2718
|
+
fi
|
|
2719
|
+
if [ "$_c47_ps" = 1 ] && [ -n "$_C47_ARGS" ]; then
|
|
2720
|
+
while IFS= read -r _t; do
|
|
2721
|
+
# What block() prints names the rule; this line says where the command was read from.
|
|
2722
|
+
( _git_d45 "$_t"; exit 0 ) \
|
|
2723
|
+
|| { echo "GUARD: read from the arguments this PowerShell call hands to git, or to a command held in a variable: ${_t:0:120}" >&2; exit 2; }
|
|
2724
|
+
done <<< "$_C47_ARGS"
|
|
2725
|
+
fi
|
|
2726
|
+
# Everything below judges a COMMIT. A call that was read only because git is followed by an expansion, and holds
|
|
2727
|
+
# no commit, is finished here: measured on 4986 real commands, letting the commit rules run on it refused
|
|
2728
|
+
# `git -c core.autocrlf=$ac archive …` twice for "a -c setting the shell expands first".
|
|
2729
|
+
if [ "$_c47_seen" = 0 ] && [ "$_c47_try" = 0 ]; then
|
|
2730
|
+
_C47_NV=""; _C47_AM=""; _C47_ENV=""; _C47_CFG=""; _C47_UNR=""; _C47_EXP=""; _C47_UNK=""; _C47_SH=""
|
|
2731
|
+
_C47_N=0; _C47_WT=""; _C47_CD=0
|
|
2732
|
+
fi
|
|
2733
|
+
if [ -n "$_C47_NV" ]; then
|
|
2734
|
+
gatelog BLOCK 4.5 "hook skip by -n or an abbreviated --no-verify"
|
|
2735
|
+
_c47_no \
|
|
2736
|
+
"this commit skips its hooks: $_C47_NV." \
|
|
2737
|
+
"Turning a gate off is not a step in any task. If a hook is wrong, fix the hook and say so."
|
|
2738
|
+
fi
|
|
2739
|
+
if [ -n "$_C47_AM" ]; then
|
|
2740
|
+
gatelog BLOCK 4.5 "abbreviated git commit --amend"
|
|
2741
|
+
_c47_no \
|
|
2742
|
+
"this commit rewrites the last one: $_C47_AM." \
|
|
2743
|
+
"A new commit usually reaches the same end without rewriting; an amend is run only on an explicit request, by the user."
|
|
2744
|
+
fi
|
|
2745
|
+
if [ -n "$_C47_ENV" ]; then
|
|
2746
|
+
gatelog BLOCK 4.5 "git commit under a GIT_ variable set in the command"
|
|
2747
|
+
_c47_no \
|
|
2748
|
+
"this commit runs with $_C47_ENV (GIT_DIR, GIT_INDEX_FILE, GIT_CONFIG_…, HOME and their kin), so git may read another" \
|
|
2749
|
+
"repository, another index or another configuration" \
|
|
2750
|
+
"than the one this gate reads. Run 'git commit' with no GIT_ variable in front of it."
|
|
2751
|
+
fi
|
|
2752
|
+
if [ -n "$_C47_CFG" ]; then
|
|
2753
|
+
gatelog BLOCK 4.5 "git commit under a setting given in the command"
|
|
2754
|
+
_c47_no \
|
|
2755
|
+
"this call runs git with $_C47_CFG, which changes what a commit does (the hooks it runs, the files it reads, or" \
|
|
2756
|
+
"what the word 'commit' means). Run 'git commit' without it."
|
|
2757
|
+
fi
|
|
2758
|
+
if [ "$_c47_seen" = 0 ] && [ "$_C47_N" != 0 ]; then
|
|
2759
|
+
gatelog BLOCK 4.5 "git commit written so that it is not recognised"
|
|
2760
|
+
_c47_no \
|
|
2761
|
+
"this call runs a git commit spelled so that it does not read as one (a quoted or escaped command word)." \
|
|
2762
|
+
"Write it plainly: git commit -m '…'"
|
|
2763
|
+
fi
|
|
2764
|
+
if [ -n "$_C47_UNR" ]; then
|
|
2765
|
+
gatelog BLOCK 4.5 "git commit through a command the shell fills in"
|
|
2766
|
+
_c47_no \
|
|
2767
|
+
"this call holds the word commit and runs $_C47_UNR, so what git is asked to do cannot be read." \
|
|
2768
|
+
"Write the command out: git commit -m '…'"
|
|
2769
|
+
fi
|
|
2770
|
+
if [ -n "$_C47_EXP" ]; then
|
|
2771
|
+
gatelog BLOCK 4.5 "git commit argument the shell expands"
|
|
2772
|
+
_c47_no \
|
|
2773
|
+
"the argument '$_C47_EXP' is changed by the shell before git reads it (a variable that is not one plain word, a" \
|
|
2774
|
+
"glob or a brace list), so it may become more arguments than it looks like. Quote it."
|
|
2775
|
+
fi
|
|
2776
|
+
if [ -n "$_C47_UNK" ]; then
|
|
2777
|
+
gatelog BLOCK 4.5 "git commit option the gate cannot read"
|
|
2778
|
+
_c47_no \
|
|
2779
|
+
"'$_C47_UNK' is not an option of git commit under that exact name. git accepts abbreviations, which is how" \
|
|
2780
|
+
"--no-verify and --amend get past a rule that looks for their names. Write the option out in full."
|
|
2781
|
+
fi
|
|
2782
|
+
if [ -n "$_C47_SH" ]; then
|
|
2783
|
+
gatelog BLOCK 4.5 "git commit inside a nested shell"
|
|
2784
|
+
_c47_no \
|
|
2785
|
+
"this call hands a git commit to '$_C47_SH', where its arguments cannot be read (a quoted script, eval, xargs)." \
|
|
2786
|
+
"Run 'git commit' directly."
|
|
2787
|
+
fi
|
|
2788
|
+
fi
|
|
2789
|
+
if git_has "$CMD_SEEN" 'add|commit|push|checkout|switch'; then
|
|
1395
2790
|
# The key is granted by the user's environment, never by the command line the model composes.
|
|
1396
2791
|
if _grep "$CMD" -q 'CLAUDE_GIT_OK'; then
|
|
1397
2792
|
gatelog BLOCK 4.4 "approval key set inside the command"
|
|
@@ -1403,7 +2798,7 @@ if git_has "$CMD" 'add|commit|push|checkout|switch'; then
|
|
|
1403
2798
|
1|yes|true|on|YES|TRUE|ON) allow_preauthorised ;; # pre-authorised session (headless/CI)
|
|
1404
2799
|
esac
|
|
1405
2800
|
fi
|
|
1406
|
-
if git_has "$
|
|
2801
|
+
if git_has "$CMD_SEEN" 'commit|push'; then
|
|
1407
2802
|
# §4.6 — A COMMIT NEEDS A CLEAN REVIEW OF THIS DIFF.
|
|
1408
2803
|
# crew-review-agent records what it cleared in .claude/review-pass.json; this reads it back and compares two
|
|
1409
2804
|
# EXACT facts: the sha256 of the staged diff, and the HEAD it was reviewed against. There is deliberately NO
|
|
@@ -1414,7 +2809,7 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1414
2809
|
# Nested inside the commit|push block on purpose: the matcher below costs a process, and on Git Bash a fork
|
|
1415
2810
|
# is 20-50 ms on a hook that runs before EVERY Bash call. Here it runs only when a commit or push is already
|
|
1416
2811
|
# on the table. Scope is `commit` alone — a push stages nothing, so it has no diff of its own to review.
|
|
1417
|
-
if git_has "$
|
|
2812
|
+
if git_has "$CMD_SEEN" 'commit'; then
|
|
1418
2813
|
# Two questions have to be answered before the record means anything, and BOTH are about the command's own
|
|
1419
2814
|
# shape: is git pointed at another worktree, and does this commit take its content from the WORKING TREE
|
|
1420
2815
|
# instead of the index? The second one is not a nicety. MEASURED here (macOS, git 2.54.0), with a reviewed
|
|
@@ -1476,11 +2871,9 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1476
2871
|
# MEASURED FALSE on bash 5.3.15 — both spellings behave alike there. It stays only because a bracket
|
|
1477
2872
|
# expression cannot be misread by anyone (calibrated here: `[\\]n` matches a backslash before an `n` and
|
|
1478
2873
|
# leaves a bare `n` alone) and because it keeps replacements free of backslashes. No correctness claim.
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
s="${s//[\\]\'/Q}"
|
|
1483
|
-
s="${s//[\\]\"/Q}"
|
|
2874
|
+
# Through _gsub (see there): the same substitutions, a piece at a time.
|
|
2875
|
+
_gsub "$s" '[\\]r' '' bs; _gsub "$_GS" $'\r' ''; _gsub "$_GS" '[\\]n' $'\n' bs
|
|
2876
|
+
_gsub "$_GS" '[\\]'"\\'" Q bs; _gsub "$_GS" '[\\]\"' Q bs; s="$_GS"
|
|
1484
2877
|
# A quoted span collapses to the single placeholder `Q`, and this is the whole design: the CONTENT of a
|
|
1485
2878
|
# quote must not be read as an option or a path, but the TOKEN has to survive. The first version DELETED
|
|
1486
2879
|
# the span, and that was a measured fail-open on Windows — `git commit -m c "a.txt"` and
|
|
@@ -1490,32 +2883,40 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1490
2883
|
# keeps adjacency too, so `-m"msg"` becomes `-mQ` (an attached value, correct) and not `-m Q`.
|
|
1491
2884
|
# Double quotes go FIRST: an apostrophe inside a double-quoted message (`-m "don't"`) is ordinary, whereas
|
|
1492
2885
|
# a double quote inside a single-quoted one is rare, so this order mangles the rarer shape.
|
|
2886
|
+
# THE WALK IS BY LENGTH, and only over what is left. It used to be `rest="${s#*\"}"` on the whole string, and
|
|
2887
|
+
# that one expansion costs the SQUARE of the distance to the quote in bash (measured, bash 3.2, one call: 13 ms at
|
|
2888
|
+
# 5 KB, 108 ms at 10 KB, 317 ms at 21 KB) — once per pair, on a string whose collapsed front keeps growing. A
|
|
2889
|
+
# 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
|
|
2890
|
+
# 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
|
|
2891
|
+
# same string: the text before a pair holds no quote and neither does the `Q` that replaces it, so the next pair
|
|
2892
|
+
# is the first one in the remainder (pinned in smoke-test against the old loop, on the same inputs).
|
|
2893
|
+
local acc="" p2
|
|
1493
2894
|
while :; do
|
|
1494
2895
|
case "$s" in *\"*\"*) ;; *) break ;; esac
|
|
1495
|
-
pre="${s%%\"*}"; rest="${s
|
|
2896
|
+
pre="${s%%\"*}"; rest="${s:${#pre}+1}"; p2="${rest%%\"*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
|
|
1496
2897
|
done
|
|
2898
|
+
s="$acc$s"; acc=""
|
|
1497
2899
|
while :; do
|
|
1498
2900
|
case "$s" in *\'*\'*) ;; *) break ;; esac
|
|
1499
|
-
pre="${s%%\'*}"; rest="${s
|
|
2901
|
+
pre="${s%%\'*}"; rest="${s:${#pre}+1}"; p2="${rest%%\'*}"; acc="$acc${pre}Q"; s="${rest:${#p2}+1}"
|
|
1500
2902
|
done
|
|
2903
|
+
s="$acc$s"
|
|
1501
2904
|
# A backslash-newline is a LINE CONTINUATION, the opposite of a separator: it JOINS. Measured, before this,
|
|
1502
2905
|
# `git commit \` + newline + ` -m c` refused the commit, because the lone `\` became a token and read as a
|
|
1503
2906
|
# pathspec. It has to run before the conversion below, or the newline is gone when we look for it.
|
|
1504
|
-
|
|
2907
|
+
_gsub "$s" '[\\]'$'\n' ' ' bs; s="$_GS"
|
|
1505
2908
|
# A NEWLINE IS A COMMAND SEPARATOR and has to become one, or a multi-line Bash call is misread: measured,
|
|
1506
2909
|
# `git commit -m c` followed by a line `echo done` refused the commit, because `done` was read as a
|
|
1507
2910
|
# pathspec. Splitting alone cannot save it — the default IFS eats newlines, so the boundary is gone by the
|
|
1508
2911
|
# time the walk sees tokens.
|
|
1509
|
-
s
|
|
2912
|
+
_gsub "$s" $'\n' ';'; s="$_GS"
|
|
1510
2913
|
# SEPARATORS BECOME THEIR OWN TOKENS. Without this, `git commit -m c; echo done` refused the commit: the
|
|
1511
2914
|
# token was `c;`, `-m` swallowed it whole, the separator inside it was never seen, and `echo` read as a
|
|
1512
2915
|
# pathspec. The fail-open twin is worse and was measured too — in
|
|
1513
2916
|
# `if true; then git commit -m c -- a.txt; fi` the pathspec token was `a.txt;`, which the `--` lookahead
|
|
1514
2917
|
# dismissed as a separator, so the commit was ALLOWED. Padding fixes both at once, and `&&`/`||` simply
|
|
1515
2918
|
# become two tokens, which the walk already treats as one boundary.
|
|
1516
|
-
|
|
1517
|
-
s="${s//&/ & }"
|
|
1518
|
-
s="${s//|/ | }"
|
|
2919
|
+
_gsub "$s" ';' ' ; '; _gsub "$_GS" '&' ' & '; _gsub "$_GS" '|' ' | '; s="$_GS"
|
|
1519
2920
|
# Splitting has to happen with globbing OFF, or a pathspec like `*.ts` would expand against the cwd and a
|
|
1520
2921
|
# commit could be judged on whatever files happen to sit there.
|
|
1521
2922
|
local unglob=0
|
|
@@ -1601,6 +3002,8 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1601
3002
|
done
|
|
1602
3003
|
}
|
|
1603
3004
|
_c46_scan "$CMD"
|
|
3005
|
+
# ...and the same question asked of the reading above (_c47_scan, already run for this call). Either answer refuses.
|
|
3006
|
+
[ -z "$_C46_WT" ] && [ -n "$_C47_WT" ] && _C46_WT="$_C47_WT"
|
|
1604
3007
|
# The record describes THIS worktree. A command that points git at another one would have us hash the wrong
|
|
1605
3008
|
# repository and pass it off as verified, so the ambiguous form fails closed instead.
|
|
1606
3009
|
if [ "$_C46_REDIR" = 1 ]; then
|
|
@@ -1619,6 +3022,27 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1619
3022
|
exit 2
|
|
1620
3023
|
fi
|
|
1621
3024
|
|
|
3025
|
+
# THE COMMIT RUNS WHERE THE CALL TAKES IT. `cd ../other && git commit -m x` commits what is staged THERE, while
|
|
3026
|
+
# the record below is read and compared HERE (measured: the other repository got the commit). One plain target is
|
|
3027
|
+
# resolved and has to be this same repository — git is asked for both git directories, so no path is compared by
|
|
3028
|
+
# its spelling; a target that cannot be read (a variable, `cd -`, two of them, `env -C`) is refused.
|
|
3029
|
+
if [ "$_C47_CD" != 0 ]; then
|
|
3030
|
+
_cdok=0
|
|
3031
|
+
if [ "$_C47_CD" = 1 ]; then
|
|
3032
|
+
case "$_C47_CDT" in /*|[A-Za-z]:*) _cdt="$_C47_CDT" ;; *) _cdt="${_CWD:-.}/$_C47_CDT" ;; esac
|
|
3033
|
+
_g1="$(git -C "${_CWD:-.}" rev-parse --absolute-git-dir 2>/dev/null)"
|
|
3034
|
+
_g2="$(git -C "$_cdt" rev-parse --absolute-git-dir 2>/dev/null)"
|
|
3035
|
+
[ -n "$_g1" ] && [ "$_g1" = "$_g2" ] && _cdok=1
|
|
3036
|
+
fi
|
|
3037
|
+
if [ "$_cdok" != 1 ]; then
|
|
3038
|
+
gatelog BLOCK 4.6 "commit after a change of directory"
|
|
3039
|
+
echo "GUARD (§4.6): this call changes directory before it commits, and the commit would not run in the repository" >&2
|
|
3040
|
+
echo "this session is in — or the target cannot be read (a variable, 'cd -', more than one). The review record" >&2
|
|
3041
|
+
echo "describes what is staged HERE. Run the commit from this directory, or run it yourself in your terminal." >&2
|
|
3042
|
+
exit 2
|
|
3043
|
+
fi
|
|
3044
|
+
fi
|
|
3045
|
+
|
|
1622
3046
|
# CREW-REVIEW-PASS (this recipe is kept identical in agents/crew-review-agent.md; smoke-test pins the pair)
|
|
1623
3047
|
# GIT does the hashing, not sha256sum/shasum, and the reason is the one that survives BOTH platforms —
|
|
1624
3048
|
# because the first two reasons written here did not. Measured on macOS: the suite's sandbox reaches its
|
|
@@ -1659,7 +3083,11 @@ if git_has "$CMD" 'commit|push'; then
|
|
|
1659
3083
|
RPJ=""; while IFS= read -r _l || [ -n "$_l" ]; do RPJ="$RPJ${_l%$'\r'}"; done < "$RP"
|
|
1660
3084
|
_rpf(){ _r="${RPJ#*\"$1\":\"}"; [ "$_r" = "$RPJ" ] && return 1; printf '%s' "${_r%%\"*}"; }
|
|
1661
3085
|
WANT_D="$(_rpf diff_oid || true)"; WANT_H="$(_rpf head || true)"
|
|
1662
|
-
|
|
3086
|
+
# --no-ext-diff --no-textconv: what `git diff` prints is configurable, and `git config diff.external true` made
|
|
3087
|
+
# every staged change print NOTHING — one id for all of them, so one review record vouched for any diff (measured,
|
|
3088
|
+
# 3.1.0 review). With neither configured the two flags change no byte, so a record written by the older recipe
|
|
3089
|
+
# still matches.
|
|
3090
|
+
HAVE_D="$(git -C "$_RPD" diff --cached --no-ext-diff --no-textconv 2>/dev/null | git hash-object --stdin 2>/dev/null)"
|
|
1663
3091
|
# --verify --quiet, not a bare `git rev-parse HEAD`: on an UNBORN head the bare form prints the literal
|
|
1664
3092
|
# string "HEAD" on stdout and still fails, so `|| echo NONE` appended to it and the value became two
|
|
1665
3093
|
# lines ("HEAD" then "NONE") — which never matches any record. Measured on a fresh `git init`.
|
|
@@ -1710,6 +3138,19 @@ $SHORT
|
|
|
1710
3138
|
|
|
1711
3139
|
${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."
|
|
1712
3140
|
;;
|
|
3141
|
+
auto|dontAsk)
|
|
3142
|
+
# No prompt reaches a person here, but their own message does: see _approval_ok above.
|
|
3143
|
+
_approval_ok && allow_approved
|
|
3144
|
+
gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
|
|
3145
|
+
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
|
|
3146
|
+
echo "Not allowed now: $_APW." >&2
|
|
3147
|
+
echo "Present the commit MESSAGE to the user (stage first: the approval covers what is staged at that moment). Then one of:" >&2
|
|
3148
|
+
echo " (a) the user sends a message that is ONLY the approval: 'approve: commit', 'approve: push' or 'approve: commit+push' ('onay: …' works too)." >&2
|
|
3149
|
+
echo " It is tied to the staged diff and HEAD at that moment, lasts 30 minutes and ends at the user's next message. The command then runs ALONE in its call (no cd, no pipe, nothing chained): git commit -m …, or git push <remote> <branch>. OR" >&2
|
|
3150
|
+
echo " (b) the user presses Shift+Tab to switch to default/acceptEdits — in this session, no restart — and this gate asks them directly, OR" >&2
|
|
3151
|
+
echo " (c) the user runs the command in their own terminal." >&2
|
|
3152
|
+
echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
|
|
3153
|
+
exit 2 ;;
|
|
1713
3154
|
*)
|
|
1714
3155
|
# bypassPermissions, plan, or an unrecognised/absent mode: we cannot prove the prompt would reach a
|
|
1715
3156
|
# human, so we fail closed rather than let the gate silently evaporate.
|