cc-discipline 2.13.6 → 2.15.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.
@@ -1,132 +1,677 @@
1
1
  #!/bin/bash
2
2
  # cc-discipline: Guard against destructive git commands
3
- # PreToolUse on Bash — blocks git checkout/restore/reset --hard/clean -f without confirmation.
4
-
5
- INPUT=$(cat)
6
-
7
- # Extract tool_name and command. MUST have a jq-or-grep fallback: jq is absent
8
- # on Windows Git Bash, and the previous jq-only version left TOOL_NAME empty
9
- # there, so the `!= "Bash"` check below exited 0 immediately and EVERY guard in
10
- # this file was dead code. Destructive-git protection never ran on Windows.
11
- # (fixed 2026-07-30 — verified with jq absent, see docs/progress.md)
12
- if command -v jq &>/dev/null; then
13
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
14
- CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null)
15
- else
16
- TOOL_NAME=$(echo "$INPUT" | sed -n -E 's/.*"tool_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p' | head -1)
17
- # The [{,] anchor makes this pick the real "command" field rather than an
18
- # escaped \"command\" nested inside it (happens when piping JSON to a hook).
19
- # Unescape \n and \" so line-spanning commands still match the patterns below.
20
- CMD=$(echo "$INPUT" | sed -n -E 's/.*[{,][[:space:]]*"command"[[:space:]]*:[[:space:]]*"(.*)".*/\1/p' \
21
- | sed 's/[\]n/ /g; s/[\]"/"/g')
22
- # If the command could not be isolated, scan the whole payload instead of
23
- # giving up. For a destructive-git guard the failure directions are not
24
- # symmetric: a spurious confirmation prompt costs one turn, a miss costs the
25
- # user's uncommitted work. Fail loud.
26
- [ -z "$CMD" ] && CMD="$INPUT"
27
- fi
3
+ # PreToolUse on Bash — blocks git checkout/restore/reset --hard/clean -f,
4
+ # branch -D, stash drop/clear and force-pushes to main/master until the user
5
+ # confirms.
6
+ #
7
+ # Failure direction: LOUD. A spurious confirmation prompt costs one turn; a
8
+ # miss costs the user's uncommitted work.
9
+ #
10
+ # How it decides (redesigned 2026-09-23 after a review found 14 destructive
11
+ # forms that the old one-regex-per-rule version let through):
12
+ # 1. Read the payload exactly: top-level tool_name, and command directly
13
+ # inside tool_input — the paths jq would read, without needing jq.
14
+ # 2. Lift every $(...) and `...` out into its own work item: substitutions
15
+ # execute even inside a quoted -m message. Heredoc bodies are cut out in
16
+ # the same pass.
17
+ # 3. Tokenize with bash quoting rules and split into single commands at
18
+ # ; && || | & ( ) and newlines. Quoted text is also queued as its own work
19
+ # item (bash -c "...", eval "...", ssh host "..."), except the argument of
20
+ # -m/--message/-F/--file, which is data. A heredoc body is data when it
21
+ # only feeds cat, tee or git commit -F - and nothing in the command runs
22
+ # code; otherwise it is judged as code (decide_heredocs). Until 2.15.0
23
+ # every body was code, and appending notes that NAME these commands to a
24
+ # file was blocked every day.
25
+ # 4. For each git invocation, skip global options (-C, -c, --git-dir, ...),
26
+ # find the subcommand and judge its flag SET — order and spelling
27
+ # (-df, -d -f, --delete --force) no longer matter.
28
+ # The awk program prints exactly one verdict line. If it prints anything else
29
+ # — awk missing, a crash, a command too tangled to follow — the wrapper falls
30
+ # back to a coarse text check and blocks when it sees git plus a guarded
31
+ # subcommand. A crash must never become exit 1: Claude Code treats any exit
32
+ # other than 2 as a non-blocking error, i.e. the command runs. Payloads over
33
+ # 64 KB skip the parser and go straight to that coarse check (see below).
34
+ #
35
+ # Out of scope by design: deliberate obfuscation ($G reset, git aliases). This
36
+ # guards against a fast, careless agent, not an adversary.
37
+ #
38
+ # Tests: tests/git-guard-matrix.sh in the cc-discipline repository; they are
39
+ # not installed with the hooks. To try a case by hand, write the command into a
40
+ # file first: this guard reads quoted text as code, so a case typed inline
41
+ # blocks the test command itself.
28
42
 
29
- # Only gate on tool_name when it actually resolved — an unresolvable tool_name
30
- # must not silently disable the guard (that was the original bug).
31
- if [ -n "$TOOL_NAME" ] && [ "$TOOL_NAME" != "Bash" ]; then
32
- exit 0
33
- fi
43
+ IFS= read -r -d '' INPUT
34
44
 
35
- # Normalize: collapse whitespace, trim
36
- CMD_NORM=$(echo "$CMD" | tr '\n' ' ' | sed 's/ */ /g')
45
+ IFS= read -r -d '' PROG <<'AWK'
46
+ function has_git(s) { return index(tolower(s), "git") > 0 }
37
47
 
38
- # This guard matches command TEXT, so a command that merely quotes a destructive
39
- # command trips it. The one recurring legitimate case is feeding JSON into one of
40
- # these hooks to test them (the workflow documented in CLAUDE.md). Exempt it —
41
- # piping a payload to a hook script never touches the working tree.
42
- if echo "$CMD_NORM" | grep -qE 'hooks[/\\][a-z-]+\.sh'; then
43
- exit 0
44
- fi
48
+ function enqueue(s) {
49
+ if (!has_git(s)) return # no rule can fire without a git word
50
+ if (s in SEEN) return # judged once: long notes repeat the same `git ...` spans
51
+ if (QN >= QMAX) { OVERFLOW = 1; return }
52
+ SEEN[s] = 1; QN++; Q[QN] = s
53
+ QH[QN] = (ENQ_HD || CURHD) # from a heredoc body judged as code, or from text inside one
54
+ }
45
55
 
