@veda-hq/kavach 0.0.0-stage → 0.3.0

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.
@@ -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)