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.
- package/README.md +11 -8
- package/README.zh-CN.md +11 -10
- package/bin/cli.js +6 -1
- package/global/CLAUDE.md +3 -3
- package/init.sh +181 -107
- package/lib/doctor.sh +26 -16
- package/lib/hook-hashes +26 -0
- package/lib/status.sh +5 -0
- package/package.json +1 -1
- package/templates/.claude/hooks/git-guard.sh +662 -117
- package/templates/.claude/hooks/pre-edit-guard.sh +2 -1
- package/templates/.claude/hooks/session-start.sh +192 -14
- package/templates/.claude/rules/00-core-principles.md +0 -5
- package/templates/.claude/rules/03-context-mgmt.md +5 -21
- package/templates/.claude/rules/04-no-mole-whacking.md +1 -10
- package/templates/.claude/rules/06-multi-task.md +5 -6
- package/templates/.claude/rules/07-integrity.md +1 -6
- package/templates/.claude/rules/stacks/embedded.md +8 -0
- package/templates/.claude/rules/stacks/js-ts.md +7 -0
- package/templates/.claude/rules/stacks/mobile.md +7 -0
- package/templates/.claude/rules/stacks/python.md +9 -0
- package/templates/.claude/rules/stacks/rtl.md +6 -0
- package/templates/.claude/skills/commit/SKILL.md +2 -0
- package/templates/.claude/skills/coplan/SKILL.md +4 -2
- package/templates/.claude/skills/self-check/SKILL.md +10 -2
- package/templates/.claude/skills/summary/SKILL.md +2 -2
- package/templates/CLAUDE.md +1 -0
- package/templates/docs/progress.md +2 -1
- package/templates/docs/todo.md +15 -0
- package/templates/.claude/rules/02-before-edit.md +0 -11
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
45
|
+
IFS= read -r -d '' PROG <<'AWK'
|
|
46
|
+
function has_git(s) { return index(tolower(s), "git") > 0 }
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
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
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|