46
- # Blank out the CONTENT of message-style arguments before matching. A commit
47
- # message that merely *describes* a destructive command is data — it can never
48
- # execute — but the text match could not tell the difference, so writing about
49
- # these commands blocked the commit:
50
- # git commit -m "revert the git reset --hard change" -> must pass
51
- # git commit -m "x" && git reset --hard -> must STILL block
52
- #
53
- # Only the argument to -m/--message/-F/--file is blanked, never quotes in
54
- # general. Stripping all quoted text would open a real hole, because these DO
55
- # execute what they quote and must keep matching:
56
- # bash -c "git reset --hard" eval "git clean -fd" sudo git reset --hard
56
+ # A block found inside a heredoc body judged as code carries a note: the usual
57
+ # case is a script that only edits a file and merely NAMES the command (a
58
+ # report asked for python heredocs to pass; judging what python code executes
59
+ # is not reliable, so the guard says what to do instead).
60
+ function block(reason, hint) {
61
+ if (VERDICT == "") VERDICT = "BLOCK\t" reason "\t" hint (CURHD ? "\t" HDNOTE : "")
62
+ }
63
+
64
+ # ── 1. Payload ────────────────────────────────────────────────────────────
65
+ # A small JSON walk that tracks depth and the key each value belongs to, so a
66
+ # "command" key anywhere else can never stand in for tool_input.command.
67
+ function json_string(s, i, n, out, c, e, start) {
68
+ out = ""; i++
69
+ while (i <= n) {
70
+ start = i
71
+ while (i <= n) { c = substr(s, i, 1); if (c == "\\" || c == "\"") break; i++ }
72
+ out = out substr(s, start, i - start)
73
+ if (i > n) break
74
+ if (c == "\"") { JEND = i; return out }
75
+ e = substr(s, i + 1, 1)
76
+ if (e == "n") out = out "\n"
77
+ else if (e == "t") out = out "\t"
78
+ else if (e == "r") out = out "\r"
79
+ else if (e == "b" || e == "f") out = out " "
80
+ else if (e == "u") out = out "\\u" # \uXXXX stays literal
81
+ else out = out e # \" \\ \/
82
+ i += 2
83
+ }
84
+ JEND = n; return out
85
+ }
86
+
87
+ function parse_payload(s, n, i, c, d, str) {
88
+ n = length(s); d = 0; i = 1
89
+ while (i <= n) {
90
+ c = substr(s, i, 1)
91
+ if (c == "{" || c == "[") {
92
+ d++; KIND[d] = c; CURK[d] = ""; WANTK[d] = (c == "{")
93
+ PKEY[d] = (d > 1 && KIND[d - 1] == "{") ? CURK[d - 1] : ""
94
+ i++; continue
95
+ }
96
+ if (c == "}" || c == "]") { d--; i++; continue }
97
+ if (c == ",") { if (d > 0 && KIND[d] == "{") WANTK[d] = 1; i++; continue }
98
+ if (c == ":") { if (d > 0) WANTK[d] = 0; i++; continue }
99
+ if (c == "\"") {
100
+ str = json_string(s, i, n); i = JEND + 1
101
+ if (d > 0 && KIND[d] == "{" && WANTK[d]) { CURK[d] = str; continue }
102
+ if (d == 1 && CURK[1] == "tool_name" && !HAVETOOL) { TOOL = str; HAVETOOL = 1 }
103
+ else if (d == 2 && KIND[1] == "{" && KIND[2] == "{" && PKEY[2] == "tool_input" \
104
+ && CURK[2] == "command" && !HAVECMD) { CMD = str; HAVECMD = 1 }
105
+ continue
106
+ }
107
+ i++
108
+ }
109
+ }
110
+
111
+ # ── 2. Command substitutions and heredoc bodies ───────────────────────────
112
+ # Substitutions are matched by depth counting that ignores quotes inside them,
113
+ # so a ")" inside a quoted string can end one early. Every character still ends
114
+ # up either in the returned string or in a queued item — but the leftover can
115
+ # land inside a message argument, which is not scanned; queue_lifted() covers
116
+ # that case. Quotes OUTSIDE substitutions are tracked: single-quoted text is
117
+ # literal (a quoted word that bash -c or ssh runs is queued by the tokenizer
118
+ # and lifted then), and a "<<" inside quotes is not a heredoc.
57
119
  #
