@veda-hq/kavach 0.0.0-stage → 0.5.6
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/LICENSE +21 -0
- package/README.md +134 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +675 -0
|
@@ -0,0 +1,1980 @@
|
|
|
1
|
+
"""Git hook-bypass and history-rewrite analysis for the command guard.
|
|
2
|
+
|
|
3
|
+
``command_guard.py`` runs two checks and the command runs only when both allow
|
|
4
|
+
it: ``bash_guard.py`` first (the protected-variable refusal, the paired-change
|
|
5
|
+
index model and its own hook-bypass and force-push rules), then ``hook_main``
|
|
6
|
+
here. The paired-change rule is ``bash_guard.py``'s, except for a commit moved
|
|
7
|
+
by ``env -C``, which that module's model does not follow
|
|
8
|
+
(``env_chdir_paired``). The guard is run against a shared adversarial corpus
|
|
9
|
+
(``corpus/``), whose every row is executed against real bash and git; this
|
|
10
|
+
module exists because the text-matching guards were bypassable in the same
|
|
11
|
+
ways:
|
|
12
|
+
|
|
13
|
+
* ``git -c core.hooksPath=/dev/null commit`` (the catalogue example),
|
|
14
|
+
``--config-env``, ``GIT_CONFIG_*`` in the environment, a persisted
|
|
15
|
+
``git config core.hooksPath`` and ``-c alias.x='commit --no-verify'``;
|
|
16
|
+
* ``'--no-verify'`` in quotes (the old guard deleted quoted text before
|
|
17
|
+
looking), ``$'--no-verify'``, ``--no-veri`` (git accepts an unambiguous
|
|
18
|
+
abbreviation);
|
|
19
|
+
* the command inside ``bash -c``, ``eval``, ``$(...)``, backticks, a heredoc
|
|
20
|
+
or a pipe into a shell.
|
|
21
|
+
|
|
22
|
+
What it does NOT claim, stated so nobody reads more into a green corpus:
|
|
23
|
+
|
|
24
|
+
* Commands that never run hooks by design (``commit-tree``, ``cherry-pick``,
|
|
25
|
+
``rebase``) and edits to the tracked hook files themselves are out of
|
|
26
|
+
scope: pre-push and CI still run on everything a push carries.
|
|
27
|
+
* A script FILE executed by name (``bash scripts/x.sh``) is committed code
|
|
28
|
+
that pre-commit and review see; only programs written inline are analysed.
|
|
29
|
+
* Code inside a non-shell interpreter (``python3 -c``) is scanned for the
|
|
30
|
+
telltale words only; the worktree guard (``worktree_guard.py``) refuses
|
|
31
|
+
inline interpreter programs outright.
|
|
32
|
+
|
|
33
|
+
Exit contract (``hook_main``): SystemExit(2) = block with a reason on stderr,
|
|
34
|
+
SystemExit(0) = allow. Malformed hook input is a refusal, never a pass.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import posixpath
|
|
42
|
+
import re
|
|
43
|
+
import shlex
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from typing import Any, Sequence
|
|
48
|
+
|
|
49
|
+
MAX_DEPTH = 6
|
|
50
|
+
|
|
51
|
+
HOOK_REMEDY = (
|
|
52
|
+
"Hooks auto-validate every commit and push; fix what they report instead "
|
|
53
|
+
"of bypassing them."
|
|
54
|
+
)
|
|
55
|
+
FORCE_REMEDY = (
|
|
56
|
+
"Never rewrite a pushed branch. To refresh it against main, merge instead:\n"
|
|
57
|
+
" git fetch origin main && git merge origin/main\n"
|
|
58
|
+
"then resolve any conflicts, commit, and `git push` normally.\n"
|
|
59
|
+
"If a history rewrite is genuinely unavoidable, stop and ask the user to run it."
|
|
60
|
+
)
|
|
61
|
+
AMBIGUOUS_REMEDY = (
|
|
62
|
+
"This guard must read the target repository's push config to prove the push "
|
|
63
|
+
"is not forced, and a shell-expanded path cannot be resolved statically.\n"
|
|
64
|
+
"Re-run it with a literal path (or from inside the target repo) so the check "
|
|
65
|
+
"can see the right repository."
|
|
66
|
+
)
|
|
67
|
+
PAIRED_REMEDY = (
|
|
68
|
+
"Stage the required change with it, running git from inside that repository. "
|
|
69
|
+
"If this change truly needs none, ask the user before committing it alone."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Blocked(Exception):
|
|
74
|
+
"""Raised with the reason a command is refused."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Lexing: the subset of bash that decides which words a command receives.
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Word:
|
|
84
|
+
"""One shell word after quote removal.
|
|
85
|
+
|
|
86
|
+
``dynamic`` means the shell would compute part of it at run time
|
|
87
|
+
(a parameter, a substitution, an unquoted glob or brace expansion), so its
|
|
88
|
+
final value is unknowable here. ``bodies`` are the programs of the command
|
|
89
|
+
substitutions inside it, which run no matter where the word lands.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
text: str = ""
|
|
93
|
+
dynamic: bool = False
|
|
94
|
+
quoted: bool = False
|
|
95
|
+
bodies: list[str] = field(default_factory=list)
|
|
96
|
+
started: bool = False
|
|
97
|
+
# Offset in ``text`` of the first quoted or escaped character: bash treats
|
|
98
|
+
# ``NAME=value`` as an assignment only when nothing before ``=`` is quoted.
|
|
99
|
+
first_quoted: int | None = None
|
|
100
|
+
# Offset of the first character computed at run time.
|
|
101
|
+
first_dynamic: int | None = None
|
|
102
|
+
glob: bool = False
|
|
103
|
+
glob_leading: bool = False
|
|
104
|
+
# An unquoted `{a,b}` / `{1..3}`: bash turns the word into several.
|
|
105
|
+
braces: bool = False
|
|
106
|
+
|
|
107
|
+
def could_be_flag(self) -> bool:
|
|
108
|
+
"""Might the shell turn this word into something starting with '-'/'+'?"""
|
|
109
|
+
|
|
110
|
+
return self.dynamic or self.glob_leading or (self.glob and self.text[:1] in "-+")
|
|
111
|
+
|
|
112
|
+
def is_assignment(self) -> str | None:
|
|
113
|
+
match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\+?=.*", self.text, re.S)
|
|
114
|
+
if match is None:
|
|
115
|
+
return None
|
|
116
|
+
if self.first_quoted is not None and self.first_quoted <= self.text.index("="):
|
|
117
|
+
return None
|
|
118
|
+
return match.group(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class Command:
|
|
123
|
+
"""A simple command: its words, the programs it feeds a shell, its scope."""
|
|
124
|
+
|
|
125
|
+
words: list[Word] = field(default_factory=list)
|
|
126
|
+
redirect_targets: list[Word] = field(default_factory=list)
|
|
127
|
+
# `< file` and `<& fd`: read, never written.
|
|
128
|
+
input_targets: list[Word] = field(default_factory=list)
|
|
129
|
+
heredocs: list[str] = field(default_factory=list)
|
|
130
|
+
# $(…) and `…` bodies inside heredocs whose delimiter is unquoted.
|
|
131
|
+
expanded_bodies: list[str] = field(default_factory=list)
|
|
132
|
+
herestrings: list[Word] = field(default_factory=list)
|
|
133
|
+
piped_from: Command | None = None
|
|
134
|
+
# Parenthesised subshell depth, so `( cd x && ... ); git push` scopes cd.
|
|
135
|
+
depth: int = 0
|
|
136
|
+
# The operator that ended the command before this one ('' for the first):
|
|
137
|
+
# `&&` means this one runs only if that one succeeded.
|
|
138
|
+
follows: str = ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
ANSI_SIMPLE = {
|
|
142
|
+
"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", "n": "\n",
|
|
143
|
+
"r": "\r", "t": "\t", "v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def decode_ansi_c(text: str, start: int) -> tuple[str, int]:
|
|
148
|
+
"""Decode a ``$'...'`` body starting after the quote; return (value, end)."""
|
|
149
|
+
|
|
150
|
+
out: list[str] = []
|
|
151
|
+
index = start
|
|
152
|
+
while index < len(text):
|
|
153
|
+
char = text[index]
|
|
154
|
+
if char == "'":
|
|
155
|
+
# Bash ends the expansion at its first NUL: $'--force\000x' is --force.
|
|
156
|
+
value = "".join(out)
|
|
157
|
+
return value.split("\0", 1)[0], index + 1
|
|
158
|
+
if char != "\\" or index + 1 >= len(text):
|
|
159
|
+
out.append(char)
|
|
160
|
+
index += 1
|
|
161
|
+
continue
|
|
162
|
+
code = text[index + 1]
|
|
163
|
+
index += 2
|
|
164
|
+
if code in ANSI_SIMPLE:
|
|
165
|
+
out.append(ANSI_SIMPLE[code])
|
|
166
|
+
elif code in "01234567":
|
|
167
|
+
digits = code
|
|
168
|
+
while len(digits) < 3 and index < len(text) and text[index] in "01234567":
|
|
169
|
+
digits += text[index]
|
|
170
|
+
index += 1
|
|
171
|
+
out.append(chr(int(digits, 8) & 0xFF)) # bash keeps 8 bits: \455 is "-"
|
|
172
|
+
elif code in "xuU":
|
|
173
|
+
limit = {"x": 2, "u": 4, "U": 8}[code]
|
|
174
|
+
digits = ""
|
|
175
|
+
while (
|
|
176
|
+
len(digits) < limit
|
|
177
|
+
and index < len(text)
|
|
178
|
+
and text[index] in "0123456789abcdefABCDEF"
|
|
179
|
+
):
|
|
180
|
+
digits += text[index]
|
|
181
|
+
index += 1
|
|
182
|
+
out.append(chr(int(digits, 16)) if digits else "\\" + code)
|
|
183
|
+
elif code == "c" and index < len(text):
|
|
184
|
+
out.append(chr(ord(text[index]) & 0x1F))
|
|
185
|
+
index += 1
|
|
186
|
+
else:
|
|
187
|
+
out.append("\\" + code)
|
|
188
|
+
raise Blocked("unterminated $'...' quoting")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
HEREDOC_OPEN = re.compile(r"<<(-?)[ \t]*(?:'([^']*)'|\"([^\"]*)\"|\\?([A-Za-z0-9_.-]+))")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def skip_heredoc_bodies(text: str, index: int, pending: list[tuple[str, bool]]) -> int:
|
|
195
|
+
"""From just after a newline, skip the bodies of ``pending`` heredocs."""
|
|
196
|
+
|
|
197
|
+
cursor = index
|
|
198
|
+
for delimiter, strip in pending:
|
|
199
|
+
while cursor < len(text):
|
|
200
|
+
end = text.find("\n", cursor)
|
|
201
|
+
end = len(text) if end < 0 else end
|
|
202
|
+
line = text[cursor:end]
|
|
203
|
+
cursor = end + 1
|
|
204
|
+
if (line.lstrip("\t") if strip else line) == delimiter:
|
|
205
|
+
break
|
|
206
|
+
pending.clear()
|
|
207
|
+
return min(cursor, len(text))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def substitutions_in(text: str) -> list[str]:
|
|
211
|
+
"""The ``$(…)`` and backquote bodies an expanding heredoc runs.
|
|
212
|
+
|
|
213
|
+
Only a backslash escapes here: quotes in a heredoc body are literal text,
|
|
214
|
+
so ``'$(x)'`` still runs ``x``. ``$((…))`` is arithmetic, not a command.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
bodies: list[str] = []
|
|
218
|
+
index = 0
|
|
219
|
+
while index < len(text):
|
|
220
|
+
char = text[index]
|
|
221
|
+
if char == "\\":
|
|
222
|
+
index += 2
|
|
223
|
+
continue
|
|
224
|
+
if text.startswith("$((", index):
|
|
225
|
+
index += 3
|
|
226
|
+
continue
|
|
227
|
+
if text.startswith("$(", index):
|
|
228
|
+
end = matching_close(text, index + 2, "(", ")")
|
|
229
|
+
bodies.append(text[index + 2 : end - 1])
|
|
230
|
+
index = end
|
|
231
|
+
continue
|
|
232
|
+
if char == "`":
|
|
233
|
+
end = text.find("`", index + 1)
|
|
234
|
+
if end < 0:
|
|
235
|
+
raise Blocked("unterminated backquote in a heredoc")
|
|
236
|
+
bodies.append(text[index + 1 : end])
|
|
237
|
+
index = end + 1
|
|
238
|
+
continue
|
|
239
|
+
index += 1
|
|
240
|
+
return bodies
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def matching_close(text: str, start: int, opener: str, closer: str) -> int:
|
|
244
|
+
"""Index just past the ``closer`` that balances an ``opener`` before ``start``.
|
|
245
|
+
|
|
246
|
+
Quotes and nested substitutions inside are respected, so
|
|
247
|
+
``$(echo ")")`` closes at the second parenthesis.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
depth = 1
|
|
251
|
+
index = start
|
|
252
|
+
quote = ""
|
|
253
|
+
heredocs: list[tuple[str, bool]] = []
|
|
254
|
+
while index < len(text):
|
|
255
|
+
char = text[index]
|
|
256
|
+
if char == "\n" and not quote and heredocs:
|
|
257
|
+
index = skip_heredoc_bodies(text, index + 1, heredocs)
|
|
258
|
+
continue
|
|
259
|
+
if quote == "'":
|
|
260
|
+
if char == "'":
|
|
261
|
+
quote = ""
|
|
262
|
+
elif char == "\\":
|
|
263
|
+
index += 2
|
|
264
|
+
continue
|
|
265
|
+
elif quote == '"':
|
|
266
|
+
if char == '"':
|
|
267
|
+
quote = ""
|
|
268
|
+
elif char == "$" and text[index + 1 : index + 2] == "(":
|
|
269
|
+
index = matching_close(text, index + 2, "(", ")")
|
|
270
|
+
continue
|
|
271
|
+
elif char in "'\"":
|
|
272
|
+
quote = char
|
|
273
|
+
elif text.startswith("<<", index) and not text.startswith("<<<", index):
|
|
274
|
+
# A heredoc body is data: an apostrophe in `$(cat <<'EOF' ... EOF)`
|
|
275
|
+
# must not open a quote that swallows the closing parenthesis.
|
|
276
|
+
match = HEREDOC_OPEN.match(text, index)
|
|
277
|
+
if match is None:
|
|
278
|
+
index += 2
|
|
279
|
+
continue
|
|
280
|
+
delimiter = next(group for group in match.groups()[1:] if group is not None)
|
|
281
|
+
heredocs.append((delimiter, match.group(1) == "-"))
|
|
282
|
+
index = match.end()
|
|
283
|
+
continue
|
|
284
|
+
elif char == "`":
|
|
285
|
+
end = text.find("`", index + 1)
|
|
286
|
+
if end < 0:
|
|
287
|
+
raise Blocked("unterminated backquote")
|
|
288
|
+
index = end + 1
|
|
289
|
+
continue
|
|
290
|
+
elif char == opener:
|
|
291
|
+
depth += 1
|
|
292
|
+
elif char == closer:
|
|
293
|
+
depth -= 1
|
|
294
|
+
if depth == 0:
|
|
295
|
+
return index + 1
|
|
296
|
+
index += 1
|
|
297
|
+
raise Blocked(f"unbalanced '{opener}'")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
OPERATORS = ("&&", "||", "|&", ";;", ";&", "|", "&", ";", "(", ")", "\n")
|
|
301
|
+
REDIRECTS = ("&>>", "<<<", "<<-", ">>", "<<", "<>", ">&", "<&", ">|", "&>", "<", ">")
|
|
302
|
+
NAME_CHAR = re.compile(r"[A-Za-z0-9_]")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class Lexer:
|
|
306
|
+
"""Turn a script into Commands, following bash closely where it matters."""
|
|
307
|
+
|
|
308
|
+
def __init__(self, text: str) -> None:
|
|
309
|
+
self.text = text
|
|
310
|
+
self.index = 0
|
|
311
|
+
self.commands: list[Command] = []
|
|
312
|
+
self.current = Command()
|
|
313
|
+
self.word = Word()
|
|
314
|
+
self.depth = 0
|
|
315
|
+
# (delimiter, strip tabs, command, body expands: delimiter unquoted)
|
|
316
|
+
self.pending_heredocs: list[tuple[str, bool, Command, bool]] = []
|
|
317
|
+
self.pipe_next = False
|
|
318
|
+
self.expect: str | None = None # "redirect" | "heredoc" | "herestring"
|
|
319
|
+
self.heredoc_strip = False
|
|
320
|
+
|
|
321
|
+
# -- word assembly -----------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def add(self, text: str, *, dynamic: bool = False, quoted: bool = False) -> None:
|
|
324
|
+
if (quoted or dynamic) and self.word.first_quoted is None:
|
|
325
|
+
self.word.first_quoted = len(self.word.text)
|
|
326
|
+
if dynamic and self.word.first_dynamic is None:
|
|
327
|
+
self.word.first_dynamic = len(self.word.text)
|
|
328
|
+
self.word.text += text
|
|
329
|
+
self.word.dynamic = self.word.dynamic or dynamic
|
|
330
|
+
self.word.quoted = self.word.quoted or quoted
|
|
331
|
+
self.word.started = True
|
|
332
|
+
|
|
333
|
+
def finish_word(self) -> None:
|
|
334
|
+
if not self.word.started:
|
|
335
|
+
return
|
|
336
|
+
word = self.word
|
|
337
|
+
self.word = Word()
|
|
338
|
+
if self.expect == "redirect":
|
|
339
|
+
self.current.redirect_targets.append(word)
|
|
340
|
+
elif self.expect == "input":
|
|
341
|
+
self.current.input_targets.append(word)
|
|
342
|
+
elif self.expect == "herestring":
|
|
343
|
+
self.current.herestrings.append(word)
|
|
344
|
+
elif self.expect == "heredoc":
|
|
345
|
+
self.pending_heredocs.append((word.text, self.heredoc_strip, self.current, not word.quoted))
|
|
346
|
+
else:
|
|
347
|
+
self.current.words.append(word)
|
|
348
|
+
self.expect = None
|
|
349
|
+
|
|
350
|
+
def finish_command(self, operator: str) -> None:
|
|
351
|
+
self.finish_word()
|
|
352
|
+
if (
|
|
353
|
+
self.current.words
|
|
354
|
+
or self.current.redirect_targets
|
|
355
|
+
or self.current.input_targets
|
|
356
|
+
or self.current.herestrings
|
|
357
|
+
):
|
|
358
|
+
self.commands.append(self.current)
|
|
359
|
+
previous: Command | None = self.current
|
|
360
|
+
else:
|
|
361
|
+
previous = None
|
|
362
|
+
# A newline after `&&`, `||` or `|` continues the list (`a &&\n b`).
|
|
363
|
+
follows = operator
|
|
364
|
+
# The pipe's source rolls across the newline with it: in
|
|
365
|
+
# `printf 'git push -f' |\n bash` bash still reads printf's program.
|
|
366
|
+
piped_from: Command | None = None
|
|
367
|
+
if previous is None and operator == "\n" and self.current.follows in ("&&", "||", "|", "|&"):
|
|
368
|
+
follows = self.current.follows
|
|
369
|
+
piped_from = self.current.piped_from
|
|
370
|
+
self.current = Command(depth=self.depth, follows=follows, piped_from=piped_from)
|
|
371
|
+
if operator in ("|", "|&") and previous is not None:
|
|
372
|
+
self.current.piped_from = previous
|
|
373
|
+
|
|
374
|
+
# -- expansions ----------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def dollar(self, in_double: bool) -> None:
|
|
377
|
+
text, index = self.text, self.index
|
|
378
|
+
following = text[index + 1 : index + 2]
|
|
379
|
+
if following == "(":
|
|
380
|
+
# `$((...))` arithmetic closes the same way; its body, analysed as
|
|
381
|
+
# a program, is a harmless subshell unless it nests a `$(...)`.
|
|
382
|
+
end = matching_close(text, index + 2, "(", ")")
|
|
383
|
+
self.word.bodies.append(text[index + 2 : end - 1])
|
|
384
|
+
self.add(text[index:end], dynamic=True)
|
|
385
|
+
self.index = end
|
|
386
|
+
return
|
|
387
|
+
if following == "{":
|
|
388
|
+
end = matching_close(text, index + 2, "{", "}")
|
|
389
|
+
inner = text[index + 2 : end - 1]
|
|
390
|
+
self.word.bodies.extend(substitution_bodies(inner))
|
|
391
|
+
self.add(text[index:end], dynamic=True)
|
|
392
|
+
self.index = end
|
|
393
|
+
return
|
|
394
|
+
if not in_double and following == "'":
|
|
395
|
+
value, end = decode_ansi_c(text, index + 2)
|
|
396
|
+
self.add(value, quoted=True)
|
|
397
|
+
self.index = end
|
|
398
|
+
return
|
|
399
|
+
if not in_double and following == '"':
|
|
400
|
+
# $"..." is looked up in the TEXTDOMAIN message catalog, which can
|
|
401
|
+
# map it to any text (a `+main` refspec included): dynamic. The
|
|
402
|
+
# string itself still lexes as double-quoted for its bodies.
|
|
403
|
+
self.add("", dynamic=True)
|
|
404
|
+
self.index += 1
|
|
405
|
+
return
|
|
406
|
+
if following and (NAME_CHAR.match(following) or following in "@*#?$!-"):
|
|
407
|
+
end = index + 2
|
|
408
|
+
if NAME_CHAR.match(following) and not following.isdigit():
|
|
409
|
+
while end < len(text) and NAME_CHAR.match(text[end]):
|
|
410
|
+
end += 1
|
|
411
|
+
self.add(text[index:end], dynamic=True)
|
|
412
|
+
self.index = end
|
|
413
|
+
return
|
|
414
|
+
self.add("$")
|
|
415
|
+
self.index += 1
|
|
416
|
+
|
|
417
|
+
def backquote(self) -> None:
|
|
418
|
+
end = self.text.find("`", self.index + 1)
|
|
419
|
+
while end > 0 and self.text[end - 1] == "\\":
|
|
420
|
+
end = self.text.find("`", end + 1)
|
|
421
|
+
if end < 0:
|
|
422
|
+
raise Blocked("unterminated backquote")
|
|
423
|
+
body = self.text[self.index + 1 : end].replace("\\`", "`")
|
|
424
|
+
self.word.bodies.append(body)
|
|
425
|
+
self.add(self.text[self.index : end + 1], dynamic=True)
|
|
426
|
+
self.index = end + 1
|
|
427
|
+
|
|
428
|
+
# -- heredocs ------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
def read_heredocs(self) -> None:
|
|
431
|
+
"""At a newline, consume the bodies of the heredocs opened on the line."""
|
|
432
|
+
|
|
433
|
+
for delimiter, strip, command, expands in self.pending_heredocs:
|
|
434
|
+
lines: list[str] = []
|
|
435
|
+
while self.index <= len(self.text):
|
|
436
|
+
end = self.text.find("\n", self.index)
|
|
437
|
+
end = len(self.text) if end < 0 else end
|
|
438
|
+
line = self.text[self.index : end]
|
|
439
|
+
self.index = end + 1
|
|
440
|
+
check = line.lstrip("\t") if strip else line
|
|
441
|
+
if check == delimiter:
|
|
442
|
+
break
|
|
443
|
+
lines.append(check)
|
|
444
|
+
if end >= len(self.text):
|
|
445
|
+
break
|
|
446
|
+
body = "\n".join(lines)
|
|
447
|
+
command.heredocs.append(body)
|
|
448
|
+
if expands:
|
|
449
|
+
# An unquoted delimiter expands the body before the recipient
|
|
450
|
+
# runs: `cat <<EOF` with `$(git commit -n)` inside commits.
|
|
451
|
+
command.expanded_bodies.extend(substitutions_in(body))
|
|
452
|
+
self.pending_heredocs = []
|
|
453
|
+
|
|
454
|
+
# -- main loop -------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
def run(self) -> list[Command]:
|
|
457
|
+
text = self.text
|
|
458
|
+
while self.index < len(text):
|
|
459
|
+
char = text[self.index]
|
|
460
|
+
if char == "\\":
|
|
461
|
+
following = text[self.index + 1 : self.index + 2]
|
|
462
|
+
if following == "\n":
|
|
463
|
+
self.index += 2
|
|
464
|
+
continue
|
|
465
|
+
if not following:
|
|
466
|
+
raise Blocked("trailing backslash")
|
|
467
|
+
self.add(following, quoted=True)
|
|
468
|
+
self.index += 2
|
|
469
|
+
continue
|
|
470
|
+
if char == "'":
|
|
471
|
+
end = text.find("'", self.index + 1)
|
|
472
|
+
if end < 0:
|
|
473
|
+
raise Blocked("unterminated single quote")
|
|
474
|
+
self.add(text[self.index + 1 : end], quoted=True)
|
|
475
|
+
self.index = end + 1
|
|
476
|
+
continue
|
|
477
|
+
if char == '"':
|
|
478
|
+
self.double_quoted()
|
|
479
|
+
continue
|
|
480
|
+
if char == "$":
|
|
481
|
+
self.dollar(in_double=False)
|
|
482
|
+
continue
|
|
483
|
+
if char == "`":
|
|
484
|
+
self.backquote()
|
|
485
|
+
continue
|
|
486
|
+
if char == "#" and not self.word.started:
|
|
487
|
+
end = text.find("\n", self.index)
|
|
488
|
+
self.index = len(text) if end < 0 else end
|
|
489
|
+
continue
|
|
490
|
+
if char in " \t":
|
|
491
|
+
self.finish_word()
|
|
492
|
+
self.index += 1
|
|
493
|
+
continue
|
|
494
|
+
if char in "<>" and text[self.index + 1 : self.index + 2] == "(":
|
|
495
|
+
end = matching_close(text, self.index + 2, "(", ")")
|
|
496
|
+
self.word.bodies.append(text[self.index + 2 : end - 1])
|
|
497
|
+
self.add(text[self.index : end], dynamic=True)
|
|
498
|
+
self.index = end
|
|
499
|
+
continue
|
|
500
|
+
redirect = next((r for r in REDIRECTS if text.startswith(r, self.index)), None)
|
|
501
|
+
if redirect:
|
|
502
|
+
# A digit-only word just before a redirect is its fd, not an argument.
|
|
503
|
+
if self.word.started and self.word.text.isdigit() and not self.word.quoted:
|
|
504
|
+
self.word = Word()
|
|
505
|
+
self.finish_word()
|
|
506
|
+
self.index += len(redirect)
|
|
507
|
+
if redirect in ("<<", "<<-"):
|
|
508
|
+
self.expect = "heredoc"
|
|
509
|
+
self.heredoc_strip = redirect == "<<-"
|
|
510
|
+
elif redirect == "<<<":
|
|
511
|
+
self.expect = "herestring"
|
|
512
|
+
elif redirect in ("<", "<&"):
|
|
513
|
+
self.expect = "input"
|
|
514
|
+
else:
|
|
515
|
+
self.expect = "redirect"
|
|
516
|
+
continue
|
|
517
|
+
operator = next((o for o in OPERATORS if text.startswith(o, self.index)), None)
|
|
518
|
+
if operator:
|
|
519
|
+
self.finish_command(operator)
|
|
520
|
+
self.index += len(operator)
|
|
521
|
+
if operator == "(":
|
|
522
|
+
self.depth += 1
|
|
523
|
+
self.current.depth = self.depth
|
|
524
|
+
elif operator == ")":
|
|
525
|
+
self.depth = max(0, self.depth - 1)
|
|
526
|
+
self.current.depth = self.depth
|
|
527
|
+
if operator == "\n" and self.pending_heredocs:
|
|
528
|
+
self.read_heredocs()
|
|
529
|
+
continue
|
|
530
|
+
if char in "*?[" or (char == "{" and self.brace_expands()):
|
|
531
|
+
# Pathname or brace expansion: the LITERAL prefix survives, so
|
|
532
|
+
# only a word that starts with one could turn into a flag.
|
|
533
|
+
if not self.word.started:
|
|
534
|
+
self.word.glob_leading = True
|
|
535
|
+
self.word.glob = True
|
|
536
|
+
if char == "{":
|
|
537
|
+
self.word.braces = True
|
|
538
|
+
self.add(char)
|
|
539
|
+
self.index += 1
|
|
540
|
+
continue
|
|
541
|
+
self.add(char)
|
|
542
|
+
self.index += 1
|
|
543
|
+
self.finish_command(";")
|
|
544
|
+
if self.pending_heredocs:
|
|
545
|
+
# A heredoc with no body line yet: treat what follows as empty.
|
|
546
|
+
for _delimiter, _strip, command, _expands in self.pending_heredocs:
|
|
547
|
+
command.heredocs.append("")
|
|
548
|
+
return self.commands
|
|
549
|
+
|
|
550
|
+
def brace_expands(self) -> bool:
|
|
551
|
+
end = self.text.find("}", self.index)
|
|
552
|
+
if end < 0:
|
|
553
|
+
return False
|
|
554
|
+
inner = self.text[self.index + 1 : end]
|
|
555
|
+
# A list `{a,b}` or a sequence expression `{1..3}`; not a path.
|
|
556
|
+
listed = "," in inner or re.search(r"\.\.", inner) is not None
|
|
557
|
+
return listed and not re.search(r"[\s;&|]", inner)
|
|
558
|
+
|
|
559
|
+
def double_quoted(self) -> None:
|
|
560
|
+
text = self.text
|
|
561
|
+
self.index += 1
|
|
562
|
+
self.word.quoted = True
|
|
563
|
+
self.word.started = True
|
|
564
|
+
while self.index < len(text):
|
|
565
|
+
char = text[self.index]
|
|
566
|
+
if char == '"':
|
|
567
|
+
self.index += 1
|
|
568
|
+
return
|
|
569
|
+
if char == "\\" and text[self.index + 1 : self.index + 2] in ('$', '`', '"', "\\", "\n"):
|
|
570
|
+
if text[self.index + 1] != "\n":
|
|
571
|
+
self.add(text[self.index + 1], quoted=True)
|
|
572
|
+
self.index += 2
|
|
573
|
+
continue
|
|
574
|
+
if char == "$":
|
|
575
|
+
self.dollar(in_double=True)
|
|
576
|
+
continue
|
|
577
|
+
if char == "`":
|
|
578
|
+
self.backquote()
|
|
579
|
+
continue
|
|
580
|
+
self.add(char, quoted=True)
|
|
581
|
+
self.index += 1
|
|
582
|
+
raise Blocked("unterminated double quote")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def substitution_bodies(text: str) -> list[str]:
|
|
586
|
+
"""Programs of the ``$(...)`` and backquotes inside ``${...}`` text."""
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
lexer = Lexer(f"_ {text}")
|
|
590
|
+
commands = lexer.run()
|
|
591
|
+
except Blocked:
|
|
592
|
+
return [text]
|
|
593
|
+
return [body for command in commands for word in command.words for body in word.bodies]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def parse(script: str) -> list[Command]:
|
|
597
|
+
return Lexer(script).run()
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# Analysis.
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "mksh", "fish"}
|
|
605
|
+
# Script operands that name the shell's own stdin.
|
|
606
|
+
STDIN_SCRIPTS = {"-", "/dev/stdin", "/dev/fd/0", "/proc/self/fd/0"}
|
|
607
|
+
# `source -` reads a file named "-": only a shell takes a lone - as stdin.
|
|
608
|
+
SOURCED_STDIN = STDIN_SCRIPTS - {"-"}
|
|
609
|
+
INLINE_INTERPRETERS = {
|
|
610
|
+
"python", "python2", "python3", "node", "nodejs", "bun", "deno", "perl",
|
|
611
|
+
"ruby", "php", "lua", "osascript", "pwsh", "tsx", "ts-node",
|
|
612
|
+
}
|
|
613
|
+
WRAPPERS = {
|
|
614
|
+
"command", "builtin", "exec", "nohup", "time", "nice", "sudo", "doas",
|
|
615
|
+
"env", "timeout", "stdbuf", "caffeinate", "noglob", "then", "do", "else",
|
|
616
|
+
"elif", "if", "while", "until", "!", "{", "}",
|
|
617
|
+
}
|
|
618
|
+
CONTROL_WORDS = {"fi", "done", "esac", "in"}
|
|
619
|
+
# Reserved words take no options: `if -x` runs a command named -x.
|
|
620
|
+
KEYWORD_WRAPPERS = {"then", "do", "else", "elif", "if", "while", "until", "!", "{", "}"}
|
|
621
|
+
# Each wrapper's (flags, value-taking options), so the command it runs is
|
|
622
|
+
# found past its options' values (`caffeinate -t 60 git …`).
|
|
623
|
+
WRAPPER_OPTIONS: dict[str, tuple[set[str], set[str]]] = {
|
|
624
|
+
"command": ({"-p", "-v", "-V"}, set()),
|
|
625
|
+
"exec": ({"-c", "-l"}, {"-a"}),
|
|
626
|
+
"time": (
|
|
627
|
+
{"-p", "-l", "-h", "-a", "-v", "-q", "--portability", "--verbose", "--quiet", "--append"},
|
|
628
|
+
{"-o", "-f", "--output", "--format"},
|
|
629
|
+
),
|
|
630
|
+
"nice": (set(), {"-n", "--adjustment"}),
|
|
631
|
+
"sudo": (
|
|
632
|
+
{
|
|
633
|
+
"-A", "-b", "-B", "-E", "-e", "-H", "-i", "-K", "-k", "-l", "-N", "-n", "-P", "-S", "-s", "-V", "-v",
|
|
634
|
+
"--askpass", "--background", "--bell", "--preserve-env", "--edit", "--set-home", "--login",
|
|
635
|
+
"--remove-timestamp", "--reset-timestamp", "--list", "--no-update", "--non-interactive",
|
|
636
|
+
"--preserve-groups", "--stdin", "--shell", "--version", "--validate",
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"-u", "-g", "-C", "-h", "-p", "-U", "-r", "-t", "-D", "-R", "-T", "--user", "--group",
|
|
640
|
+
"--close-from", "--host", "--prompt", "--other-user", "--role", "--type", "--chdir",
|
|
641
|
+
"--chroot", "--command-timeout",
|
|
642
|
+
},
|
|
643
|
+
),
|
|
644
|
+
"doas": ({"-n", "-s", "-L"}, {"-u", "-C"}),
|
|
645
|
+
"env": (
|
|
646
|
+
{"-i", "-0", "-v", "--ignore-environment", "--null", "--debug"},
|
|
647
|
+
{"-u", "-C", "-S", "-P", "-L", "-U", "--unset", "--chdir", "--split-string"},
|
|
648
|
+
),
|
|
649
|
+
"timeout": (
|
|
650
|
+
{"-f", "-p", "-v", "--foreground", "--preserve-status", "--verbose"},
|
|
651
|
+
{"-k", "-s", "--kill-after", "--signal"},
|
|
652
|
+
),
|
|
653
|
+
"stdbuf": (set(), {"-i", "-o", "-e", "--input", "--output", "--error"}),
|
|
654
|
+
"caffeinate": ({"-d", "-i", "-m", "-s", "-u"}, {"-t", "-w"}),
|
|
655
|
+
}
|
|
656
|
+
# Words a git flag scan never needs to see: they only appear as the program
|
|
657
|
+
# of opaque interpreters, where a textual scan is all that is possible.
|
|
658
|
+
RISKY_TEXT = re.compile(
|
|
659
|
+
r"no-verify|hookspath|core\.hooks|--force|--mirror|git_config|alias\.|\+refs/|remote\.[^\s=]*\.push",
|
|
660
|
+
re.IGNORECASE,
|
|
661
|
+
)
|
|
662
|
+
# `git` as a word of its own (a program name or path), not `.git` or `digit`.
|
|
663
|
+
GIT_WORD = re.compile(r"(?<![\w.-])git(?![\w.-])")
|
|
664
|
+
GIT_ENV_INJECTION = re.compile(
|
|
665
|
+
r"^(GIT_CONFIG(_[A-Z0-9_]*)?|GIT_EXEC_PATH|GIT_DIR|GIT_COMMON_DIR|GIT_WORK_TREE)$"
|
|
666
|
+
)
|
|
667
|
+
# Any case: macOS's default filesystem reads `.GIT/Config` as `.git/config`.
|
|
668
|
+
GIT_CONFIG_FILE = re.compile(
|
|
669
|
+
r"(^|/)(\.git/(config|config\.worktree|hooks(/|$))|config\.worktree$)", re.IGNORECASE
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def writes_git_config(path: str) -> bool:
|
|
674
|
+
"""Whether a literal write target names git's config or hooks, as written or
|
|
675
|
+
with `.`, `..` and doubled slashes resolved (`.git/./config`, `.git//config`,
|
|
676
|
+
`.git/hooks/../config`). Both forms count, so normalising never drops one."""
|
|
677
|
+
return any(GIT_CONFIG_FILE.search(form) for form in (path, posixpath.normpath(path)))
|
|
678
|
+
GIT_GLOBAL_VALUE_OPTS = {
|
|
679
|
+
"-C", "-c", "--config-env", "--git-dir", "--work-tree", "--namespace",
|
|
680
|
+
"--exec-path", "--super-prefix", "--attr-source", "--list-cmds",
|
|
681
|
+
}
|
|
682
|
+
REMOTE_PUSH_KEY = re.compile(r"(?i)^remote\..+\.push$")
|
|
683
|
+
UNRESOLVABLE = re.compile(r"[$`*?\[]|^~")
|
|
684
|
+
KNOWN_SUBCOMMANDS = {
|
|
685
|
+
"add", "am", "annotate", "apply", "archive", "bisect", "blame", "branch",
|
|
686
|
+
"bundle", "cat-file", "check-attr", "check-ignore", "check-ref-format",
|
|
687
|
+
"checkout", "cherry", "cherry-pick", "clean", "clone", "commit",
|
|
688
|
+
"commit-graph", "commit-tree", "config", "count-objects", "describe",
|
|
689
|
+
"diff", "diff-files", "diff-index", "diff-tree", "difftool", "fetch",
|
|
690
|
+
"for-each-ref", "format-patch", "fsck", "gc", "grep", "hash-object", "help",
|
|
691
|
+
"init", "log", "ls-files", "ls-remote", "ls-tree", "maintenance", "merge",
|
|
692
|
+
"merge-base", "merge-file", "merge-tree", "mergetool", "mv", "name-rev",
|
|
693
|
+
"notes", "pack-refs", "prune", "pull", "push", "range-diff", "read-tree",
|
|
694
|
+
"rebase", "reflog", "remote", "repack", "replace", "reset", "restore",
|
|
695
|
+
"rev-list", "rev-parse", "revert", "rm", "shortlog", "show", "show-branch",
|
|
696
|
+
"show-ref", "sparse-checkout", "stash", "status", "submodule", "switch",
|
|
697
|
+
"symbolic-ref", "tag", "update-index", "update-ref", "var", "verify-commit",
|
|
698
|
+
"verify-pack", "verify-tag", "version", "whatchanged", "worktree",
|
|
699
|
+
"write-tree",
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def is_no_verify(word: str) -> bool:
|
|
704
|
+
"""``--no-verify`` or an abbreviation git could resolve to it."""
|
|
705
|
+
|
|
706
|
+
name = word.split("=", 1)[0]
|
|
707
|
+
return len(name) >= 6 and name.startswith("--no-v") and "--no-verify".startswith(name)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
DANGEROUS_LONG = ("--force", "--force-with-lease", "--mirror")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def dangerous_long(word: str) -> str | None:
|
|
714
|
+
# The option as typed, which git accepts as any unambiguous abbreviation.
|
|
715
|
+
typed = word.split("=", 1)[0]
|
|
716
|
+
if not typed.startswith("--") or len(typed) < 3:
|
|
717
|
+
return None
|
|
718
|
+
for full in DANGEROUS_LONG:
|
|
719
|
+
if full.startswith(typed):
|
|
720
|
+
return full
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@dataclass
|
|
725
|
+
class Scope:
|
|
726
|
+
"""What earlier commands in the same script changed for later ones."""
|
|
727
|
+
|
|
728
|
+
cwd: str | None
|
|
729
|
+
# Git-configuration variables exported to every later command.
|
|
730
|
+
env_injected: frozenset[str] = frozenset()
|
|
731
|
+
# `set -a`: every later assignment is exported.
|
|
732
|
+
allexport: bool = False
|
|
733
|
+
# Git-configuration variables `unset` or `export -n` took from every later
|
|
734
|
+
# command; "*" stands for all of them.
|
|
735
|
+
env_dropped: frozenset[str] = frozenset()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
BRACE_LIMIT = 256
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def brace_alternatives(text: str) -> list[str] | None:
|
|
742
|
+
"""The words bash's brace expansion makes of ``text`` (`--{force,x}` is
|
|
743
|
+
`--force --x`), or None when this reader cannot expand it faithfully."""
|
|
744
|
+
|
|
745
|
+
for start, char in enumerate(text):
|
|
746
|
+
if char != "{" or (start > 0 and text[start - 1] == "$"):
|
|
747
|
+
continue
|
|
748
|
+
depth = 0
|
|
749
|
+
commas: list[int] = []
|
|
750
|
+
end = -1
|
|
751
|
+
for index in range(start, len(text)):
|
|
752
|
+
if text[index] == "{":
|
|
753
|
+
depth += 1
|
|
754
|
+
elif text[index] == "}":
|
|
755
|
+
depth -= 1
|
|
756
|
+
if depth == 0:
|
|
757
|
+
end = index
|
|
758
|
+
break
|
|
759
|
+
elif text[index] == "," and depth == 1:
|
|
760
|
+
commas.append(index)
|
|
761
|
+
if end < 0:
|
|
762
|
+
return [text]
|
|
763
|
+
inner = text[start + 1 : end]
|
|
764
|
+
prefix, suffix = text[:start], text[end + 1 :]
|
|
765
|
+
if commas:
|
|
766
|
+
cuts = [start, *commas, end]
|
|
767
|
+
parts = [text[cuts[i] + 1 : cuts[i + 1]] for i in range(len(cuts) - 1)]
|
|
768
|
+
else:
|
|
769
|
+
sequence = re.fullmatch(r"(-?\d+|[A-Za-z])\.\.(-?\d+|[A-Za-z])(?:\.\.(-?\d+))?", inner)
|
|
770
|
+
if sequence is None:
|
|
771
|
+
continue # `{x}` is literal; a later group may still expand
|
|
772
|
+
first, last, step_text = sequence.groups()
|
|
773
|
+
step = abs(int(step_text)) if step_text else 1
|
|
774
|
+
if first.lstrip("-").isdigit() != last.lstrip("-").isdigit() or step == 0:
|
|
775
|
+
return None
|
|
776
|
+
# Counted before it is built: `{1..1000000000}` would exhaust the
|
|
777
|
+
# guard's memory or time, even in a branch bash never runs.
|
|
778
|
+
span = abs(int(last) - int(first)) if first.lstrip("-").isdigit() else abs(ord(last) - ord(first))
|
|
779
|
+
if span // step + 1 > BRACE_LIMIT:
|
|
780
|
+
return None
|
|
781
|
+
if first.lstrip("-").isdigit():
|
|
782
|
+
low, high = int(first), int(last)
|
|
783
|
+
values = range(low, high + (1 if high >= low else -1), step if high >= low else -step)
|
|
784
|
+
parts = [str(value) for value in values]
|
|
785
|
+
else:
|
|
786
|
+
low, high = ord(first), ord(last)
|
|
787
|
+
values = range(low, high + (1 if high >= low else -1), step if high >= low else -step)
|
|
788
|
+
parts = [chr(value) for value in values]
|
|
789
|
+
if len(parts) > BRACE_LIMIT:
|
|
790
|
+
return None
|
|
791
|
+
out: list[str] = []
|
|
792
|
+
for part in parts:
|
|
793
|
+
expanded = brace_alternatives(prefix + part + suffix)
|
|
794
|
+
if expanded is None:
|
|
795
|
+
return None
|
|
796
|
+
out.extend(expanded)
|
|
797
|
+
if len(out) > BRACE_LIMIT:
|
|
798
|
+
return None
|
|
799
|
+
return out
|
|
800
|
+
return [text]
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def expand_braces(words: list[Word]) -> list[Word]:
|
|
804
|
+
"""Each brace-expanded word as the words bash makes of it. One this
|
|
805
|
+
reader cannot expand (a quoted part, too many results) becomes a computed
|
|
806
|
+
word, which every check treats as possibly anything."""
|
|
807
|
+
|
|
808
|
+
out: list[Word] = []
|
|
809
|
+
for word in words:
|
|
810
|
+
if not word.braces or word.is_assignment() is not None:
|
|
811
|
+
out.append(word)
|
|
812
|
+
continue
|
|
813
|
+
alternatives = None if word.first_quoted is not None else brace_alternatives(word.text)
|
|
814
|
+
if alternatives is None:
|
|
815
|
+
out.append(Word(text=word.text, dynamic=True, quoted=word.quoted, bodies=word.bodies, started=True, first_dynamic=0))
|
|
816
|
+
continue
|
|
817
|
+
for index, text in enumerate(alternatives):
|
|
818
|
+
out.append(
|
|
819
|
+
Word(
|
|
820
|
+
text=text,
|
|
821
|
+
dynamic=word.dynamic,
|
|
822
|
+
quoted=word.quoted,
|
|
823
|
+
bodies=word.bodies if index == 0 else [],
|
|
824
|
+
started=True,
|
|
825
|
+
first_dynamic=word.first_dynamic,
|
|
826
|
+
glob=any(char in text for char in "*?["),
|
|
827
|
+
glob_leading=text[:1] in ("*", "?", "["),
|
|
828
|
+
)
|
|
829
|
+
)
|
|
830
|
+
return out
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
class Analyzer:
|
|
834
|
+
def __init__(self, base_cwd: str | None, read_config: bool = True, paired_changes: Sequence[Any] = ()) -> None:
|
|
835
|
+
self.base_cwd = base_cwd
|
|
836
|
+
self.read_config = read_config
|
|
837
|
+
# The consumer's paired changes (config.PairedChange: `changes`,
|
|
838
|
+
# `requires`, `reason`); empty turns the env -C half of that rule off.
|
|
839
|
+
self.paired_changes = tuple(paired_changes)
|
|
840
|
+
# A git-configuration variable the current nested program inherits.
|
|
841
|
+
self.inherited: str | None = None
|
|
842
|
+
# The git-configuration variables this guard's own lookups inherit
|
|
843
|
+
# (`git_read` runs with the hook's environment), so a command that
|
|
844
|
+
# drops one runs a git that reads another repository or configuration.
|
|
845
|
+
self.hook_selectors = frozenset(name for name in os.environ if GIT_ENV_INJECTION.match(name))
|
|
846
|
+
# The current command (or a program it runs) was moved by `env -C`,
|
|
847
|
+
# which bash_guard.py's paired-change model does not follow.
|
|
848
|
+
self.chdir_by_env = False
|
|
849
|
+
|
|
850
|
+
# -- entry -------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
def script(self, text: str, cwd: str | None, depth: int = 0, into: Scope | None = None) -> None:
|
|
853
|
+
"""Analyse ``text`` as a shell program run in ``cwd``. ``into`` is the
|
|
854
|
+
scope of a caller whose own shell runs it (`eval`, `source` of a
|
|
855
|
+
heredoc): its cd, exports and `set -a` then hold for the caller's
|
|
856
|
+
later commands too, as they do in bash."""
|
|
857
|
+
|
|
858
|
+
if depth > MAX_DEPTH:
|
|
859
|
+
raise Blocked("nested shell programs too deep to verify")
|
|
860
|
+
try:
|
|
861
|
+
commands = parse(text)
|
|
862
|
+
except Blocked as error:
|
|
863
|
+
# Bash runs every complete line before the one it cannot parse, so
|
|
864
|
+
# an unparseable script that runs git at all (`git push -f` on a
|
|
865
|
+
# line of its own, then an unterminated quote) is refused whole.
|
|
866
|
+
if RISKY_TEXT.search(text) or GIT_WORD.search(text):
|
|
867
|
+
raise Blocked(f"the command cannot be parsed ({error}) and runs git or mentions a hook or push override") from None
|
|
868
|
+
return
|
|
869
|
+
scope = into or Scope(
|
|
870
|
+
cwd=cwd,
|
|
871
|
+
env_injected=frozenset({self.inherited}) if self.inherited else frozenset(),
|
|
872
|
+
)
|
|
873
|
+
stack: list[str | None] = []
|
|
874
|
+
last_depth = 0
|
|
875
|
+
for command in commands:
|
|
876
|
+
while command.depth > last_depth:
|
|
877
|
+
stack.append(scope.cwd)
|
|
878
|
+
last_depth += 1
|
|
879
|
+
while command.depth < last_depth:
|
|
880
|
+
scope.cwd = stack.pop() if stack else scope.cwd
|
|
881
|
+
last_depth -= 1
|
|
882
|
+
self.command(command, scope, depth)
|
|
883
|
+
|
|
884
|
+
# -- one command -----------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
def command(self, command: Command, scope: Scope, depth: int) -> None:
|
|
887
|
+
# Programs this command runs in a nested shell (bash -c, eval, xargs,
|
|
888
|
+
# find -exec, aliases) inherit its environment, so a git configuration
|
|
889
|
+
# injected here -- by an earlier export or by this command's own
|
|
890
|
+
# NAME=value prefix -- reaches the git inside them too.
|
|
891
|
+
saved = self.inherited
|
|
892
|
+
saved_chdir = self.chdir_by_env
|
|
893
|
+
try:
|
|
894
|
+
# A command xargs, find -exec or eval runs keeps what the command
|
|
895
|
+
# that ran it was given (`GIT_CONFIG_COUNT=1 xargs git commit`).
|
|
896
|
+
self.inherited = min(scope.env_injected) if scope.env_injected else saved
|
|
897
|
+
self.one_command(command, scope, depth)
|
|
898
|
+
finally:
|
|
899
|
+
self.inherited = saved
|
|
900
|
+
self.chdir_by_env = saved_chdir
|
|
901
|
+
|
|
902
|
+
def one_command(self, command: Command, scope: Scope, depth: int) -> None:
|
|
903
|
+
for word in [
|
|
904
|
+
*command.words,
|
|
905
|
+
*command.redirect_targets,
|
|
906
|
+
*command.input_targets,
|
|
907
|
+
*command.herestrings,
|
|
908
|
+
]:
|
|
909
|
+
for body in word.bodies:
|
|
910
|
+
self.script(body, scope.cwd, depth + 1)
|
|
911
|
+
for body in command.expanded_bodies:
|
|
912
|
+
self.script(body, scope.cwd, depth + 1)
|
|
913
|
+
for target in command.redirect_targets:
|
|
914
|
+
if not target.dynamic and writes_git_config(target.text):
|
|
915
|
+
raise Blocked(f"writing '{target.text}' directly can disable the repository hooks. {HOOK_REMEDY}")
|
|
916
|
+
|
|
917
|
+
# `git push --{force,verbose}` is `git push --force --verbose`.
|
|
918
|
+
words = expand_braces(list(command.words))
|
|
919
|
+
assignments: list[str] = []
|
|
920
|
+
chdirs: list[Word] = []
|
|
921
|
+
# Variables `env -u NAME` or `env -i` ("*") take from the command.
|
|
922
|
+
drops: list[str] = []
|
|
923
|
+
# Leading NAME=value words, then wrappers and their own options.
|
|
924
|
+
index = 0
|
|
925
|
+
while index < len(words):
|
|
926
|
+
word = words[index]
|
|
927
|
+
assigned = word.is_assignment()
|
|
928
|
+
if assigned is not None:
|
|
929
|
+
assignments.append(assigned)
|
|
930
|
+
index += 1
|
|
931
|
+
continue
|
|
932
|
+
name = os.path.basename(word.text) if not word.dynamic else word.text
|
|
933
|
+
# Quoting a wrapper's name does not stop bash running it:
|
|
934
|
+
# `'env' git push --force` is env running git.
|
|
935
|
+
if name in WRAPPERS:
|
|
936
|
+
index = self.skip_wrapper(words, index, assignments, chdirs, drops)
|
|
937
|
+
continue
|
|
938
|
+
if name in ("coproc", "function") and not word.dynamic:
|
|
939
|
+
# `coproc [NAME] { …; }` and `function NAME { …; }` run the
|
|
940
|
+
# body that follows: a NAME is skipped only before a group,
|
|
941
|
+
# since `coproc NAME cmd` runs the simple command NAME.
|
|
942
|
+
index += 1
|
|
943
|
+
if name == "function" or (index + 1 < len(words) and words[index + 1].text in ("{", "(")):
|
|
944
|
+
index += 1
|
|
945
|
+
continue
|
|
946
|
+
break
|
|
947
|
+
words = words[index:]
|
|
948
|
+
if chdirs:
|
|
949
|
+
# `env -C DIR cmd` runs cmd in DIR: its git reads DIR's config.
|
|
950
|
+
cwd = scope.cwd
|
|
951
|
+
for chdir in chdirs:
|
|
952
|
+
cwd = self.resolve_dir(chdir, cwd, strict=False)
|
|
953
|
+
scope = Scope(
|
|
954
|
+
cwd=cwd, env_injected=scope.env_injected, allexport=scope.allexport, env_dropped=scope.env_dropped
|
|
955
|
+
)
|
|
956
|
+
self.chdir_by_env = True
|
|
957
|
+
if drops:
|
|
958
|
+
# `env -u NAME` / `env -i` drop NAME for this command only.
|
|
959
|
+
scope = Scope(
|
|
960
|
+
cwd=scope.cwd,
|
|
961
|
+
env_injected=scope.env_injected,
|
|
962
|
+
allexport=scope.allexport,
|
|
963
|
+
env_dropped=scope.env_dropped | frozenset(drops),
|
|
964
|
+
)
|
|
965
|
+
self.dropped_selector(scope, words)
|
|
966
|
+
for name in assignments:
|
|
967
|
+
if GIT_ENV_INJECTION.match(name):
|
|
968
|
+
if not words:
|
|
969
|
+
# `NAME=value` alone sets a shell variable, which git sees
|
|
970
|
+
# only once it is exported: by `set -a`, by an earlier
|
|
971
|
+
# export, or by the environment this shell started with.
|
|
972
|
+
if scope.allexport or name in scope.env_injected or name in os.environ:
|
|
973
|
+
scope.env_injected = scope.env_injected | {name}
|
|
974
|
+
else:
|
|
975
|
+
self.injected(name, words)
|
|
976
|
+
self.inherited = name
|
|
977
|
+
if not words:
|
|
978
|
+
return
|
|
979
|
+
head = words[0]
|
|
980
|
+
name = os.path.basename(head.text)
|
|
981
|
+
if head.dynamic:
|
|
982
|
+
# A computed command may be a wrapper (`$W git push -f`, W=env):
|
|
983
|
+
# its arguments are then a command of their own.
|
|
984
|
+
if any(RISKY_TEXT.search(word.text) or is_no_verify(word.text) or GIT_WORD.search(word.text) for word in words[1:]):
|
|
985
|
+
raise Blocked(f"'{head.text}' is computed at run time and its arguments run git or override hooks or pushes. {HOOK_REMEDY}")
|
|
986
|
+
return
|
|
987
|
+
if name in ("export", "declare", "typeset", "readonly", "local"):
|
|
988
|
+
# `export -n NAME` keeps the variable but stops exporting it. Any
|
|
989
|
+
# other declaration of a git-configuration name is read as an
|
|
990
|
+
# export, which fails closed for `local` and plain `declare`.
|
|
991
|
+
unexport = name == "export" and any(
|
|
992
|
+
not word.dynamic and re.fullmatch(r"-[a-z]*n[a-z]*", word.text) for word in words[1:]
|
|
993
|
+
)
|
|
994
|
+
for word in words[1:]:
|
|
995
|
+
match = re.match(r"([A-Za-z_][A-Za-z0-9_]*)", word.text)
|
|
996
|
+
if match and GIT_ENV_INJECTION.match(match.group(1)):
|
|
997
|
+
if unexport:
|
|
998
|
+
scope.env_injected = scope.env_injected - {match.group(1)}
|
|
999
|
+
scope.env_dropped = scope.env_dropped | {match.group(1)}
|
|
1000
|
+
else:
|
|
1001
|
+
scope.env_injected = scope.env_injected | {match.group(1)}
|
|
1002
|
+
return
|
|
1003
|
+
if name == "unset":
|
|
1004
|
+
# `unset NAME` takes the variable from every later command (`-f`
|
|
1005
|
+
# is read the same way, as bash_guard.py does). A computed
|
|
1006
|
+
# name may be any variable.
|
|
1007
|
+
for word in words[1:]:
|
|
1008
|
+
if word.dynamic:
|
|
1009
|
+
scope.env_dropped = scope.env_dropped | {"*"}
|
|
1010
|
+
elif GIT_ENV_INJECTION.match(word.text):
|
|
1011
|
+
scope.env_dropped = scope.env_dropped | {word.text}
|
|
1012
|
+
return
|
|
1013
|
+
if name == "set":
|
|
1014
|
+
self.set_options(words[1:], scope)
|
|
1015
|
+
return
|
|
1016
|
+
if name == "cd" and len(words) > 1:
|
|
1017
|
+
scope.cwd = self.resolve_dir(words[1], scope.cwd, strict=False)
|
|
1018
|
+
return
|
|
1019
|
+
if name in CONTROL_WORDS:
|
|
1020
|
+
return
|
|
1021
|
+
if name in SHELLS:
|
|
1022
|
+
self.shell(command, words, scope, depth)
|
|
1023
|
+
return
|
|
1024
|
+
if name in ("source", "."):
|
|
1025
|
+
self.source(command, words, scope, depth)
|
|
1026
|
+
return
|
|
1027
|
+
if name == "eval":
|
|
1028
|
+
if any(word.dynamic for word in words[1:]):
|
|
1029
|
+
raise Blocked("eval of a computed string cannot be verified")
|
|
1030
|
+
self.script(" ".join(word.text for word in words[1:]), scope.cwd, depth + 1, scope)
|
|
1031
|
+
return
|
|
1032
|
+
if name == "xargs":
|
|
1033
|
+
self.xargs(words, scope, depth)
|
|
1034
|
+
return
|
|
1035
|
+
if name == "find":
|
|
1036
|
+
self.find_exec(words, scope, depth)
|
|
1037
|
+
return
|
|
1038
|
+
if name in INLINE_INTERPRETERS or re.fullmatch(r"python\d+(\.\d+)*", name):
|
|
1039
|
+
for word in words[1:]:
|
|
1040
|
+
if RISKY_TEXT.search(word.text):
|
|
1041
|
+
raise Blocked(f"an inline {name} program mentions a hook or push override. {HOOK_REMEDY}")
|
|
1042
|
+
return
|
|
1043
|
+
if name in ("tee", "cp", "mv", "ln", "install", "dd", "sed", "perl", "truncate"):
|
|
1044
|
+
for word in self.written_operands(name, words[1:]):
|
|
1045
|
+
if not word.dynamic and writes_git_config(word.text.removeprefix("of=")):
|
|
1046
|
+
if name != "sed" or any(w.text.startswith("-i") or w.text.startswith("--in-place") for w in words[1:]):
|
|
1047
|
+
raise Blocked(f"'{name}' on '{word.text}' can disable the repository hooks. {HOOK_REMEDY}")
|
|
1048
|
+
if name == "git":
|
|
1049
|
+
self.git(words, scope, depth)
|
|
1050
|
+
|
|
1051
|
+
@staticmethod
|
|
1052
|
+
def written_operands(name: str, operands: list[Word]) -> list[Word]:
|
|
1053
|
+
"""The operands ``name`` may write. cp, ln and install write only their
|
|
1054
|
+
destination (the last operand, or ``-t DIR``), so copying .git/config
|
|
1055
|
+
out is a read; every other writer is judged on all its operands."""
|
|
1056
|
+
|
|
1057
|
+
if name not in ("cp", "ln", "install"):
|
|
1058
|
+
return operands
|
|
1059
|
+
for index, word in enumerate(operands):
|
|
1060
|
+
if word.text in ("-t", "--target-directory") and index + 1 < len(operands):
|
|
1061
|
+
return [operands[index + 1]]
|
|
1062
|
+
if word.text.startswith("--target-directory="):
|
|
1063
|
+
return [word]
|
|
1064
|
+
if any(word.dynamic for word in operands):
|
|
1065
|
+
return operands
|
|
1066
|
+
plain = [word for word in operands if not word.text.startswith("-")]
|
|
1067
|
+
return plain[-1:] if plain else operands
|
|
1068
|
+
|
|
1069
|
+
def skip_wrapper(
|
|
1070
|
+
self, words: list[Word], index: int, assignments: list[str], chdirs: list[Word], drops: list[str]
|
|
1071
|
+
) -> int:
|
|
1072
|
+
"""The index of the command a wrapper runs, past its own options.
|
|
1073
|
+
|
|
1074
|
+
Each wrapper's options are read from WRAPPER_OPTIONS, values included
|
|
1075
|
+
(`caffeinate -t 60 git …`: 60 is no command). An option this table does
|
|
1076
|
+
not know may take a value, so the command is unknown: refused when
|
|
1077
|
+
what follows mentions git or a hook or push override.
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
name = os.path.basename(words[index].text)
|
|
1081
|
+
index += 1
|
|
1082
|
+
if name in KEYWORD_WRAPPERS:
|
|
1083
|
+
return index
|
|
1084
|
+
flags, values = WRAPPER_OPTIONS.get(name, (set(), set()))
|
|
1085
|
+
while index < len(words):
|
|
1086
|
+
word = words[index]
|
|
1087
|
+
text = word.text
|
|
1088
|
+
if name == "env" and text == "-":
|
|
1089
|
+
index += 1 # `env -` is `env -i`
|
|
1090
|
+
drops.append("*")
|
|
1091
|
+
continue
|
|
1092
|
+
if name == "env" and not text.startswith("-"):
|
|
1093
|
+
# env takes every NAME=VALUE argument as an assignment, quoted
|
|
1094
|
+
# or not (`env 'X=y' git …`), up to its first command word.
|
|
1095
|
+
literal = text if word.first_dynamic is None else text[: word.first_dynamic]
|
|
1096
|
+
if "=" in literal and not literal.startswith("="):
|
|
1097
|
+
assignments.append(literal.split("=", 1)[0])
|
|
1098
|
+
index += 1
|
|
1099
|
+
continue
|
|
1100
|
+
break
|
|
1101
|
+
if not text.startswith("-") or text == "-" or word.dynamic:
|
|
1102
|
+
break
|
|
1103
|
+
index += 1
|
|
1104
|
+
if text == "--":
|
|
1105
|
+
break
|
|
1106
|
+
option, has_value, attached = text.partition("=")
|
|
1107
|
+
if option.startswith("--"):
|
|
1108
|
+
if option in values:
|
|
1109
|
+
if has_value:
|
|
1110
|
+
value = Word(text=attached, dynamic=word.dynamic, started=True)
|
|
1111
|
+
else:
|
|
1112
|
+
value = words[index] if index < len(words) else Word(text="", started=True)
|
|
1113
|
+
index += 1
|
|
1114
|
+
self.wrapper_value(name, option, value, chdirs, drops)
|
|
1115
|
+
continue
|
|
1116
|
+
if option in flags:
|
|
1117
|
+
if name == "env" and option == "--ignore-environment":
|
|
1118
|
+
drops.append("*")
|
|
1119
|
+
continue
|
|
1120
|
+
elif name == "nice" and re.fullmatch(r"-\d+", text):
|
|
1121
|
+
continue
|
|
1122
|
+
else:
|
|
1123
|
+
known = True
|
|
1124
|
+
for position, letter in enumerate(text[1:], start=1):
|
|
1125
|
+
short = f"-{letter}"
|
|
1126
|
+
if short in values:
|
|
1127
|
+
if position + 1 < len(text):
|
|
1128
|
+
value = Word(text=text[position + 1 :], dynamic=word.dynamic, started=True)
|
|
1129
|
+
else:
|
|
1130
|
+
value = words[index] if index < len(words) else Word(text="", started=True)
|
|
1131
|
+
index += 1
|
|
1132
|
+
self.wrapper_value(name, short, value, chdirs, drops)
|
|
1133
|
+
break
|
|
1134
|
+
if short not in flags:
|
|
1135
|
+
known = False
|
|
1136
|
+
break
|
|
1137
|
+
if name == "env" and short == "-i":
|
|
1138
|
+
drops.append("*")
|
|
1139
|
+
if known:
|
|
1140
|
+
continue
|
|
1141
|
+
if any(RISKY_TEXT.search(w.text) or GIT_WORD.search(w.text) or is_no_verify(w.text) for w in words[index:]):
|
|
1142
|
+
raise Blocked(f"'{name} {text}' is an option this guard does not know, so the command it runs cannot be verified")
|
|
1143
|
+
if name == "timeout" and index < len(words):
|
|
1144
|
+
index += 1 # the duration
|
|
1145
|
+
return index
|
|
1146
|
+
|
|
1147
|
+
@staticmethod
|
|
1148
|
+
def wrapper_value(name: str, option: str, value: Word, chdirs: list[Word], drops: list[str]) -> None:
|
|
1149
|
+
if name == "env" and option in ("-S", "--split-string"):
|
|
1150
|
+
raise Blocked("env -S splits a string into a command that cannot be verified")
|
|
1151
|
+
if name == "env" and option in ("-C", "--chdir"):
|
|
1152
|
+
chdirs.append(value)
|
|
1153
|
+
if name == "env" and option in ("-u", "--unset"):
|
|
1154
|
+
drops.append("*" if value.dynamic else value.text)
|
|
1155
|
+
|
|
1156
|
+
def lost_selectors(self, scope: Scope) -> frozenset[str]:
|
|
1157
|
+
"""The hook's git-configuration variables this command runs without.
|
|
1158
|
+
|
|
1159
|
+
The guard's lookups (`git_read`) inherit the hook's environment, so
|
|
1160
|
+
when `env -u`/`env -i` or an earlier `unset`/`export -n` drops the
|
|
1161
|
+
hook's own `GIT_DIR` or `GIT_CONFIG_*`, the command's git reads another
|
|
1162
|
+
repository or configuration than the one the lookup checked.
|
|
1163
|
+
"""
|
|
1164
|
+
|
|
1165
|
+
if "*" in scope.env_dropped:
|
|
1166
|
+
return self.hook_selectors
|
|
1167
|
+
return self.hook_selectors & scope.env_dropped
|
|
1168
|
+
|
|
1169
|
+
def refuse_lost(self, scope: Scope) -> None:
|
|
1170
|
+
lost = self.lost_selectors(scope)
|
|
1171
|
+
if lost:
|
|
1172
|
+
raise Blocked(
|
|
1173
|
+
f"'{min(lost)}' is set for this guard, and the command runs git without it, so git would "
|
|
1174
|
+
f"read a repository or configuration the guard did not check. {HOOK_REMEDY}"
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
def dropped_selector(self, scope: Scope, words: list[Word]) -> None:
|
|
1178
|
+
"""Another program run without a hook selector may run git in a nested
|
|
1179
|
+
program, whose lookups this guard makes with the hook's environment.
|
|
1180
|
+
Git itself is refused only where its configuration is read."""
|
|
1181
|
+
|
|
1182
|
+
if not words or (not words[0].dynamic and os.path.basename(words[0].text) == "git"):
|
|
1183
|
+
return
|
|
1184
|
+
if any(word.dynamic or GIT_WORD.search(word.text) for word in words):
|
|
1185
|
+
self.refuse_lost(scope)
|
|
1186
|
+
|
|
1187
|
+
@staticmethod
|
|
1188
|
+
def set_options(args: list[Word], scope: Scope) -> None:
|
|
1189
|
+
"""`set -a` and `set -o allexport` export every later assignment;
|
|
1190
|
+
`+a` and `+o allexport` stop it. A cluster with `o` takes the next
|
|
1191
|
+
word as an option name (`set -eo allexport`). Options end at `--`,
|
|
1192
|
+
`-` or the first operand. A computed word may be any of them, so it
|
|
1193
|
+
turns the export on."""
|
|
1194
|
+
|
|
1195
|
+
position = 0
|
|
1196
|
+
while position < len(args):
|
|
1197
|
+
word = args[position]
|
|
1198
|
+
if word.dynamic:
|
|
1199
|
+
scope.allexport = True
|
|
1200
|
+
return
|
|
1201
|
+
text = word.text
|
|
1202
|
+
if text in ("--", "-") or not re.fullmatch(r"[-+][A-Za-z]+", text):
|
|
1203
|
+
return
|
|
1204
|
+
on = text.startswith("-")
|
|
1205
|
+
if "a" in text:
|
|
1206
|
+
scope.allexport = on
|
|
1207
|
+
if "o" in text:
|
|
1208
|
+
position += 1
|
|
1209
|
+
if position < len(args) and (args[position].dynamic or args[position].text == "allexport"):
|
|
1210
|
+
scope.allexport = on or args[position].dynamic
|
|
1211
|
+
position += 1
|
|
1212
|
+
|
|
1213
|
+
def injected(self, name: str, words: list[Word]) -> None:
|
|
1214
|
+
if os.path.basename(words[0].text) == "git" or words[0].dynamic:
|
|
1215
|
+
raise Blocked(
|
|
1216
|
+
f"'{name}' injects git configuration or repository selection through the "
|
|
1217
|
+
f"environment, which this guard cannot verify. {HOOK_REMEDY}"
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
# -- nested programs ---------------------------------------------------------
|
|
1221
|
+
|
|
1222
|
+
def shell(self, command: Command, words: list[Word], scope: Scope, depth: int) -> None:
|
|
1223
|
+
args = words[1:]
|
|
1224
|
+
# bash reads options up to its first operand: `-c` anywhere among them
|
|
1225
|
+
# makes that operand the program. `-o`/`-O` and the rc-file options
|
|
1226
|
+
# take a value, which is no operand (`bash -o pipefail -c '...'`).
|
|
1227
|
+
command_flag = False
|
|
1228
|
+
stdin_flag = False
|
|
1229
|
+
index = 0
|
|
1230
|
+
while index < len(args):
|
|
1231
|
+
word = args[index]
|
|
1232
|
+
text = word.text
|
|
1233
|
+
if word.dynamic:
|
|
1234
|
+
# A computed word could be `-c`: then a later word is a program.
|
|
1235
|
+
if any(RISKY_TEXT.search(w.text) or GIT_WORD.search(w.text) or is_no_verify(w.text) for w in args[index + 1 :]):
|
|
1236
|
+
raise Blocked("a shell whose options are computed at run time cannot be verified")
|
|
1237
|
+
break
|
|
1238
|
+
if text == "--":
|
|
1239
|
+
index += 1
|
|
1240
|
+
break
|
|
1241
|
+
if text in ("-o", "+o", "-O", "+O", "--rcfile", "--init-file"):
|
|
1242
|
+
index += 2
|
|
1243
|
+
continue
|
|
1244
|
+
if re.fullmatch(r"[-+][A-Za-z]+", text):
|
|
1245
|
+
if text.startswith("-") and "c" in text[1:]:
|
|
1246
|
+
command_flag = True
|
|
1247
|
+
if text.startswith("-") and "s" in text[1:]:
|
|
1248
|
+
stdin_flag = True
|
|
1249
|
+
index += 1
|
|
1250
|
+
continue
|
|
1251
|
+
if text.startswith("--"):
|
|
1252
|
+
index += 1
|
|
1253
|
+
continue
|
|
1254
|
+
break
|
|
1255
|
+
operands = args[index:]
|
|
1256
|
+
self.shell_program(command, operands, command_flag, stdin_flag, scope, depth)
|
|
1257
|
+
|
|
1258
|
+
def source(self, command: Command, words: list[Word], scope: Scope, depth: int) -> None:
|
|
1259
|
+
"""`source FILE` / `. FILE` runs FILE in this shell: a committed file is
|
|
1260
|
+
out of scope, but stdin or a process substitution is the command's own."""
|
|
1261
|
+
operands = words[1:]
|
|
1262
|
+
if operands and operands[0].text == "--":
|
|
1263
|
+
operands = operands[1:]
|
|
1264
|
+
if operands and (operands[0].text in SOURCED_STDIN or operands[0].text.startswith("<(")):
|
|
1265
|
+
self.shell_program(command, operands, False, False, scope, depth, same_shell=True)
|
|
1266
|
+
|
|
1267
|
+
def shell_program(
|
|
1268
|
+
self,
|
|
1269
|
+
command: Command,
|
|
1270
|
+
operands: list[Word],
|
|
1271
|
+
command_flag: bool,
|
|
1272
|
+
stdin_flag: bool,
|
|
1273
|
+
scope: Scope,
|
|
1274
|
+
depth: int,
|
|
1275
|
+
*,
|
|
1276
|
+
same_shell: bool = False,
|
|
1277
|
+
) -> None:
|
|
1278
|
+
# `source` of a heredoc or here-string runs it in this shell; a pipe
|
|
1279
|
+
# into `source` runs it in a subshell, as does every `bash`.
|
|
1280
|
+
into = scope if same_shell else None
|
|
1281
|
+
if operands and operands[0].text.startswith("<("):
|
|
1282
|
+
raise Blocked("a shell running a process substitution's output cannot be verified")
|
|
1283
|
+
if not command_flag and (stdin_flag or (operands and operands[0].text in STDIN_SCRIPTS)):
|
|
1284
|
+
# `bash -s args`, `bash /dev/stdin`, `bash -`: the program is stdin,
|
|
1285
|
+
# the operands only its positional arguments.
|
|
1286
|
+
operands = []
|
|
1287
|
+
if command_flag:
|
|
1288
|
+
if not operands:
|
|
1289
|
+
return
|
|
1290
|
+
program = operands[0]
|
|
1291
|
+
# The literal parts and substitutions are analysed first, for the
|
|
1292
|
+
# most specific refusal; a program with any computed part is then
|
|
1293
|
+
# refused anyway, since the computed part is code this guard never
|
|
1294
|
+
# sees (`bash -c "$P # --force"` runs whatever P holds).
|
|
1295
|
+
self.script(program.text, scope.cwd, depth + 1)
|
|
1296
|
+
for body in program.bodies:
|
|
1297
|
+
self.script(body, scope.cwd, depth + 1)
|
|
1298
|
+
if program.dynamic:
|
|
1299
|
+
raise Blocked("a nested shell program built at run time cannot be verified")
|
|
1300
|
+
return
|
|
1301
|
+
if operands:
|
|
1302
|
+
return # a script file: committed code, out of this guard's scope
|
|
1303
|
+
for body in command.heredocs:
|
|
1304
|
+
self.script(body, scope.cwd, depth + 1, into)
|
|
1305
|
+
for word in command.herestrings:
|
|
1306
|
+
if word.dynamic:
|
|
1307
|
+
raise Blocked("a shell reading a computed here-string cannot be verified")
|
|
1308
|
+
self.script(word.text, scope.cwd, depth + 1, into)
|
|
1309
|
+
source = command.piped_from
|
|
1310
|
+
if source is not None:
|
|
1311
|
+
head = os.path.basename(source.words[0].text) if source.words else ""
|
|
1312
|
+
if head in ("echo", "printf") and not any(w.dynamic for w in source.words[1:]):
|
|
1313
|
+
if head == "echo":
|
|
1314
|
+
texts = [w.text for w in source.words[1:] if not re.fullmatch(r"-[neE]+", w.text)]
|
|
1315
|
+
raw = " ".join(texts)
|
|
1316
|
+
# Whether echo decodes escapes depends on -e, xpg_echo and
|
|
1317
|
+
# which shell's echo runs, and either reading can hide a
|
|
1318
|
+
# command from the other (`\x27` is a quote only once
|
|
1319
|
+
# decoded), so both are analysed.
|
|
1320
|
+
for program in dict.fromkeys((raw, printf_unescape(raw))):
|
|
1321
|
+
self.script(program, scope.cwd, depth + 1)
|
|
1322
|
+
return
|
|
1323
|
+
rendered = render_printf([w.text for w in source.words[1:]])
|
|
1324
|
+
if rendered is None:
|
|
1325
|
+
raise Blocked("a shell reading printf output in a format this guard cannot render cannot be verified")
|
|
1326
|
+
self.script(rendered, scope.cwd, depth + 1)
|
|
1327
|
+
return
|
|
1328
|
+
raise Blocked("a shell reading its program from a pipe cannot be verified")
|
|
1329
|
+
|
|
1330
|
+
def xargs(self, words: list[Word], scope: Scope, depth: int) -> None:
|
|
1331
|
+
index = 1
|
|
1332
|
+
value_options = {"-I", "-i", "-n", "-L", "-l", "-P", "-s", "-d", "-E", "-e", "-a", "--arg-file", "--delimiter", "--max-args", "--max-procs"}
|
|
1333
|
+
while index < len(words) and words[index].text.startswith("-"):
|
|
1334
|
+
index += 2 if words[index].text in value_options else 1
|
|
1335
|
+
rest = words[index:]
|
|
1336
|
+
if rest:
|
|
1337
|
+
# xargs runs the rest as a command of its own -- wrappers and all
|
|
1338
|
+
# (`xargs env git push --force`) -- with words read from stdin
|
|
1339
|
+
# appended: an unknown, possibly a flag.
|
|
1340
|
+
self.command(Command(words=[*rest, Word(text="$XARGS", dynamic=True, started=True)]), scope, depth + 1)
|
|
1341
|
+
|
|
1342
|
+
def find_exec(self, words: list[Word], scope: Scope, depth: int) -> None:
|
|
1343
|
+
for index, word in enumerate(words):
|
|
1344
|
+
if word.text in ("-exec", "-execdir", "-ok", "-okdir"):
|
|
1345
|
+
inner: list[Word] = []
|
|
1346
|
+
for item in words[index + 1 :]:
|
|
1347
|
+
if item.text in (";", "+"):
|
|
1348
|
+
break
|
|
1349
|
+
inner.append(item)
|
|
1350
|
+
if inner:
|
|
1351
|
+
self.command(Command(words=inner), scope, depth + 1)
|
|
1352
|
+
|
|
1353
|
+
# -- git -----------------------------------------------------------------------
|
|
1354
|
+
|
|
1355
|
+
def resolve_dir(self, word: Word, base: str | None, *, strict: bool) -> str | None:
|
|
1356
|
+
if word.dynamic or UNRESOLVABLE.search(word.text):
|
|
1357
|
+
if strict:
|
|
1358
|
+
raise Blocked(f"cannot statically resolve the directory '{word.text}'.\n{AMBIGUOUS_REMEDY}")
|
|
1359
|
+
return "\0unresolved"
|
|
1360
|
+
root = base if base and base != "\0unresolved" else (self.base_cwd or os.getcwd())
|
|
1361
|
+
if base == "\0unresolved" and not os.path.isabs(word.text):
|
|
1362
|
+
return "\0unresolved"
|
|
1363
|
+
return word.text if os.path.isabs(word.text) else os.path.join(root, word.text)
|
|
1364
|
+
|
|
1365
|
+
def git(self, words: list[Word], scope: Scope, depth: int) -> None:
|
|
1366
|
+
injected = min(scope.env_injected) if scope.env_injected else self.inherited
|
|
1367
|
+
if injected:
|
|
1368
|
+
raise Blocked(
|
|
1369
|
+
f"'{injected}' was set earlier in this command and injects git "
|
|
1370
|
+
f"configuration through the environment. {HOOK_REMEDY}"
|
|
1371
|
+
)
|
|
1372
|
+
index = 1
|
|
1373
|
+
overrides: list[tuple[str, Word]] = []
|
|
1374
|
+
cds: list[Word] = []
|
|
1375
|
+
git_dir: Word | None = None
|
|
1376
|
+
while index < len(words) and words[index].text.startswith("-"):
|
|
1377
|
+
word = words[index]
|
|
1378
|
+
option, _, attached = word.text.partition("=")
|
|
1379
|
+
if word.text.startswith("-c") and word.text != "-c" and not word.text.startswith("--"):
|
|
1380
|
+
overrides.append(("-c", Word(text=word.text[2:], dynamic=word.dynamic, started=True)))
|
|
1381
|
+
index += 1
|
|
1382
|
+
continue
|
|
1383
|
+
if option in GIT_GLOBAL_VALUE_OPTS:
|
|
1384
|
+
if attached or "=" in word.text:
|
|
1385
|
+
value = Word(text=attached, dynamic=word.dynamic, started=True)
|
|
1386
|
+
index += 1
|
|
1387
|
+
elif index + 1 < len(words):
|
|
1388
|
+
value = words[index + 1]
|
|
1389
|
+
index += 2
|
|
1390
|
+
else:
|
|
1391
|
+
index += 1
|
|
1392
|
+
continue
|
|
1393
|
+
if option == "-C":
|
|
1394
|
+
cds.append(value)
|
|
1395
|
+
elif option == "--git-dir":
|
|
1396
|
+
git_dir = value
|
|
1397
|
+
elif option in ("-c", "--config-env"):
|
|
1398
|
+
overrides.append((option, value))
|
|
1399
|
+
continue
|
|
1400
|
+
index += 1
|
|
1401
|
+
for option, value in overrides:
|
|
1402
|
+
self.override(option, value)
|
|
1403
|
+
if index >= len(words):
|
|
1404
|
+
return
|
|
1405
|
+
sub = words[index]
|
|
1406
|
+
args = words[index + 1 :]
|
|
1407
|
+
if sub.dynamic:
|
|
1408
|
+
if any(is_no_verify(w.text) or RISKY_TEXT.search(w.text) for w in args):
|
|
1409
|
+
raise Blocked(f"a computed git subcommand with hook or push overrides cannot be verified. {HOOK_REMEDY}")
|
|
1410
|
+
return
|
|
1411
|
+
name = sub.text
|
|
1412
|
+
if name not in KNOWN_SUBCOMMANDS:
|
|
1413
|
+
self.alias(name, args, scope, cds, depth, git_dir, overrides)
|
|
1414
|
+
if name == "commit":
|
|
1415
|
+
stages_all = self.commit(args)
|
|
1416
|
+
if self.chdir_by_env and self.paired_changes:
|
|
1417
|
+
self.env_chdir_paired(stages_all, scope, cds, git_dir)
|
|
1418
|
+
elif name == "push":
|
|
1419
|
+
self.push(args, scope, cds, git_dir, overrides)
|
|
1420
|
+
elif name == "config":
|
|
1421
|
+
self.config(args)
|
|
1422
|
+
else:
|
|
1423
|
+
for word in args:
|
|
1424
|
+
if word.text == "--":
|
|
1425
|
+
break
|
|
1426
|
+
if is_no_verify(word.text):
|
|
1427
|
+
raise Blocked(f"--no-verify is forbidden on all git commands. {HOOK_REMEDY}")
|
|
1428
|
+
|
|
1429
|
+
def override(self, option: str, value: Word) -> None:
|
|
1430
|
+
key, _, setting = value.text.partition("=")
|
|
1431
|
+
lowered = key.strip().lower()
|
|
1432
|
+
if value.dynamic and not re.fullmatch(r"[a-z0-9._-]+", lowered):
|
|
1433
|
+
# `K=core.hooksPath; git -c "$K=/dev/null"`: the key is only known
|
|
1434
|
+
# once the shell expands it, so no literal-key rule can apply.
|
|
1435
|
+
raise Blocked(f"'{option} {value.text}' computes its configuration key in the shell, so it cannot be verified. {HOOK_REMEDY}")
|
|
1436
|
+
if lowered == "core.hookspath":
|
|
1437
|
+
raise Blocked(f"'{option} {key}=...' repoints the repository hooks. {HOOK_REMEDY}")
|
|
1438
|
+
if lowered.startswith("alias."):
|
|
1439
|
+
raise Blocked(f"'{option} {key}=...' defines an alias this guard cannot see through. {HOOK_REMEDY}")
|
|
1440
|
+
if lowered in ("include.path",) or (lowered.startswith("includeif.") and lowered.endswith(".path")):
|
|
1441
|
+
raise Blocked(f"'{option} {key}=...' includes configuration this guard cannot read. {HOOK_REMEDY}")
|
|
1442
|
+
|
|
1443
|
+
def push_override(self, option: str, value: Word) -> None:
|
|
1444
|
+
"""A `-c remote.<r>.push=+...` forces this invocation alone.
|
|
1445
|
+
|
|
1446
|
+
Verified against git: a plain push rejected as non-fast-forward
|
|
1447
|
+
succeeds with `(forced update)` under this override. Only judged once
|
|
1448
|
+
the command is known to be a push, so `git -c ... config --list` is
|
|
1449
|
+
untouched.
|
|
1450
|
+
"""
|
|
1451
|
+
|
|
1452
|
+
key, _, setting = value.text.partition("=")
|
|
1453
|
+
if REMOTE_PUSH_KEY.match(key.strip()):
|
|
1454
|
+
if option == "--config-env" or value.dynamic:
|
|
1455
|
+
raise Blocked(
|
|
1456
|
+
f"'{option} {key}=...' sets a push refspec from a value this guard cannot read, "
|
|
1457
|
+
f"so it cannot be proven non-forcing.\n{FORCE_REMEDY}"
|
|
1458
|
+
)
|
|
1459
|
+
if setting.strip().startswith("+"):
|
|
1460
|
+
raise Blocked(f"'{option} {value.text}' sets a forcing '+' push refspec.\n{FORCE_REMEDY}")
|
|
1461
|
+
|
|
1462
|
+
def alias(
|
|
1463
|
+
self,
|
|
1464
|
+
name: str,
|
|
1465
|
+
args: list[Word],
|
|
1466
|
+
scope: Scope,
|
|
1467
|
+
cds: list[Word],
|
|
1468
|
+
depth: int,
|
|
1469
|
+
git_dir: Word | None = None,
|
|
1470
|
+
overrides: list[tuple[str, Word]] | None = None,
|
|
1471
|
+
) -> None:
|
|
1472
|
+
if not self.read_config:
|
|
1473
|
+
return
|
|
1474
|
+
# Past the limit the alias is neither read nor judged, so its command
|
|
1475
|
+
# is refused, never let through unchecked.
|
|
1476
|
+
if depth > MAX_DEPTH:
|
|
1477
|
+
raise Blocked(f"'git {name}' is an alias chain too deep to verify. {HOOK_REMEDY}")
|
|
1478
|
+
cwd = scope.cwd if scope.cwd and scope.cwd != "\0unresolved" else self.base_cwd
|
|
1479
|
+
for cd in cds:
|
|
1480
|
+
if cd.dynamic or UNRESOLVABLE.search(cd.text):
|
|
1481
|
+
# `git -C "$D" publish`: D's repository may define publish.
|
|
1482
|
+
raise Blocked(f"'git {name}' may be an alias of a repository chosen at run time, which cannot be read. {HOOK_REMEDY}")
|
|
1483
|
+
cwd = cd.text if os.path.isabs(cd.text) else os.path.join(cwd or os.getcwd(), cd.text)
|
|
1484
|
+
# The alias is read from the repository git will use: `--git-dir`
|
|
1485
|
+
# selects one whose aliases the current checkout does not have.
|
|
1486
|
+
selected = None
|
|
1487
|
+
if git_dir is not None:
|
|
1488
|
+
if git_dir.dynamic or UNRESOLVABLE.search(git_dir.text):
|
|
1489
|
+
raise Blocked(f"'git {name}' may be an alias of a repository chosen at run time, which cannot be read. {HOOK_REMEDY}")
|
|
1490
|
+
selected = git_dir.text if os.path.isabs(git_dir.text) else os.path.join(cwd or os.getcwd(), git_dir.text)
|
|
1491
|
+
self.refuse_lost(scope)
|
|
1492
|
+
read = git_read(["config", "--get", f"alias.{name}"], cwd, selected)
|
|
1493
|
+
if read is None:
|
|
1494
|
+
raise Blocked(f"the configuration that says whether 'git {name}' is an alias could not be read. {HOOK_REMEDY}")
|
|
1495
|
+
expansion = read.strip()
|
|
1496
|
+
if not expansion:
|
|
1497
|
+
return
|
|
1498
|
+
def spelled(word: Word) -> str:
|
|
1499
|
+
# A word the shell computes stays computed in the re-read: quoting
|
|
1500
|
+
# its text would turn `"$FLAG"` (maybe --no-verify) into a literal.
|
|
1501
|
+
return '"$GIT_ALIAS_ARG"' if word.dynamic or word.could_be_flag() else shlex.quote(word.text)
|
|
1502
|
+
|
|
1503
|
+
rest = " ".join(spelled(word) for word in args)
|
|
1504
|
+
if expansion.startswith("!"):
|
|
1505
|
+
self.script(f"{expansion[1:]} {rest}", cwd, depth + 1)
|
|
1506
|
+
else:
|
|
1507
|
+
# git keeps this invocation's `-c` settings for the expansion:
|
|
1508
|
+
# `git -c remote.origin.push=+HEAD:main publish` pushes with them.
|
|
1509
|
+
# (-C and --git-dir already chose `cwd`.)
|
|
1510
|
+
carried = " ".join(f"{option} {spelled(value)}" for option, value in overrides or [])
|
|
1511
|
+
self.script(f"git {carried} {expansion} {rest}", cwd, depth + 1)
|
|
1512
|
+
|
|
1513
|
+
def env_chdir_paired(self, stages_all: bool, scope: Scope, cds: list[Word], git_dir: Word | None) -> None:
|
|
1514
|
+
"""Refuse a commit moved by `env -C` that would record a paired
|
|
1515
|
+
change's `changes` path without its `requires` path. bash_guard.py
|
|
1516
|
+
owns this rule but reads the index of the directory the command
|
|
1517
|
+
starts in, so `env -C sub git commit` is judged here, in the
|
|
1518
|
+
repository git will really use. A repository chosen at run time, or
|
|
1519
|
+
an index that cannot be read, is refused: no answer is not "no
|
|
1520
|
+
change"."""
|
|
1521
|
+
|
|
1522
|
+
run_in = scope.cwd if scope.cwd is not None else (self.base_cwd or os.getcwd())
|
|
1523
|
+
for cd in cds:
|
|
1524
|
+
run_in = self.resolve_dir(cd, run_in, strict=False)
|
|
1525
|
+
selected: str | None = None
|
|
1526
|
+
if git_dir is not None:
|
|
1527
|
+
selected = self.resolve_dir(git_dir, run_in, strict=False)
|
|
1528
|
+
if "\0unresolved" in (run_in, selected):
|
|
1529
|
+
raise Blocked(f"'env -C' runs this commit in a repository chosen at run time, whose index cannot be read. {PAIRED_REMEDY}")
|
|
1530
|
+
staged = git_read(["diff", "--cached", "--name-only"], run_in, selected)
|
|
1531
|
+
tracked = git_read(["diff", "--name-only"], run_in, selected) if stages_all else ""
|
|
1532
|
+
if staged is None or tracked is None or not os.path.isdir(run_in):
|
|
1533
|
+
raise Blocked(f"the index of the repository 'env -C' moves this commit into could not be read. {PAIRED_REMEDY}")
|
|
1534
|
+
paths = [*staged.splitlines(), *tracked.splitlines()]
|
|
1535
|
+
for pair in self.paired_changes:
|
|
1536
|
+
if any(path.endswith(pair.changes) for path in paths) and not any(pair.requires in path for path in paths):
|
|
1537
|
+
raise Blocked(
|
|
1538
|
+
f"'{pair.changes}' is staged without a path containing '{pair.requires}' in the "
|
|
1539
|
+
f"repository 'env -C' moves this commit into: {pair.reason.rstrip('.')}. {PAIRED_REMEDY}"
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
def commit(self, args: list[Word]) -> bool:
|
|
1543
|
+
"""Refuse a hook bypass among git commit's options, and return whether
|
|
1544
|
+
they stage tracked changes too (`-a`, `--all`). Option values
|
|
1545
|
+
(`-m '-n'`, `-m '-a'`) are skipped, so they are never read as options."""
|
|
1546
|
+
|
|
1547
|
+
stages_all = False
|
|
1548
|
+
value_long = {
|
|
1549
|
+
"--message", "--file", "--reuse-message", "--reedit-message", "--fixup",
|
|
1550
|
+
"--squash", "--author", "--date", "--template", "--cleanup", "--trailer",
|
|
1551
|
+
"--pathspec-from-file",
|
|
1552
|
+
}
|
|
1553
|
+
runtime = Blocked(
|
|
1554
|
+
"a word expanded at run time sits in an option position of git commit, "
|
|
1555
|
+
f"where it could become --no-verify. Write the flags literally. {HOOK_REMEDY}"
|
|
1556
|
+
)
|
|
1557
|
+
index = 0
|
|
1558
|
+
while index < len(args):
|
|
1559
|
+
word = args[index]
|
|
1560
|
+
text = word.text
|
|
1561
|
+
literal = text if word.first_dynamic is None else text[: word.first_dynamic]
|
|
1562
|
+
if text == "--" and not word.could_be_flag():
|
|
1563
|
+
return stages_all
|
|
1564
|
+
if literal.startswith("--") and "=" in literal:
|
|
1565
|
+
# `--message="$MSG"`: the option NAME is literal, only its value is not.
|
|
1566
|
+
if is_no_verify(literal):
|
|
1567
|
+
raise Blocked(f"--no-verify is forbidden on all git commands. {HOOK_REMEDY}")
|
|
1568
|
+
index += 1
|
|
1569
|
+
continue
|
|
1570
|
+
if literal.startswith("-") and len(literal) > 1 and not literal.startswith("--"):
|
|
1571
|
+
cluster = literal[1:]
|
|
1572
|
+
consumed = False
|
|
1573
|
+
for position, flag in enumerate(cluster):
|
|
1574
|
+
if flag == "n":
|
|
1575
|
+
raise Blocked(f"'git commit -n' is --no-verify. {HOOK_REMEDY}")
|
|
1576
|
+
if flag == "a":
|
|
1577
|
+
stages_all = True
|
|
1578
|
+
if flag in "mFCct":
|
|
1579
|
+
consumed = True
|
|
1580
|
+
if position == len(cluster) - 1 and literal == text:
|
|
1581
|
+
index += 1 # the value is the next word
|
|
1582
|
+
break
|
|
1583
|
+
if flag in "Su":
|
|
1584
|
+
consumed = True
|
|
1585
|
+
break
|
|
1586
|
+
if literal != text and not consumed:
|
|
1587
|
+
raise runtime
|
|
1588
|
+
index += 1
|
|
1589
|
+
continue
|
|
1590
|
+
if word.could_be_flag():
|
|
1591
|
+
raise runtime
|
|
1592
|
+
if text.startswith("--"):
|
|
1593
|
+
if is_no_verify(text):
|
|
1594
|
+
raise Blocked(f"--no-verify is forbidden on all git commands. {HOOK_REMEDY}")
|
|
1595
|
+
stages_all = stages_all or text == "--all"
|
|
1596
|
+
# git takes any unambiguous abbreviation (`--mes '-n'` is a
|
|
1597
|
+
# message); an ambiguous one is an error and nothing runs.
|
|
1598
|
+
takes_value = len(text) > 2 and any(option.startswith(text) for option in value_long)
|
|
1599
|
+
index += 2 if takes_value else 1
|
|
1600
|
+
continue
|
|
1601
|
+
index += 1
|
|
1602
|
+
return stages_all
|
|
1603
|
+
|
|
1604
|
+
def push(
|
|
1605
|
+
self,
|
|
1606
|
+
args: list[Word],
|
|
1607
|
+
scope: Scope,
|
|
1608
|
+
cds: list[Word],
|
|
1609
|
+
git_dir: Word | None,
|
|
1610
|
+
overrides: list[tuple[str, Word]],
|
|
1611
|
+
) -> None:
|
|
1612
|
+
for option, value in overrides:
|
|
1613
|
+
self.push_override(option, value)
|
|
1614
|
+
value_options = {"-o", "--push-option", "--repo", "--receive-pack", "--exec"}
|
|
1615
|
+
positionals: list[Word] = []
|
|
1616
|
+
index = 0
|
|
1617
|
+
while index < len(args):
|
|
1618
|
+
word = args[index]
|
|
1619
|
+
text = word.text
|
|
1620
|
+
if text == "--":
|
|
1621
|
+
positionals.extend(args[index + 1 :])
|
|
1622
|
+
break
|
|
1623
|
+
if text in value_options:
|
|
1624
|
+
index += 2
|
|
1625
|
+
continue
|
|
1626
|
+
literal = text if word.first_dynamic is None else text[: word.first_dynamic]
|
|
1627
|
+
if literal.startswith("-") and literal != text and "=" not in literal:
|
|
1628
|
+
raise Blocked(f"the push option '{text}' is completed at run time and could become --force.\n{FORCE_REMEDY}")
|
|
1629
|
+
if text.startswith("--"):
|
|
1630
|
+
if is_no_verify(text):
|
|
1631
|
+
raise Blocked(f"--no-verify is forbidden on all git commands. {HOOK_REMEDY}")
|
|
1632
|
+
danger = dangerous_long(text)
|
|
1633
|
+
if danger == "--mirror":
|
|
1634
|
+
raise Blocked(f"'git push --mirror' force-updates and deletes remote refs.\n{FORCE_REMEDY}")
|
|
1635
|
+
if danger:
|
|
1636
|
+
raise Blocked(f"'git push {danger}' is forbidden.\n{FORCE_REMEDY}")
|
|
1637
|
+
index += 1
|
|
1638
|
+
continue
|
|
1639
|
+
if text.startswith("-") and len(text) > 1 and not word.dynamic:
|
|
1640
|
+
for flag in text[1:]:
|
|
1641
|
+
if flag == "o":
|
|
1642
|
+
if text.endswith("o"):
|
|
1643
|
+
index += 1
|
|
1644
|
+
break
|
|
1645
|
+
if flag == "f":
|
|
1646
|
+
raise Blocked(f"'git push -f' is forbidden.\n{FORCE_REMEDY}")
|
|
1647
|
+
index += 1
|
|
1648
|
+
continue
|
|
1649
|
+
positionals.append(word)
|
|
1650
|
+
index += 1
|
|
1651
|
+
for word in positionals:
|
|
1652
|
+
if word.could_be_flag():
|
|
1653
|
+
raise Blocked(
|
|
1654
|
+
f"the refspec '{word.text}' is computed at run time and could carry a forcing '+'. "
|
|
1655
|
+
f"Push a literal refspec (e.g. HEAD).\n{FORCE_REMEDY}"
|
|
1656
|
+
)
|
|
1657
|
+
if word.text.startswith("+") or re.search(r"(^|[\s])\+", word.text):
|
|
1658
|
+
raise Blocked(f"a '+' refspec on git push forces the update.\n{FORCE_REMEDY}")
|
|
1659
|
+
if not self.read_config:
|
|
1660
|
+
return
|
|
1661
|
+
cwd = scope.cwd
|
|
1662
|
+
if cwd == "\0unresolved":
|
|
1663
|
+
raise Blocked(f"cannot statically resolve the directory this push runs in.\n{AMBIGUOUS_REMEDY}")
|
|
1664
|
+
for cd in cds:
|
|
1665
|
+
cwd = self.resolve_dir(cd, cwd, strict=True)
|
|
1666
|
+
directory = None
|
|
1667
|
+
if git_dir is not None:
|
|
1668
|
+
directory = self.resolve_dir(git_dir, cwd, strict=True)
|
|
1669
|
+
target = cwd or self.base_cwd
|
|
1670
|
+
if target and not os.path.isdir(target):
|
|
1671
|
+
raise Blocked(f"cannot inspect the target directory '{target}'.\n{AMBIGUOUS_REMEDY}")
|
|
1672
|
+
unread = f"the push configuration this push would follow could not be read.\n{AMBIGUOUS_REMEDY}"
|
|
1673
|
+
self.refuse_lost(scope)
|
|
1674
|
+
listed = git_read(["remote"], target, directory)
|
|
1675
|
+
if listed is None:
|
|
1676
|
+
raise Blocked(unread)
|
|
1677
|
+
remotes = set(listed.split())
|
|
1678
|
+
named = next((w.text for w in positionals if w.text in remotes), None)
|
|
1679
|
+
for remote in [named] if named else sorted(remotes) or ["origin"]:
|
|
1680
|
+
values = git_read(["config", "--get-all", f"remote.{remote}.push"], target, directory)
|
|
1681
|
+
if values is None:
|
|
1682
|
+
raise Blocked(unread)
|
|
1683
|
+
if any(line.strip().startswith("+") for line in values.splitlines()):
|
|
1684
|
+
raise Blocked(
|
|
1685
|
+
f"remote.{remote}.push is configured with a forcing '+' refspec, so even a "
|
|
1686
|
+
f"plain `git push` rewrites the remote.\n{FORCE_REMEDY}"
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
def config(self, args: list[Word]) -> None:
|
|
1690
|
+
readers = {
|
|
1691
|
+
"--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "-l",
|
|
1692
|
+
"--show-origin", "--show-scope", "--name-only", "get", "list",
|
|
1693
|
+
"--get-color", "--get-colorbool",
|
|
1694
|
+
}
|
|
1695
|
+
# Edit mode names no key yet opens the whole file, and GIT_EDITOR can
|
|
1696
|
+
# write core.hooksPath or an alias into it unseen. Every spelling is
|
|
1697
|
+
# refused: `edit`, `--edit` and its abbreviations, `-e` in a short
|
|
1698
|
+
# cluster (`-ze`; `-f` takes the rest of the cluster as its file).
|
|
1699
|
+
edits = Blocked(f"editing git configuration can repoint the hooks or add an alias unseen. {HOOK_REMEDY}")
|
|
1700
|
+
if args and args[0].text == "edit":
|
|
1701
|
+
raise edits
|
|
1702
|
+
for word in args:
|
|
1703
|
+
if word.text == "--" and not word.dynamic:
|
|
1704
|
+
break
|
|
1705
|
+
literal = word.text if word.first_dynamic is None else word.text[: word.first_dynamic]
|
|
1706
|
+
if literal.startswith("-") and literal != word.text:
|
|
1707
|
+
raise Blocked(f"a git config option completed at run time could be --edit. {HOOK_REMEDY}")
|
|
1708
|
+
if literal.startswith("--"):
|
|
1709
|
+
name = literal.split("=", 1)[0]
|
|
1710
|
+
# `--e` is ambiguous (with --expiry-date) and errors; refused anyway.
|
|
1711
|
+
if len(name) > 2 and "--edit".startswith(name):
|
|
1712
|
+
raise edits
|
|
1713
|
+
elif literal.startswith("-"):
|
|
1714
|
+
cluster = literal[1:].split("f", 1)[0]
|
|
1715
|
+
if "e" in cluster:
|
|
1716
|
+
raise edits
|
|
1717
|
+
value_options = {"--file", "-f", "--blob", "--type", "-t", "--default", "--comment", "--value", "--url"}
|
|
1718
|
+
# Every option git config takes without a value. git also takes any
|
|
1719
|
+
# unique prefix of a long option and a separate value for it, so
|
|
1720
|
+
# `--typ path core.hooksPath /dev/null` sets the hooks path: an option
|
|
1721
|
+
# outside these two sets is refused, never skipped.
|
|
1722
|
+
flag_options = {
|
|
1723
|
+
"--global", "--system", "--local", "--worktree",
|
|
1724
|
+
"--replace-all", "--add", "--unset", "--unset-all", "--rename-section", "--remove-section",
|
|
1725
|
+
"-e", "--edit", "--no-type", "--bool", "--int", "--bool-or-int", "--bool-or-str", "--path",
|
|
1726
|
+
"--expiry-date", "--fixed-value", "-z", "--null", "--includes", "--no-includes",
|
|
1727
|
+
"--all", "--regexp", "--show-names", "--no-show-names", "--append",
|
|
1728
|
+
}
|
|
1729
|
+
positionals: list[Word] = []
|
|
1730
|
+
index = 0
|
|
1731
|
+
subcommand_style = bool(args) and args[0].text in (
|
|
1732
|
+
"set", "unset", "get", "list", "rename-section", "remove-section",
|
|
1733
|
+
)
|
|
1734
|
+
if subcommand_style:
|
|
1735
|
+
if args[0].text in ("get", "list"):
|
|
1736
|
+
return
|
|
1737
|
+
index = 1
|
|
1738
|
+
# After `--` every word is a key or value: `git config -- core.hooksPath
|
|
1739
|
+
# --list` SETS the hooks path to `--list`.
|
|
1740
|
+
options_end = len(args)
|
|
1741
|
+
while index < len(args):
|
|
1742
|
+
word = args[index]
|
|
1743
|
+
if word.text == "--" and not word.dynamic:
|
|
1744
|
+
options_end = index
|
|
1745
|
+
positionals.extend(args[index + 1 :])
|
|
1746
|
+
break
|
|
1747
|
+
if word.text in readers:
|
|
1748
|
+
return
|
|
1749
|
+
if word.text in value_options:
|
|
1750
|
+
index += 2
|
|
1751
|
+
continue
|
|
1752
|
+
if word.text.startswith("-"):
|
|
1753
|
+
name, attached = word.text.split("=", 1)[0], "=" in word.text
|
|
1754
|
+
if word.dynamic or name not in (value_options if attached else flag_options):
|
|
1755
|
+
raise Blocked(
|
|
1756
|
+
f"git config {word.text!r} is an option this guard does not know, so what it writes cannot be verified. {HOOK_REMEDY}"
|
|
1757
|
+
)
|
|
1758
|
+
index += 1
|
|
1759
|
+
continue
|
|
1760
|
+
positionals.append(word)
|
|
1761
|
+
index += 1
|
|
1762
|
+
if not positionals:
|
|
1763
|
+
return
|
|
1764
|
+
# `git config KEY` alone reads KEY: no value and only options that
|
|
1765
|
+
# select where to read (an unknown or abbreviated option may be a
|
|
1766
|
+
# write such as `--unset`, so it keeps the key judged).
|
|
1767
|
+
location_options = {
|
|
1768
|
+
"--global", "--local", "--system", "--worktree", "--file", "-f", "--blob",
|
|
1769
|
+
"--type", "--bool", "--int", "--bool-or-int", "--path", "--expiry-date",
|
|
1770
|
+
"--null", "-z", "--includes", "--no-includes", "--default",
|
|
1771
|
+
}
|
|
1772
|
+
options = [
|
|
1773
|
+
word.text for word in args[1 if subcommand_style else 0 : options_end] if word.text.startswith("-")
|
|
1774
|
+
]
|
|
1775
|
+
# A lone computed word before `--` may itself be `--edit`.
|
|
1776
|
+
if len(positionals) == 1 and positionals[0].could_be_flag() and options_end == len(args):
|
|
1777
|
+
raise Blocked(f"a git config word computed at run time could be --edit. {HOOK_REMEDY}")
|
|
1778
|
+
if (
|
|
1779
|
+
not subcommand_style
|
|
1780
|
+
and len(positionals) == 1
|
|
1781
|
+
and all(option.split("=", 1)[0] in location_options for option in options)
|
|
1782
|
+
):
|
|
1783
|
+
return
|
|
1784
|
+
key = positionals[0]
|
|
1785
|
+
if key.dynamic:
|
|
1786
|
+
raise Blocked(f"git config with a computed key cannot be verified. {HOOK_REMEDY}")
|
|
1787
|
+
lowered = key.text.lower()
|
|
1788
|
+
if lowered == "core.hookspath":
|
|
1789
|
+
raise Blocked(f"changing core.hooksPath repoints the repository hooks. {HOOK_REMEDY}")
|
|
1790
|
+
if lowered.startswith("alias."):
|
|
1791
|
+
raise Blocked(f"a git alias can hide --no-verify or --force from this guard. {HOOK_REMEDY}")
|
|
1792
|
+
if lowered == "include.path" or (lowered.startswith("includeif.") and lowered.endswith(".path")):
|
|
1793
|
+
raise Blocked(f"an included config file can repoint the hooks unseen. {HOOK_REMEDY}")
|
|
1794
|
+
if REMOTE_PUSH_KEY.match(key.text):
|
|
1795
|
+
value = positionals[1] if len(positionals) > 1 else None
|
|
1796
|
+
if value is not None and (value.dynamic or value.text.strip().startswith("+")):
|
|
1797
|
+
raise Blocked(f"a forcing '+' push refspec rewrites the remote on every push.\n{FORCE_REMEDY}")
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
PRINTF_DIRECTIVE = re.compile(r"%(.)", re.DOTALL)
|
|
1801
|
+
PRINTF_ESCAPES = {"n": "\n", "t": "\t", "\\": "\\", "r": "\r", "a": "\a", "v": "\v", "f": "\f", "'": "'", '"': '"'}
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def printf_unescape(text: str) -> str:
|
|
1805
|
+
"""Decode printf's escapes as bash does: named ones, `\\xHH`, `\\uHHHH`,
|
|
1806
|
+
`\\UHHHHHHHH` and octal `\\NNN` (and `%b`'s `\\0NNN`), so `\\x2dn` is `-n`."""
|
|
1807
|
+
|
|
1808
|
+
def decode(match: re.Match[str]) -> str:
|
|
1809
|
+
escape = match.group(1)
|
|
1810
|
+
if escape[0] in "xuU" and len(escape) > 1:
|
|
1811
|
+
code = int(escape[1:], 16)
|
|
1812
|
+
return chr(code) if code <= 0x10FFFF else "�"
|
|
1813
|
+
if escape[0] in "01234567":
|
|
1814
|
+
return chr(int(escape, 8) & 0xFF)
|
|
1815
|
+
return PRINTF_ESCAPES.get(escape, "\\" + escape)
|
|
1816
|
+
|
|
1817
|
+
return re.sub(
|
|
1818
|
+
r"\\(x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{1,4}|U[0-9A-Fa-f]{1,8}|0[0-7]{0,3}|[1-7][0-7]{0,2}|.)",
|
|
1819
|
+
decode,
|
|
1820
|
+
text,
|
|
1821
|
+
flags=re.DOTALL,
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def render_printf(words: list[str]) -> str | None:
|
|
1826
|
+
"""What `printf WORDS` writes: the program a `printf … | bash` runs.
|
|
1827
|
+
|
|
1828
|
+
The format is reused until the arguments run out, as printf does, so
|
|
1829
|
+
`printf '%s ' git push --force` writes `git push --force `. Only %s, %b,
|
|
1830
|
+
%d, %i and %% are rendered; any other directive (widths included) is
|
|
1831
|
+
None, and the caller refuses.
|
|
1832
|
+
"""
|
|
1833
|
+
|
|
1834
|
+
if words[:1] == ["--"]:
|
|
1835
|
+
words = words[1:]
|
|
1836
|
+
if not words:
|
|
1837
|
+
return ""
|
|
1838
|
+
if words[0] == "-v":
|
|
1839
|
+
return "" # printf -v writes to a variable, not the pipe
|
|
1840
|
+
fmt, args = words[0], words[1:]
|
|
1841
|
+
if any(directive not in "sbdi%" for directive in PRINTF_DIRECTIVE.findall(fmt)):
|
|
1842
|
+
return None
|
|
1843
|
+
# Literal text and directives alternate. printf decodes escapes in the
|
|
1844
|
+
# format and in %b arguments only: a %s argument is written as it is, so
|
|
1845
|
+
# `printf '%s' 'echo \x27; git push -f #\x27'` writes the backslashes and
|
|
1846
|
+
# bash runs the push. Decoding it would turn them into quotes and hide it.
|
|
1847
|
+
pieces = PRINTF_DIRECTIVE.split(fmt)
|
|
1848
|
+
out: list[str] = []
|
|
1849
|
+
while True:
|
|
1850
|
+
used = 0
|
|
1851
|
+
for position, piece in enumerate(pieces):
|
|
1852
|
+
if position % 2 == 0:
|
|
1853
|
+
out.append(printf_unescape(piece))
|
|
1854
|
+
elif piece == "%":
|
|
1855
|
+
out.append("%")
|
|
1856
|
+
else:
|
|
1857
|
+
value = args[used] if used < len(args) else ""
|
|
1858
|
+
used += 1
|
|
1859
|
+
if piece in "di":
|
|
1860
|
+
# bash converts: `%d` of `'x` writes 120, never the quote.
|
|
1861
|
+
# Only a plain integer is rendered; anything else refuses.
|
|
1862
|
+
if value == "":
|
|
1863
|
+
value = "0"
|
|
1864
|
+
elif re.fullmatch(r"[ \t]*[+-]?\d+", value) is None:
|
|
1865
|
+
return None
|
|
1866
|
+
else:
|
|
1867
|
+
value = str(int(value))
|
|
1868
|
+
out.append(printf_unescape(value) if piece == "b" else value)
|
|
1869
|
+
args = args[used:]
|
|
1870
|
+
if used == 0 or not args:
|
|
1871
|
+
break
|
|
1872
|
+
return "".join(out)
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def git_read(args: list[str], cwd: str | None, git_dir: str | None) -> str | None:
|
|
1876
|
+
"""Configuration git will act on, or None when it could not be read.
|
|
1877
|
+
|
|
1878
|
+
A timeout, a crash or an error status is no answer: a guard reading "no alias" or "no forcing refspec" from a failed lookup
|
|
1879
|
+
would allow exactly what the lookup exists to find. `git config --get`'s
|
|
1880
|
+
status 1 means the key is unset, which is an answer. A directory that
|
|
1881
|
+
does not exist yet (made later in the same command) is read from its
|
|
1882
|
+
nearest existing ancestor: git finds a repository by walking up, so
|
|
1883
|
+
`mkdir plain; git -C plain publish` uses the enclosing repository's
|
|
1884
|
+
aliases.
|
|
1885
|
+
"""
|
|
1886
|
+
|
|
1887
|
+
while cwd and not os.path.isdir(cwd) and os.path.dirname(cwd) != cwd:
|
|
1888
|
+
cwd = os.path.dirname(cwd)
|
|
1889
|
+
prefix = [f"--git-dir={git_dir}"] if git_dir else []
|
|
1890
|
+
# The hook's input stays out of git's environment: over the platform's
|
|
1891
|
+
# exec cap git would not start, and the guard would refuse for want of
|
|
1892
|
+
# an answer.
|
|
1893
|
+
env = {key: value for key, value in os.environ.items() if key != "HOOK_INPUT"}
|
|
1894
|
+
try:
|
|
1895
|
+
result = subprocess.run(
|
|
1896
|
+
["git", *prefix, *args],
|
|
1897
|
+
capture_output=True,
|
|
1898
|
+
text=True,
|
|
1899
|
+
timeout=10,
|
|
1900
|
+
cwd=cwd or None,
|
|
1901
|
+
env=env,
|
|
1902
|
+
check=False,
|
|
1903
|
+
)
|
|
1904
|
+
except (OSError, subprocess.SubprocessError):
|
|
1905
|
+
return None
|
|
1906
|
+
if result.returncode == 0:
|
|
1907
|
+
return result.stdout
|
|
1908
|
+
if result.returncode == 1 and args[:1] == ["config"]:
|
|
1909
|
+
return ""
|
|
1910
|
+
return None
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def check(
|
|
1914
|
+
command: str, cwd: str | None, *, read_config: bool = True, paired_changes: Sequence[Any] = ()
|
|
1915
|
+
) -> str | None:
|
|
1916
|
+
"""The reason ``command`` must be refused, or None when it may run.
|
|
1917
|
+
``paired_changes`` are the consumer's (config.PairedChange); none turns
|
|
1918
|
+
that rule off."""
|
|
1919
|
+
|
|
1920
|
+
analyzer = Analyzer(cwd, read_config=read_config, paired_changes=paired_changes)
|
|
1921
|
+
try:
|
|
1922
|
+
analyzer.script(command, cwd)
|
|
1923
|
+
except Blocked as reason:
|
|
1924
|
+
return str(reason)
|
|
1925
|
+
return None
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
# ---------------------------------------------------------------------------
|
|
1929
|
+
# command_guard.py entry point.
|
|
1930
|
+
# ---------------------------------------------------------------------------
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def block(message: str) -> None:
|
|
1934
|
+
print(message, file=sys.stderr)
|
|
1935
|
+
raise SystemExit(2)
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def read_hook_input(raw: str) -> tuple[str, str]:
|
|
1939
|
+
"""The Bash command from a PreToolUse payload and the directory its shell
|
|
1940
|
+
runs in; refuse anything malformed.
|
|
1941
|
+
|
|
1942
|
+
The directory is the payload's ``cwd`` when it is absolute: hooks run from
|
|
1943
|
+
the main checkout, so a subagent's command in its own worktree would
|
|
1944
|
+
otherwise be judged against the wrong repository (bash_guard.py reads it
|
|
1945
|
+
the same way). Without one, the hook process's own directory.
|
|
1946
|
+
"""
|
|
1947
|
+
|
|
1948
|
+
try:
|
|
1949
|
+
data = json.loads(raw)
|
|
1950
|
+
except (ValueError, RecursionError):
|
|
1951
|
+
block("BLOCKED: malformed hook input (not JSON); refusing to run the command unchecked.")
|
|
1952
|
+
if not isinstance(data, dict):
|
|
1953
|
+
block("BLOCKED: malformed hook input (expected a JSON object).")
|
|
1954
|
+
tool_input = data.get("tool_input")
|
|
1955
|
+
if not isinstance(tool_input, dict):
|
|
1956
|
+
block("BLOCKED: malformed hook input (tool_input is not an object).")
|
|
1957
|
+
command = tool_input.get("command", "")
|
|
1958
|
+
if not isinstance(command, str):
|
|
1959
|
+
block("BLOCKED: malformed hook input (command is not a string).")
|
|
1960
|
+
cwd = data.get("cwd")
|
|
1961
|
+
return command, cwd if isinstance(cwd, str) and os.path.isabs(cwd) else os.getcwd()
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def hook_main(settings: Any = None) -> None:
|
|
1965
|
+
"""command_guard.py's second check: the hook input is in ``HOOK_INPUT``,
|
|
1966
|
+
``settings`` the consumer's config.Config (None: no paired changes).
|
|
1967
|
+
|
|
1968
|
+
Always ends in SystemExit(0) or SystemExit(2); the entry blocks on any
|
|
1969
|
+
other end, an exception included. The paired-change rule is
|
|
1970
|
+
bash_guard.py's, whose index model the command-guard suite holds, except
|
|
1971
|
+
for commits moved by `env -C` (Analyzer.env_chdir_paired).
|
|
1972
|
+
"""
|
|
1973
|
+
|
|
1974
|
+
paired = tuple(getattr(settings, "paired_changes", ()))
|
|
1975
|
+
command, cwd = read_hook_input(os.environ.get("HOOK_INPUT", ""))
|
|
1976
|
+
if command.strip():
|
|
1977
|
+
reason = check(command, cwd, paired_changes=paired)
|
|
1978
|
+
if reason is not None:
|
|
1979
|
+
block(f"BLOCKED: {reason}")
|
|
1980
|
+
raise SystemExit(0)
|