58
- # Escaped quotes inside a message end the match early, leaving the tail to be
59
- # scanned — that direction is a false positive (one wasted turn), never a miss.
60
- SQ="'"
61
- CMD_SCAN=$(printf '%s' "$CMD_NORM" \
62
- | sed -E 's/(^|[[:space:]])(-m|--message|-F|--file)[[:space:]]*"[^"]*"/\1\2 ARG/g' \
63
- | sed -E "s/(^|[[:space:]])(-m|--message|-F|--file)[[:space:]]*${SQ}[^${SQ}]*${SQ}/\1\2 ARG/g" \
64
- | sed -E 's/(^|[[:space:]])(-m|--message|-F|--file)[[:space:]]+[^[:space:]]+/\1\2 ARG/g')
65
-
66
- BLOCKED=""
67
- SUGGESTION=""
68
-
69
- # git checkout . / git checkout -- <file> (discard working tree changes)
70
- # But allow: git checkout <branch>, git checkout -b <branch>
71
- if echo "$CMD_SCAN" | grep -qE 'git\s+checkout\s+(\.|--\s)'; then
72
- BLOCKED="git checkout (discards uncommitted changes)"
73
- SUGGESTION="git stash"
74
- fi
120
+ # Heredoc bodies are cut out here and kept aside in PB[], so no quote or
121
+ # backtick inside one can confuse what follows it. A QUOTED delimiter means the
122
+ # body is literal, so nothing in it is lifted; an unquoted one expands, so its
123
+ # substitutions are lifted like any others. Whether a body is then judged as
124
+ # code is decided after tokenizing (decide_heredocs). The commit-message form
125
+ # -m "$(cat <<'EOF' ... EOF)" is dropped on purpose (message_heredoc_end).
126
+ function lift_substitutions(s, nohd, n, i, c, out, j, depth, st, w, mp, q, hn) {
127
+ n = length(s); out = ""; i = 1; st = 1; q = ""; hn = 0 # s[st..i-1] is pending, not yet in out
128
+ if (!nohd) PBN = 0
129
+ while (i <= n) {
130
+ c = substr(s, i, 1)
131
+ if (q == "'") { if (c == "'") q = ""; i++; continue }
132
+ if (c == "\\") { i += 2; continue } # an escaped character is literal
133
+ if (q == "\"") { if (c == "\"") { q = ""; i++; continue } }
134
+ else if (c == "'" || c == "\"") { q = c; i++; continue }
135
+ else if (!nohd && c == "<" && substr(s, i + 1, 1) == "<" && substr(s, i + 2, 1) != "<" \
136
+ && substr(s, i - 1, 1) != "<" && (j = heredoc_op(s, i + 2, n)) > 0) {
137
+ hn++; HLD[hn] = HOP_DELIM; HLDASH[hn] = HOP_DASH; HLQ[hn] = HOP_QUOTED
138
+ i = j; continue
139
+ }
140
+ else if (c == "\n" && hn > 0) {
141
+ out = out substr(s, st, i - st + 1) # up to and including the newline
142
+ i = cut_bodies(s, i + 1, n, hn); hn = 0; st = i; continue
143
+ }
144
+ if (c == "$" && substr(s, i + 1, 1) == "(") {
145
+ out = out substr(s, st, i - st)
146
+ # Only the tail can decide message position. Matching all of out
147
+ # on every "$(" made long commands quadratic.
148
+ if (length(out) > 64) { w = substr(out, length(out) - 63); mp = (w ~ MSGTAIL) }
149
+ else mp = (out ~ MSGPOS)
150
+ if (mp && (j = message_heredoc_end(s, i + 2, n)) > 0) {
151
+ out = out "SUBST"; i = j + 1; st = i; continue
152
+ }
153
+ depth = 1; j = i + 2
154
+ while (j <= n) {
155
+ c = substr(s, j, 1)
156
+ if (c == "(") depth++
157
+ else if (c == ")") { depth--; if (depth == 0) break }
158
+ j++
159
+ }
160
+ enqueue(substr(s, i + 2, j - i - 2))
161
+ out = out "SUBST"; i = j + 1; st = i; continue
162
+ }
163
+ if (c == "`") {
164
+ j = i + 1
165
+ while (j <= n && substr(s, j, 1) != "`") j++
166
+ if (j <= n) {
167
+ out = out substr(s, st, i - st)
168
+ enqueue(substr(s, i + 1, j - i - 1)); out = out "SUBST"; i = j + 1; st = i; continue
169
+ }
170
+ }
171
+ i++
172
+ }
173
+ return out substr(s, st)
174
+ }
75
175
 
76
- # git restore . / git restore <file> (without --staged)
77
- if echo "$CMD_SCAN" | grep -qE 'git\s+restore\s' && ! echo "$CMD_SCAN" | grep -qE 'git\s+restore\s+--staged'; then
78
- BLOCKED="git restore (discards uncommitted changes)"
79
- SUGGESTION="git stash"
80
- fi
176
+ # After "<<": optional "-", blanks, then a delimiter that is 'X', "X", \X or a
177
+ # bare word. Sets HOP_DELIM, HOP_DASH, HOP_QUOTED; returns the index just past
178
+ # the delimiter, or 0 when there is none.
179
+ function heredoc_op(s, j, n, dash, q, k) {
180
+ dash = 0
181
+ if (substr(s, j, 1) == "-") { dash = 1; j++ }
182
+ while (substr(s, j, 1) ~ /[ \t]/) j++
183
+ q = substr(s, j, 1)
184
+ if (q == "'" || q == "\"") {
185
+ k = j + 1
186
+ while (k <= n && substr(s, k, 1) != q && substr(s, k, 1) != "\n") k++
187
+ if (substr(s, k, 1) != q) return 0
188
+ HOP_DELIM = substr(s, j + 1, k - j - 1); HOP_QUOTED = 1; j = k + 1
189
+ } else {
190
+ HOP_QUOTED = 0
191
+ if (q == "\\") { HOP_QUOTED = 1; j++ }
192
+ k = j
193
+ while (k <= n && substr(s, k, 1) ~ /[A-Za-z0-9_.-]/) k++
194
+ HOP_DELIM = substr(s, j, k - j); j = k
195
+ }
196
+ if (HOP_DELIM == "") return 0
197
+ HOP_DASH = dash
198
+ return j
199
+ }
81
200
 
82
- # git reset --hard
83
- if echo "$CMD_SCAN" | grep -qE 'git\s+reset\s+--hard'; then
84
- BLOCKED="git reset --hard (destroys all uncommitted changes)"
85
- SUGGESTION="git stash && git reset"
86
- fi
201
+ # Cut the bodies of the hn heredocs opened on the line that just ended; s[i] is
202
+ # the first body line. Returns the index just past the last terminator line. A
203
+ # body with no terminator runs to the end of the string, as it does in bash.
204
+ function cut_bodies(s, i, n, hn, k, e, line, start, body) {
205
+ for (k = 1; k <= hn; k++) {
206
+ start = i
207
+ while (1) {
208
+ if (i > n) { body = substr(s, start); break }
209
+ e = eol(s, i, n)
210
+ line = (e > 0) ? substr(s, i, e - i) : substr(s, i)
211
+ sub(/\r$/, "", line)
212
+ if (HLDASH[k]) sub(/^\t+/, "", line)
213
+ if (line == HLD[k]) { body = substr(s, start, i - start); i = (e > 0) ? e + 1 : n + 1; break }
214
+ if (e == 0) { body = substr(s, start); i = n + 1; break }
215
+ i = e + 1
216
+ }
217
+ PBN++; PB[PBN] = HLQ[k] ? body : lift_substitutions(body, 1)
218
+ }
219
+ return i
220
+ }
87
221
 
88
- # git clean -f / -fd / -fx
89
- if echo "$CMD_SCAN" | grep -qE 'git\s+clean\s+-[a-z]*f'; then
90
- BLOCKED="git clean -f (permanently deletes untracked files)"
91
- SUGGESTION="git stash --include-untracked"
92
- fi
222
+ # Index of the next newline at or after j, or 0. A scan rather than
223
+ # index(substr(s, j), "\n"), which copies the rest of s on every line and made
224
+ # long heredocs quadratic: 9 s for a 200 KB command under BSD awk.
225
+ function eol(s, j, n) {
226
+ while (j <= n && substr(s, j, 1) != "\n") j++
227
+ return (j <= n) ? j : 0
228
+ }
93
229
 
94
- # git branch -D (force delete unmerged branch)
95
- if echo "$CMD_SCAN" | grep -qE 'git\s+branch\s+-D\s'; then
96
- BLOCKED="git branch -D (deletes branch even if not merged)"
97
- SUGGESTION="git branch -d (safe delete, fails if not merged)"
98
- fi
230
+ # -m "$(cat <<'EOF' ... EOF)" is how Claude Code writes commit messages. With a
231
+ # QUOTED delimiter the body is not expanded, so it is data, never code. Returns
232
+ # the index of the closing ")" when the substitution is exactly that form —
233
+ # only blanks after the terminator line — and 0 otherwise. The terminator is
234
+ # found before any parenthesis is counted, so "1)" in a message is harmless.
235
+ function message_heredoc_end(s, j, n, dash, q, k, delim, line, e) {
236
+ while (substr(s, j, 1) ~ /[ \t]/) j++
237
+ if (substr(s, j, 3) != "cat") return 0
238
+ j += 3
239
+ if (substr(s, j, 1) !~ /[ \t]/) return 0
240
+ while (substr(s, j, 1) ~ /[ \t]/) j++
241
+ if (substr(s, j, 2) != "<<") return 0
242
+ j += 2; dash = 0
243
+ if (substr(s, j, 1) == "-") { dash = 1; j++ }
244
+ while (substr(s, j, 1) ~ /[ \t]/) j++
245
+ q = substr(s, j, 1)
246
+ if (q == "'" || q == "\"") {
247
+ k = index(substr(s, j + 1), q); if (k == 0) return 0
248
+ delim = substr(s, j + 1, k - 1); j = j + k + 1
249
+ } else if (q == "\\") {
250
+ j++; k = j
251
+ while (substr(s, k, 1) ~ /[A-Za-z0-9_]/) k++
252
+ delim = substr(s, j, k - j); j = k
253
+ } else return 0 # unquoted: body expands
254
+ if (delim !~ /^[A-Za-z0-9_]+$/) return 0
255
+ while (substr(s, j, 1) ~ /[ \t\r]/) j++
256
+ if (substr(s, j, 1) != "\n") return 0 # e.g. cat <<'X' | sh
257
+ j++
258
+ while (j <= n) {
259
+ e = eol(s, j, n)
260
+ line = (e > 0) ? substr(s, j, e - j) : substr(s, j)
261
+ sub(/\r$/, "", line)
262
+ if (dash) sub(/^\t+/, "", line)
263
+ if (line == delim) {
264
+ j = (e > 0) ? e + 1 : n + 1
265
+ while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
266
+ return (substr(s, j, 1) == ")") ? j : 0
267
+ }
268
+ if (e == 0) return 0
269
+ j = e + 1
270
+ }
271
+ return 0
272
+ }
99
273
 
100
- # git push --force / -f (to main/master)
101
- #
102
- # Both the flag test and the branch test are scoped to the push command itself
103
- # and anchored on both sides. Three defects found 2026-09-02, all from the old
104
- # one-line form:
105
- # 1. `-f` had no word boundary, so it matched inside `--format`, `--file` and
106
- # `--follow`.
107
- # 2. `.*` was unbounded, so a match anywhere later in a compound command
108
- # counted — pushing to main on the same line as `git log --format=%h` was
109
- # blocked as a force-push.
110
- # 3. `--force` had no boundary either, so it matched inside
111
- # `--force-with-lease` — the guard blocked the very remedy it recommends.
112
- # `-[a-zA-Z]*f[a-zA-Z]*` still catches clustered short flags (-fu, -uf, -ufv);
113
- # `--format` cannot match it because a letter class cannot consume the second
114
- # dash. `[^;&|]*` keeps the scan inside one command, which is also what fixes
115
- # the branch test — moving it inside the segment stops `... origin dev && echo
116
- # main` from counting. The branch test itself stays deliberately UNanchored:
117
- # anchoring it would let `refs/heads/main` through, and a miss here costs the
118
- # user's history while a false positive costs one turn.
119
- PUSH_SEG=$(printf '%s' "$CMD_SCAN" | sed -n 's/.*\(git[[:space:]]\{1,\}push[^;&|]*\).*/\1/p')
120
- if [ -n "$PUSH_SEG" ] \
121
- && printf '%s' "$PUSH_SEG" | grep -qE '(^|[[:space:]])(-[a-zA-Z]*f[a-zA-Z]*|--force)([[:space:]]|$)' \
122
- && printf '%s' "$PUSH_SEG" | grep -qE '(main|master)'; then
123
- BLOCKED="git push --force to main/master (rewrites shared history)"
124
- SUGGESTION="git push --force-with-lease"
125
- fi
274
+ # ── 3. Tokenizer ──────────────────────────────────────────────────────────
275
+ # Fills T[1..TN] (word text or operator), TY[k] ("w" or the operator), TQ[k]
276
+ # (the quoted text inside a word) and TM[k] (1 when the word is the argument
277
+ # of a message flag, i.e. data).
278
+ function end_word() {
279
+ if (!_inw) return
280
+ if (_hdword) _hdword = 0 # a heredoc delimiter
281
+ else if (_redir) { _redir = 0; RN++; RDT[RN] = _cur; RDA[RN] = TN } # a redirection target, kept aside
282
+ else {
283
+ TN++; T[TN] = _cur; TY[TN] = "w"; TQ[TN] = _qb
284
+ TM[TN] = (_last ~ /^(-m|--message|-F|--file)$/ || _cur ~ /^(-m.|--message=|-F.|--file=)/)
285
+ _last = _cur
286
+ }
287
+ _cur = ""; _inw = 0; _qb = ""
288
+ }
289
+
290
+ function add_op(op) {
291
+ end_word()
292
+ TN++; T[TN] = op; TY[TN] = op; TQ[TN] = ""; TM[TN] = 0
293
+ _last = ""
294
+ }
126
295
 
127
- if [ -n "$BLOCKED" ]; then
128
- echo "Git safety check: Blocked $BLOCKED. This is an irreversible operation — uncommitted work would be lost. Before proceeding: (1) Check git status and git diff to see what would be affected. (2) If changes should be kept, run: $SUGGESTION first. (3) If you're certain the changes should be discarded, tell the user what will be lost and ask for explicit confirmation." >&2
129
- exit 2
296
+ function tokenize(s, n, i, c, e, q) {
297
+ split("", T); split("", TY); split("", TQ); split("", TM)
298
+ TN = 0; HOPN = 0; RN = 0; _cur = ""; _inw = 0; _qb = ""; _redir = 0; _hdword = 0; _last = ""
299
+ n = length(s); i = 1; q = ""
300
+ while (i <= n) {
301
+ c = substr(s, i, 1)
302
+ if (q == "'") { # take the whole run up to the closing quote
303
+ e = i
304
+ while (e <= n && substr(s, e, 1) != "'") e++
305
+ _cur = _cur substr(s, i, e - i); _qb = _qb substr(s, i, e - i)
306
+ if (e <= n) q = ""
307
+ i = e + 1; continue
308
+ }
309
+ if (q == "\"") {
310
+ if (c == "\\") {
311
+ e = substr(s, i + 1, 1)
312
+ if (e == "\n") { i += 2; continue }
313
+ if (e == "\"" || e == "\\" || e == "`" || e == "$") { _cur = _cur e; _qb = _qb e; i += 2; continue }
314
+ }
315
+ if (c == "\"") { q = ""; i++; continue }
316
+ if (c == "\\") { _cur = _cur c; _qb = _qb c; i++; continue } # a backslash that escapes nothing
317
+ e = i
318
+ while (e <= n && (c = substr(s, e, 1)) != "\"" && c != "\\") e++
319
+ _cur = _cur substr(s, i, e - i); _qb = _qb substr(s, i, e - i)
320
+ i = e; continue
321
+ }
322
+ if (c == "\\") {
323
+ e = substr(s, i + 1, 1)
324
+ if (e != "\n") { _cur = _cur e; _inw = 1 } # backslash-newline: continuation
325
+ i += 2; continue
326
+ }
327
+ if (c == "'" || c == "\"") {
328
+ q = c; _inw = 1; if (_qb != "") _qb = _qb "\n"
329
+ i++; continue
330
+ }
331
+ if (c == " " || c == "\t" || c == "\r") { end_word(); i++; continue }
332
+ if (c == "#" && !_inw) { # a comment runs to the end of the line
333
+ e = eol(s, i, n)
334
+ i = (e > 0) ? e : n + 1
335
+ continue
336
+ }
337
+ if (c == "\n") { add_op(";"); i++; continue } # heredoc bodies were cut out by the lift pass
338
+ # A backtick left here is one the lift pass read as quoted: still a boundary.
339
+ if (c == ";" || c == "(" || c == ")" || c == "`") { add_op(";"); i++; continue }
340
+ if (c == "&") {
341
+ e = substr(s, i + 1, 1)
342
+ if (e == "&") { add_op("&&"); i += 2; continue }
343
+ if (e == ">") { # &> and &>> redirect
344
+ end_word(); i += 2
345
+ if (substr(s, i, 1) == ">") i++
346
+ _redir = 1; continue
347
+ }
348
+ add_op("&"); i++; continue
349
+ }
350
+ if (c == "|") {
351
+ e = substr(s, i + 1, 1)
352
+ if (e == "|") { add_op("||"); i += 2; continue }
353
+ if (e == "&") i++
354
+ add_op("|"); i++; continue
355
+ }
356
+ if (c == ">" || c == "<") {
357
+ if (_inw && _qb == "" && _cur ~ /^[0-9]+$/) { _cur = ""; _inw = 0 } # the fd in 2>
358
+ else end_word()
359
+ if (substr(s, i, 3) == "<<<") { i += 3; continue } # here-string: its word is data for the command, keep it
360
+ if (substr(s, i, 2) == "<<") { # the body is already cut out
361
+ i += 2
362
+ if (substr(s, i, 1) == "-") i++
363
+ HOPN++; HOPAT[HOPN] = TN; _hdword = 1; continue
364
+ }
365
+ i++
366
+ while (substr(s, i, 1) ~ /[<>&|]/) i++
367
+ _redir = 1; continue
368
+ }
369
+ e = i + 1 # an ordinary run up to the next special character
370
+ while (e <= n && index(STOP, substr(s, e, 1)) == 0) e++
371
+ _cur = _cur substr(s, i, e - i); _inw = 1; i = e
372
+ }
373
+ end_word()
374
+ }
375
+
376
+ # ── 3b. Heredoc bodies: data or code ──────────────────────────────────────
377
+ # A body is data only when all of this holds, and code otherwise:
378
+ # - it belongs to the command Claude typed; a heredoc inside a substitution
379
+ # or a quoted string runs wherever that text runs;
380
+ # - its command is a data sink (cat, tee, git commit -F -) that is not
381
+ # writing a script (x.sh, .git/hooks/..., bin/...), and every later stage
382
+ # of its pipeline is a sink or a plain filter: cat <<X | bash is code;
383
+ # - nothing in the command runs an interpreter or a script, and there is no
384
+ # <( or >(: cat > x.sh <<X ... && bash x.sh writes code and then runs it;
385
+ # - the tokenizer found exactly as many "<<" as the lift pass cut bodies.
386
+ # When the two passes disagree, nothing is trusted.
387
+ # This is a list of what may pass, not of what must be blocked, so a command
388
+ # it does not know keeps its heredoc judged as code.
389
+ function decide_heredocs(s, k, allcode) {
390
+ if (PBN == 0) return
391
+ allcode = (CURQI != 1 || HOPN != PBN || index(s, "<(") || index(s, ">(") || item_runs())
392
+ ENQ_HD = 1
393
+ for (k = 1; k <= PBN; k++)
394
+ if (allcode || !heredoc_is_data(HOPAT[k])) enqueue(PB[k])
395
+ ENQ_HD = 0
396
+ }
397
+
398
+ function base(w) { w = tolower(w); sub(/.*[\/\\]/, "", w); sub(/\.exe$/, "", w); return w }
399
+
400
+ # Index of the command word of stage T[a..b], past VAR=value assignments and
401
+ # wrappers such as sudo or env; 0 when there is none.
402
+ function cmd_at(a, b, k) {
403
+ k = a
404
+ while (k <= b) {
405
+ if (T[k] ~ /^[A-Za-z_][A-Za-z0-9_]*=/) { k++; continue }
406
+ if (T[k] ~ /^(sudo|env|nohup|time|command|nice)$/) { k++; while (k <= b && T[k] ~ /^-/) k++; continue }
407
+ return k
408
+ }
409
+ return 0
410
+ }
411
+
412
+ # A file that will be run later: a script extension, or a hooks/ or bin/ path.
413
+ function script_like(w) {
414
+ return (w ~ /\.(sh|bash|zsh|py|pl|rb|js|mjs|cjs|ts|ps1|psm1|bat|cmd)$/ || w ~ /(^|[\/\\])(hooks|\.husky|bin)[\/\\]/)
415
+ }
416
+
417
+ function sink_stage(a, b, k, c, j) {
418
+ k = cmd_at(a, b); if (!k) return 0
419
+ c = base(T[k])
420
+ # cat > x.sh <<EOF writes a script: that body is code, run now or later
421
+ for (j = 1; j <= RN; j++) if (RDA[j] >= a && RDA[j] <= b && script_like(RDT[j])) return 0
422
+ if (c == "cat") return 1
423
+ if (c == "tee") { for (j = k + 1; j <= b; j++) if (script_like(T[j])) return 0; return 1 }
424
+ if (c != "git") return 0
425
+ for (j = k + 1; j <= b && T[j] != "commit"; j++) ;
426
+ for (; j <= b; j++)
427
+ if ((T[j] ~ /^(-F|--file)$/ && T[j + 1] == "-") || T[j] == "--file=-" || T[j] == "-F-") return 1
428
+ return 0
429
+ }
430
+
431
+ function filter_stage(a, b, k) {
432
+ k = cmd_at(a, b)
433
+ return k && base(T[k]) ~ /^(grep|egrep|fgrep|head|tail|wc|sort|uniq|cut|tr)$/
434
+ }
435
+
436
+ # Does anything in this command run code? Any word naming an interpreter, a
437
+ # ./path, or a stage whose command is a script file.
438
+ function item_runs( k, a, c) {
439
+ for (k = 1; k <= TN; k++)
440
+ if (TY[k] == "w" && (base(T[k]) ~ RUNNERS || T[k] ~ /^\.\.?[\/\\]/)) return 1
441
+ a = 1
442
+ for (k = 1; k <= TN + 1; k++) {
443
+ if (k > TN || TY[k] != "w") {
444
+ if (a < k && (c = cmd_at(a, k - 1)) && T[c] ~ /\.(sh|bash|zsh|py|pl|rb|js|mjs|ts|ps1|bat|cmd)$/) return 1
445
+ a = k + 1
446
+ }
447
+ }
448
+ return 0
449
+ }
450
+
451
+ function heredoc_is_data(p, a, b, k, st, own) {
452
+ if (p < 1 || TY[p] != "w") p++ # "<<" before any word: the next word owns it
453
+ if (p > TN || TY[p] != "w") return 0
454
+ a = p; while (a > 1 && (TY[a - 1] == "w" || TY[a - 1] == "|")) a--
455
+ b = p; while (b < TN && (TY[b + 1] == "w" || TY[b + 1] == "|")) b++
456
+ own = 0; st = a
457
+ for (k = a; k <= b + 1; k++) {
458
+ if (k > b || TY[k] == "|") {
459
+ if (st <= p && p <= k - 1) { if (!sink_stage(st, k - 1)) return 0; own = 1 }
460
+ else if (own && !(sink_stage(st, k - 1) || filter_stage(st, k - 1))) return 0
461
+ st = k + 1
462
+ }
463
+ }
464
+ return own
465
+ }
466
+
467
+ # ── 4. Judging ────────────────────────────────────────────────────────────
468
+ function is_git(w, lw) {
469
+ lw = tolower(w)
470
+ return (lw == "git" || lw == "git.exe" || lw ~ /[\/\\]git(\.exe)?$/)
471
+ }
472
+
473
+ # The last stage of a pipeline runs a hook script (the documented way to test
474
+ # a hook): its echo/printf/cat stages are payload, not commands.
475
+ function is_hook_stage(a, b, w) {
476
+ while (a <= b && T[a] ~ /^[A-Za-z_][A-Za-z0-9_]*=/) a++ # VAR=value prefixes, e.g. PATH=... bash hook.sh
477
+ if (a > b) return 0
478
+ w = T[a]
479
+ if (w == "bash" || w == "sh") { if (a + 1 > b) return 0; w = T[a + 1] }
480
+ return (w ~ /(^|\/)hooks\/[a-z-]+\.sh$/)
481
+ }
482
+
483
+ function judge_all( k, nst, s, hook) {
484
+ k = 1
485
+ while (k <= TN && VERDICT == "") {
486
+ nst = 0
487
+ while (1) {
488
+ nst++; SA[nst] = k
489
+ while (k <= TN && TY[k] == "w") k++
490
+ SB[nst] = k - 1
491
+ if (k <= TN && TY[k] == "|") { k++; continue }
492
+ break
493
+ }
494
+ hook = is_hook_stage(SA[nst], SB[nst])
495
+ for (s = 1; s <= nst && VERDICT == ""; s++) {
496
+ if (hook && s < nst && SA[s] <= SB[s] && T[SA[s]] ~ /^(echo|printf|cat)$/) { queue_lifted(SA[s], SB[s]); continue }
497
+ judge_stage(SA[s], SB[s])
498
+ }
499
+ k++ # past the operator
500
+ }
501
+ }
502
+
503
+ # A word holding the SUBST placeholder had a substitution lifted out of it by
504
+ # quote-blind matching. If that matching went wrong, the rest of the
505
+ # substitution now sits inside this word, so its quoted text is queued even
506
+ # where it would otherwise be skipped: a message argument, an exempt stage.
507
+ function queue_lifted(a, b, k) {
508
+ for (k = a; k <= b; k++) if (TQ[k] != "" && index(T[k], "SUBST")) enqueue(TQ[k])
509
+ }
510
+
511
+ function judge_stage(a, b, k, e) {
512
+ for (k = a; k <= b; k++) if (TQ[k] != "" && (!TM[k] || index(T[k], "SUBST"))) enqueue(TQ[k])
513
+ for (k = a; k <= b && VERDICT == ""; k++) {
514
+ if (!is_git(T[k])) continue
515
+ e = k + 1
516
+ while (e <= b && !is_git(T[e])) e++
517
+ judge_git(k + 1, e - 1)
518
+ k = e - 1
519
+ }
520
+ }
521
+
522
+ # Global options: these take the next word unless written with "=" (checked
523
+ # against git 2.50.1). Any other option is skipped on its own — but if it is a
524
+ # long option this list does not know, the word after it might be its value,
525
+ # so the word after that is ALSO tried as the subcommand.
526
+ function judge_git(a, b, j, w, amb) {
527
+ j = a; amb = 0
528
+ while (j <= b) {
529
+ w = T[j]
530
+ if (w ~ /^(-C|-c|--git-dir|--work-tree|--namespace|--config-env|--attr-source)$/) { j += 2; amb = 0; continue }
531
+ if (w ~ /^-/) {
532
+ amb = (w ~ /^--/ && w !~ /=/ && w !~ /^--(paginate|no-pager|no-replace-objects|no-lazy-fetch|no-optional-locks|no-advice|bare|exec-path|html-path|man-path|info-path|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs|version|help)$/)
533
+ j++; continue
534
+ }
535
+ break
536
+ }
537
+ if (j > b) return
538
+ judge_sub(T[j], j + 1, b)
539
+ if (VERDICT == "" && amb && j + 1 <= b) judge_sub(T[j + 1], j + 2, b)
540
+ }
541
+
542
+ function judge_sub(name, a, b, k, w, dd, pn, i, nm, force, staged, wt, r, L, sv, lv) {
543
+ if (name !~ /^(checkout|restore|reset|clean|branch|push|stash)$/) return
544
+ # options that take a value: without this, "push -f -o ci.skip origin"
545
+ # reads ci.skip as the remote and origin as a branch
546
+ sv = ""; lv = "^$"
547
+ if (name == "push") { sv = "o"; lv = "^--(push-option|repo|receive-pack|exec)$" }
548
+ split("", F); split("", P); pn = 0; dd = 0
549
+ for (k = a; k <= b; k++) {
550
+ w = T[k]
551
+ if (dd) { pn++; P[pn] = w; continue }
552
+ if (w == "--") { dd = 1; F["--"] = 1; continue }
553
+ if (w ~ /^--/) { nm = w; sub(/=.*/, "", nm); F[nm] = 1; if (w !~ /=/ && nm ~ lv) k++; continue }
554
+ if (w ~ /^-[A-Za-z0-9]+$/) { # a cluster: -fd, -uf, -f4
555
+ for (i = 2; i <= length(w); i++) {
556
+ L = substr(w, i, 1); F["-" L] = 1
557
+ if (index(sv, L)) { if (i == length(w)) k++; break } # the rest, or the next word, is its value
558
+ }
559
+ continue
560
+ }
561
+ if (w ~ /^-./) { F[w] = 1; continue }
562
+ pn++; P[pn] = w
563
+ }
564
+ if (name == "checkout") {
565
+ # a path checkout overwrites the working tree; branch names cannot start with "."
566
+ if (F["--"] || F["-f"] || F["--force"]) return block("git checkout (discards uncommitted changes)", "git stash")
567
+ for (i = 1; i <= pn; i++) if (P[i] ~ /^\./) return block("git checkout (discards uncommitted changes)", "git stash")
568
+ return
569
+ }
570
+ if (name == "restore") {
571
+ staged = (F["-S"] || F["--staged"]) && !F["--no-staged"]
572
+ wt = F["-W"] || F["--worktree"]
573
+ if (!(staged && !wt)) block("git restore (discards uncommitted changes)", "git stash")
574
+ return
575
+ }
576
+ if (name == "reset") {
577
+ if (F["--hard"]) block("git reset --hard (destroys all uncommitted changes)", "git stash && git reset")
578
+ return
579
+ }
580
+ if (name == "clean") {
581
+ if (F["-f"] || F["--force"]) block("git clean -f (permanently deletes untracked files)", "git stash --include-untracked")
582
+ return
583
+ }
584
+ if (name == "branch") {
585
+ if (F["-D"] || ((F["-d"] || F["--delete"]) && (F["-f"] || F["--force"])))
586
+ block("git branch -D (deletes branch even if not merged)", "git branch -d (safe delete, fails if not merged)")
587
+ return
588
+ }
589
+ if (name == "stash") {
590
+ # The hint for reset --hard is "git stash first", which makes the stash
591
+ # the backup; dropping it is the same loss one step later.
592
+ if (pn >= 1 && P[1] ~ /^(drop|clear)$/)
593
+ block("git stash " P[1] " (permanently deletes stashed changes)", "git stash list and git stash show -p to see what it holds (a dropped stash can only be recovered with git fsck)")
594
+ return
595
+ }
596
+ # push: first positional is the remote, the rest are refspecs. -f/--force
597
+ # forces every refspec; a leading + forces that one. --force-with-lease and
598
+ # --force-if-includes are the safe forms and do not count. The main/master
599
+ # test stays unanchored on purpose: anchoring it would let refs/heads/main
600
+ # through, and a miss costs the user's history.
601
+ force = F["-f"] || F["--force"]
602
+ if (force && pn <= 1) return block("git push --force with no explicit branch (the target may be main/master)", "name the branch, or use git push --force-with-lease")
603
+ for (i = 2; i <= pn; i++) {
604
+ r = P[i]
605
+ if ((force || r ~ /^\+/) && r ~ /main|master/) return block("git push --force to main/master (rewrites shared history)", "git push --force-with-lease")
606
+ }
607
+ }
608
+
609
+ # ── Main ──────────────────────────────────────────────────────────────────
610
+ BEGIN {
611
+ QMAX = 1000; QN = 0; OVERFLOW = 0; VERDICT = ""; ENQ_HD = 0; CURHD = 0
612
+ HDNOTE = "This was inside a heredoc that gets run (fed to an interpreter such as python or bash, or written as a script), which this guard judges as code. If the script only edits files and merely names the command, write it to a file with the Write tool and run that file instead."
613
+ HAVETOOL = 0; TOOL = ""; HAVECMD = 0; CMD = ""
614
+ MSGPOS = "(^|[ \t\n])(-m|--message|-F|--file)(=|[ \t]*(\\\\\n[ \t]*)?)\"?$"
615
+ MSGTAIL = "[ \t\n](-m|--message|-F|--file)(=|[ \t]*(\\\\\n[ \t]*)?)\"?$"
616
+ STOP = " \t\r\n;&|()<>'\"\\`" # characters that end an unquoted run
617
+ RUNNERS = "^(bash|sh|zsh|dash|ksh|fish|ash|busybox|python[0-9.]*|perl|ruby|node|nodejs|deno|bun|php|lua|tclsh|osascript|pwsh|powershell|cmd|ssh|xargs|parallel|eval|exec|source|\\.)$"
618
+ }
619
+ { PAYLOAD = (NR == 1) ? $0 : PAYLOAD "\n" $0 }
620
+ END {
621
+ parse_payload(PAYLOAD)
622
+ if (HAVETOOL && TOOL != "Bash") { print "OK"; exit }
623
+ if (!HAVECMD) CMD = PAYLOAD # cannot isolate the command: scan it all
624
+ enqueue(CMD)
625
+ for (qi = 1; qi <= QN && VERDICT == ""; qi++) {
626
+ CURQI = qi; CURHD = QH[qi]
627
+ LIFTED = lift_substitutions(Q[qi], 0)
628
+ tokenize(LIFTED)
629
+ decide_heredocs(LIFTED)
630
+ judge_all()
631
+ }
632
+ if (VERDICT != "") print VERDICT
633
+ else if (!OVERFLOW) print "OK"
634
+ # overflow with no verdict prints nothing: the wrapper's coarse check decides
635
+ }
636
+ AWK
637
+
638
+ # Payloads over 64 KB skip the parser. BSD awk's substr() costs time in
639
+ # proportion to the whole string (measured: 17x slower on a 200 KB string than
640
+ # on a 2 KB one), so the character scan is quadratic there: a 1 MB
641
+ # heredoc took 156 s on macOS, past any hook timeout — and a timed-out hook
642
+ # lets the command run. They get the coarse check below instead.
643
+ if [ ${#INPUT} -gt 65536 ]; then
644
+ VERDICT=TOOLONG
645
+ else
646
+ VERDICT=$(awk "$PROG" <<<"$INPUT" 2>/dev/null)
130
647
  fi
648
+ TAB=$'\t'
649
+ NOTE="" # never inherited from the environment
650
+
651
+ case "$VERDICT" in
652
+ OK)
653
+ exit 0 ;;
654
+ "BLOCK$TAB"*)
655
+ REST=${VERDICT#BLOCK$TAB}
656
+ BLOCKED=${REST%%$TAB*}
657
+ REST=${REST#*$TAB}
658
+ SUGGESTION=${REST%%$TAB*}
659
+ case "$REST" in *"$TAB"*) NOTE=${REST#*$TAB} ;; esac ;;
660
+ *)
661
+ # No verdict from the parser, or too long to parse. Fail loud, but only
662
+ # when the payload mentions git and a guarded subcommand at all — a
663
+ # broken parser must not block every Bash call.
664
+ if printf '%s' "$INPUT" | grep -qiE 'git.*(checkout|restore|reset|clean|branch|push)'; then
665
+ if [ "$VERDICT" = TOOLONG ]; then
666
+ BLOCKED="a command over 64 KB that names git and a subcommand that can destroy work (too long to parse; write large files with the Write tool instead)"
667
+ else
668
+ BLOCKED="a git command that git-guard could not parse (it names a subcommand that can destroy work)"
669
+ fi
670
+ SUGGESTION="git stash"
671
+ else
672
+ exit 0
673
+ fi ;;
674
+ esac
131
675
 
132
- exit 0
676
+ echo "Git safety check: Blocked $BLOCKED. This is an irreversible operation — uncommitted work would be lost. Before proceeding: (1) Check git status and git diff to see what would be affected. (2) If changes should be kept, run: $SUGGESTION first. (3) If you're certain the changes should be discarded, tell the user what will be lost and ask for explicit confirmation.${NOTE:+ $NOTE}" >&2
677
+ exit 2