phi-gate 0.2.0__py3-none-any.whl

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.
phi_gate/shell.py ADDED
@@ -0,0 +1,1582 @@
1
+ """Static analysis of Bash tool commands.
2
+
3
+ Before a command runs, the PreToolUse hook asks this module:
4
+
5
+ - which existing files and directories the command could read, print, copy,
6
+ link or send, so they can be scanned first;
7
+ - whether it sends data off the machine (curl, wget, scp, sftp, rsync, nc,
8
+ ncat, socat, ssh and similar tools, /dev/tcp redirections, network calls
9
+ in inline scripts);
10
+ - whether it reads a path that cannot be known without running it (command
11
+ substitution, unknown variables, paths computed inside inline scripts);
12
+ - which git commands print repository history (show, log -p, diff, stash
13
+ show -p, grep, archive, cat-file, blame and others) and with which
14
+ arguments;
15
+ - whether it creates commits, through `git commit`, an alias, or plumbing;
16
+ - whether it changes core.hooksPath;
17
+ - which files it writes.
18
+
19
+ What it follows: cd, pushd, popd, $PWD, $OLDPWD, ~+ and ~-; assignments,
20
+ including export, declare, readonly, local and typeset; environment
21
+ variables visible to the hook; for-loops over literal words; ANSI-C quoting;
22
+ brace expansion; globs; redirections; heredocs and here-strings; bash -c and
23
+ eval; command and process substitution (each analysed as a command of its
24
+ own); interpreter one-liners and heredoc scripts; git aliases given with -c
25
+ or configured in the repository.
26
+
27
+ What it does not cover: shell is a programming language, so this analysis
28
+ cannot be complete. A program or script file that opens files itself
29
+ (`python tool.py`, `make`, a test runner) is not analysed. Its standard
30
+ output is scanned by the PostToolUse hook, but only after the data has
31
+ reached the model's context, and that scan never sees data a command sends
32
+ to the network, to another process, or to a file. Paths that cannot be
33
+ resolved are reported in `Analysis.unresolved` so the caller can decide
34
+ conservatively.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import glob
40
+ import itertools
41
+ import os
42
+ import re
43
+ import shlex
44
+ import shutil
45
+ import subprocess
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+
49
+ SEPARATORS = {";", "&&", "||", "|", "&", "\n", "(", ")", "|&", ";;", "`"}
50
+ SHELLS = {"bash", "sh", "zsh", "dash", "ksh"}
51
+ WRAPPERS = {"sudo", "env", "nice", "nohup", "time", "command", "exec", "xargs", "timeout", "stdbuf", "builtin"}
52
+ KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until", "!", "{", "time"}
53
+
54
+ # Commands that change or describe files without printing their content. A
55
+ # segment run by one of these does not count as reading the files it names
56
+ # (so `rm` or `git rm --cached` on a PHI file, the remediation, is allowed).
57
+ # cp, mv, ln, install and rsync are handled separately: their sources count
58
+ # as reads.
59
+ NON_READING = {
60
+ "rm", "ls", "stat", "touch", "chmod", "chown", "shred", "unlink", "mkdir", "rmdir", "cd", "du", "wc",
61
+ "basename", "dirname", "realpath", "test", "[", "echo", "printf", "true", "false", "sleep", "export", "set",
62
+ "unset", "which", "type", "pwd", "date", "whoami", "kill", "pkill", "declare", "readonly", "local", "typeset",
63
+ "pushd", "popd", "alias", "hash", "shift", "wait", "jobs", "umask", "trap", "exit", "return", "break",
64
+ "continue", "nproc", "uname", "hostname", "id",
65
+ }
66
+ GIT_NON_READING = {
67
+ "rm", "mv", "add", "restore", "reset", "status", "ls-files", "stash", "checkout", "switch", "commit",
68
+ "commit-tree", "update-ref", "write-tree", "merge", "rebase", "cherry-pick", "revert", "branch", "tag",
69
+ "init", "fetch", "pull", "push", "remote", "config", "rev-parse", "worktree", "gc", "log", "show", "diff",
70
+ "whatchanged", "reflog", "grep", "archive", "cat-file", "fast-export", "format-patch", "range-diff",
71
+ "diff-tree", "diff-index", "diff-files", "shortlog", "ls-tree", "rev-list", "describe", "merge-base",
72
+ "show-ref", "symbolic-ref", "for-each-ref", "count-objects", "version", "help", "clean",
73
+ }
74
+
75
+ # Commands that walk directories given as arguments.
76
+ RECURSIVE_READERS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "ack-grep", "find", "tar", "zip", "7z", "zgrep"}
77
+ # Recursive by default; searching the working directory when given no path.
78
+ ALWAYS_RECURSIVE = {"rg", "ag", "ack", "ack-grep"}
79
+ GREP_FAMILY = {"grep", "egrep", "fgrep", "zgrep"}
80
+
81
+ # Commands that transmit data to another machine.
82
+ NETWORK = {
83
+ "curl", "wget", "scp", "sftp", "rsync", "nc", "ncat", "netcat", "socat", "ssh", "telnet", "ftp", "tftp",
84
+ "lftp", "rclone", "aws", "gsutil", "gcloud", "az", "azcopy", "s3cmd", "mail", "mailx", "sendmail", "mutt",
85
+ "http", "https", "xh", "openssl", "smbclient",
86
+ }
87
+ COPIERS = {"cp", "mv", "ln", "install", "rsync", "scp"}
88
+ # Options of copying commands that take a separate value.
89
+ COPY_VALUE_OPTS = {
90
+ "install": {"-m", "--mode", "-o", "--owner", "-g", "--group", "-S", "--suffix"},
91
+ "rsync": {"-e", "--rsh", "--exclude", "--include", "--filter", "-f"},
92
+ "scp": {"-P", "-i", "-F", "-l", "-o", "-c", "-J", "-S"},
93
+ }
94
+
95
+ WRITE_WORDS = {
96
+ "tee", "sed", "rm", "mv", "cp", "truncate", "chmod", "chown", "ln", "dd", "install", "shred", "unlink",
97
+ "python", "python3", "perl", "ruby", "node", "deno", "bun", "awk", "gawk", "echo", "printf", "touch",
98
+ "vi", "vim", "nano", "ed", "rsync", "git",
99
+ }
100
+ # Commands whose operands are the files they modify.
101
+ MODIFIERS = {"touch", "truncate", "chmod", "chown", "tee", "shred"}
102
+
103
+ MAX_EMBEDDED_SCAN = 20_000
104
+ MAX_BRACE_EXPANSIONS = 64
105
+ MAX_VALUES = 64
106
+ MAX_DEPTH = 3
107
+ GIT_TIMEOUT = 5
108
+
109
+ # Markers substituted into the command text before tokenising. They are in a
110
+ # Unicode private-use range, so they cannot collide with ordinary text.
111
+ LIT_DOLLAR = "" # a `$` that the shell does not expand (quoted or escaped)
112
+ LIT_TICK = "" # a literal backtick
113
+ MARK = "" # brackets a placeholder: S<n> command substitution, P<n> process substitution, D<n> heredoc
114
+ _PLACEHOLDER = re.compile(MARK + r"([SPD])(\d+)" + MARK)
115
+
116
+ # The plugin's own CLI. Only this file is trusted to run read-only phi-gate
117
+ # commands without its arguments being scanned.
118
+ PLUGIN_ENTRY = Path(__file__).resolve().parents[2] / "bin" / "phi-gate"
119
+ ENTRY_NAME = "phi-gate"
120
+ # Ignored or untracked paths `git clean -x` could delete that phi-gate
121
+ # depends on: its state, its configuration, and the agent's settings.
122
+ CLEAN_PROTECTED = (".phi-gate", ".phi-gate.toml", ".claude", ".mcp.json", ".pre-commit-config.yaml")
123
+
124
+ _EMBEDDED_PATH = re.compile(
125
+ r"(?<![\w~./-])(?:[\w~./-]{0,200}[\w-]\.[A-Za-z0-9]{1,8}\b|[\w~.-]{0,200}/[\w~./-]{1,300})"
126
+ )
127
+ _BRACE = re.compile(r"^(.*?)\{([^{}]*,[^{}]*)\}(.*)$")
128
+ _ASSIGN = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$", re.S)
129
+ _UNEXPANDED = re.compile(r"\$(?:[A-Za-z_{(@*#?!$0-9-])")
130
+ _VAR_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-=+?])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)")
131
+ _HEREDOC = re.compile(r"<<(-?)[ \t]*(?:'([^'\n]*)'|\"([^\"\n]*)\"|\\?([^\s;&|<>()'\"]+))")
132
+ # Substitutions whose output is known not to be a file path, or is known.
133
+ _SAFE_SUBST = re.compile(
134
+ r"(?:date|whoami|id(?: -[ugn]+)?|nproc|uname(?: -\w+)?|hostname|arch|getconf \w+|tput \w+|"
135
+ r"git rev-parse (?:--short )?HEAD|git branch --show-current|git describe(?: [\w-]+)*)(?:\s+[+\w%:-]*)*"
136
+ )
137
+
138
+
139
+ def tokenize(command: str) -> list[str]:
140
+ try:
141
+ lex = shlex.shlex(command.replace("`", " ` "), posix=True, punctuation_chars="();<>|&\n")
142
+ lex.whitespace = " \t\r"
143
+ lex.whitespace_split = True
144
+ return list(lex)
145
+ except ValueError: # unbalanced quotes: fall back to a crude split
146
+ return re.findall(r"[^\s;&|<>()]+|[;&|<>()\n]+", command)
147
+
148
+
149
+ def segments(tokens: list[str]) -> list[list[str]]:
150
+ return [seg for _, seg in _segments(tokens)]
151
+
152
+
153
+ def _segments(tokens: list[str]) -> list[tuple[str, list[str]]]:
154
+ """Simple commands with the separator that precedes each."""
155
+ out: list[tuple[str, list[str]]] = [("", [])]
156
+ for tok in tokens:
157
+ if tok in SEPARATORS:
158
+ out.append((tok, []))
159
+ else:
160
+ out[-1][1].append(tok)
161
+ return [(sep, s) for sep, s in out if s]
162
+
163
+
164
+ def _lit(word: str) -> str:
165
+ """The word as the program receives it, with literal-dollar markers restored."""
166
+ return word.replace(LIT_DOLLAR, "$").replace(LIT_TICK, "`")
167
+
168
+
169
+ def _expand_vars(word: str, cwd: Path, oldpwd: Path | None = None) -> str:
170
+ home = str(Path.home())
171
+ word = re.sub(r"\$\{PWD\}|\$PWD\b", lambda _m: str(cwd), word)
172
+ word = re.sub(r"\$\{HOME\}|\$HOME\b", lambda _m: home, word)
173
+ if oldpwd is not None:
174
+ word = re.sub(r"\$\{OLDPWD\}|\$OLDPWD\b", lambda _m: str(oldpwd), word)
175
+ if word == "~+" or word.startswith("~+/"):
176
+ word = str(cwd) + word[2:]
177
+ elif oldpwd is not None and (word == "~-" or word.startswith("~-/")):
178
+ word = str(oldpwd) + word[2:]
179
+ elif word == "~" or word.startswith("~/"):
180
+ word = home + word[1:]
181
+ return word
182
+
183
+
184
+ def _expand_braces(word: str) -> list[str]:
185
+ results = [word]
186
+ for _ in range(3):
187
+ nxt: list[str] = []
188
+ changed = False
189
+ for w in results:
190
+ m = _BRACE.match(w)
191
+ if m:
192
+ changed = True
193
+ nxt.extend(m.group(1) + alt + m.group(3) for alt in m.group(2).split(","))
194
+ else:
195
+ nxt.append(w)
196
+ results = nxt[:MAX_BRACE_EXPANSIONS]
197
+ if not changed:
198
+ break
199
+ return results
200
+
201
+
202
+ def _command_word(seg: list[str]) -> tuple[str, int]:
203
+ """Basename of the command a segment runs, skipping VAR=x, keywords and wrappers."""
204
+ i = 0
205
+ while i < len(seg):
206
+ w = seg[i]
207
+ if _ASSIGN.match(w) or w in KEYWORDS:
208
+ i += 1
209
+ continue
210
+ name = os.path.basename(_lit(w))
211
+ if name in WRAPPERS:
212
+ i += 1
213
+ while i < len(seg) and seg[i].startswith("-"):
214
+ i += 1
215
+ if name == "timeout" and i < len(seg):
216
+ i += 1 # the duration
217
+ continue
218
+ return name, i
219
+ return "", len(seg)
220
+
221
+
222
+ @dataclass
223
+ class Commit:
224
+ args: list[str]
225
+ bypass: bool = False
226
+ include_all: bool = False
227
+ kind: str = "commit"
228
+
229
+
230
+ @dataclass
231
+ class Move:
232
+ sources: list[Path]
233
+ dest: Path
234
+
235
+
236
+ @dataclass
237
+ class GitRead:
238
+ """A git command that prints file content from the index or history."""
239
+
240
+ sub: str
241
+ args: list[str]
242
+ cwd: Path
243
+ unresolved: bool = False
244
+
245
+
246
+ @dataclass
247
+ class Analysis:
248
+ read_files: list[Path] = field(default_factory=list)
249
+ read_dirs: list[Path] = field(default_factory=list)
250
+ commits: list[Commit] = field(default_factory=list)
251
+ stages_changes: bool = False # `git add` somewhere in the command
252
+ disables_hooks: bool = False
253
+ shows_staged: bool = False # `git diff --cached` prints staged content
254
+ writes: list[Path] = field(default_factory=list) # paths a writing command names
255
+ created: list[Path] = field(default_factory=list) # paths the command certainly creates or modifies
256
+ egress: list[str] = field(default_factory=list) # how the command sends data off the machine
257
+ unresolved: list[str] = field(default_factory=list) # reads whose path is known only at run time
258
+ git_reads: list[GitRead] = field(default_factory=list)
259
+ moves: list[Move] = field(default_factory=list)
260
+
261
+
262
+ def commit_flags(args: list[str]) -> tuple[bool, bool]:
263
+ """(bypasses_hooks, includes_unstaged) for `git commit` arguments."""
264
+ bypass = include_all = False
265
+ skip_next = False
266
+ takes_value = {"-m", "--message", "-F", "--file", "-C", "-c", "--author", "--date", "-t", "--template",
267
+ "--reuse-message", "--reedit-message", "--fixup", "--squash", "--cleanup", "--trailer"}
268
+ for tok in args:
269
+ if skip_next:
270
+ skip_next = False
271
+ continue
272
+ if tok in takes_value:
273
+ skip_next = True
274
+ continue
275
+ if tok.startswith("--"):
276
+ name = tok[2:].split("=", 1)[0]
277
+ if len(name) >= 4 and "no-verify".startswith(name): # git accepts unambiguous prefixes
278
+ bypass = True
279
+ elif len(name) >= 2 and "all".startswith(name):
280
+ include_all = True
281
+ continue
282
+ if tok.startswith("-") and len(tok) > 1:
283
+ for k, ch in enumerate(tok[1:]):
284
+ if ch in "mFCct": # option with a value: rest of cluster, or the next token
285
+ if k == len(tok) - 2:
286
+ skip_next = True
287
+ break
288
+ if ch == "n":
289
+ bypass = True
290
+ if ch == "a":
291
+ include_all = True
292
+ return bypass, include_all
293
+
294
+
295
+ def _long_no_verify(args: list[str]) -> bool:
296
+ for tok in args:
297
+ if tok == "--":
298
+ break
299
+ name = tok[2:].split("=", 1)[0] if tok.startswith("--") else ""
300
+ if len(name) >= 6 and "no-verify".startswith(name):
301
+ return True
302
+ return False
303
+
304
+
305
+ # --------------------------------------------------------------------------
306
+ # Text preparation: quoting, substitutions, heredocs
307
+ # --------------------------------------------------------------------------
308
+
309
+
310
+ @dataclass
311
+ class _Prepared:
312
+ text: str
313
+ subs: list[str] = field(default_factory=list)
314
+ procs: list[str] = field(default_factory=list)
315
+ docs: list[str] = field(default_factory=list)
316
+
317
+
318
+ _C_ESCAPES = {"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", "n": "\n", "r": "\r", "t": "\t",
319
+ "v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?"}
320
+
321
+
322
+ def _ansi_c(text: str, start: int) -> tuple[int, str]:
323
+ """Decode the body of $'...' starting at `start`; returns (index after the quote, value)."""
324
+ out: list[str] = []
325
+ i, n = start, len(text)
326
+ while i < n:
327
+ c = text[i]
328
+ if c == "'":
329
+ return i + 1, "".join(out)
330
+ if c != "\\" or i + 1 >= n:
331
+ out.append(c)
332
+ i += 1
333
+ continue
334
+ e = text[i + 1]
335
+ if e in _C_ESCAPES:
336
+ out.append(_C_ESCAPES[e])
337
+ i += 2
338
+ elif e in "xuU":
339
+ width = {"x": 2, "u": 4, "U": 8}[e]
340
+ m = re.match(r"[0-9A-Fa-f]+", text[i + 2:i + 2 + width])
341
+ if m:
342
+ try:
343
+ out.append(chr(int(m.group(0), 16)))
344
+ except (ValueError, OverflowError):
345
+ pass
346
+ i += 2 + len(m.group(0))
347
+ else:
348
+ out.append("\\" + e)
349
+ i += 2
350
+ elif e in "01234567":
351
+ m = re.match(r"[0-7]{1,3}", text[i + 1:i + 4])
352
+ assert m is not None
353
+ out.append(chr(int(m.group(0), 8) & 0xFF))
354
+ i += 1 + len(m.group(0))
355
+ elif e == "c" and i + 2 < n:
356
+ out.append(chr(ord(text[i + 2]) & 0x1F))
357
+ i += 3
358
+ else:
359
+ out.append("\\" + e)
360
+ i += 2
361
+ return n, "".join(out)
362
+
363
+
364
+ def _squote(value: str) -> str:
365
+ value = value.replace("$", LIT_DOLLAR).replace("`", LIT_TICK)
366
+ return "'" + value.replace("'", "'\\''") + "'"
367
+
368
+
369
+ def _matching_paren(text: str, open_idx: int) -> int:
370
+ depth = 0
371
+ quote: str | None = None
372
+ k = open_idx
373
+ n = len(text)
374
+ while k < n:
375
+ ch = text[k]
376
+ if quote:
377
+ if ch == "\\" and quote == '"':
378
+ k += 2
379
+ continue
380
+ if ch == quote:
381
+ quote = None
382
+ elif ch == "\\":
383
+ k += 2
384
+ continue
385
+ elif ch in "'\"":
386
+ quote = ch
387
+ elif ch == "(":
388
+ depth += 1
389
+ elif ch == ")":
390
+ depth -= 1
391
+ if depth == 0:
392
+ return k
393
+ k += 1
394
+ return n
395
+
396
+
397
+ def _prepare(command: str, top: Path | None) -> _Prepared:
398
+ """Rewrite the command so the tokenizer sees what the shell would: literal
399
+ dollars marked, ANSI-C strings decoded, substitutions and heredoc bodies
400
+ replaced by placeholders that refer to their text."""
401
+ prep = _Prepared("")
402
+ out: list[str] = []
403
+ pending: list[tuple[str, bool, int]] = []
404
+ quote: str | None = None
405
+ i, n = 0, len(command)
406
+
407
+ def substitution(inner: str) -> str:
408
+ s = inner.strip()
409
+ if s in ("pwd", "pwd -P", "pwd -L"):
410
+ return "$PWD"
411
+ if s == "git rev-parse --show-toplevel" and top is not None:
412
+ return str(top)
413
+ if _SAFE_SUBST.fullmatch(s):
414
+ return "0"
415
+ prep.subs.append(inner)
416
+ return f"{MARK}S{len(prep.subs) - 1}{MARK}"
417
+
418
+ while i < n:
419
+ c = command[i]
420
+ if quote == "'":
421
+ if c == "'":
422
+ quote = None
423
+ out.append({"$": LIT_DOLLAR, "`": LIT_TICK}.get(c, c))
424
+ i += 1
425
+ continue
426
+ if c == "\\" and i + 1 < n:
427
+ nxt = command[i + 1]
428
+ if nxt == "\n":
429
+ i += 2
430
+ continue
431
+ if nxt == "$":
432
+ out.append(LIT_DOLLAR)
433
+ elif nxt == "`":
434
+ out.append(LIT_TICK)
435
+ else:
436
+ out.append(command[i:i + 2])
437
+ i += 2
438
+ continue
439
+ if c == "'" and quote is None:
440
+ quote = "'"
441
+ out.append(c)
442
+ i += 1
443
+ continue
444
+ if c == '"':
445
+ quote = None if quote == '"' else '"'
446
+ out.append(c)
447
+ i += 1
448
+ continue
449
+ if c == "$" and i + 1 < n:
450
+ nxt = command[i + 1]
451
+ if nxt == "'" and quote is None:
452
+ i, value = _ansi_c(command, i + 2)
453
+ out.append(_squote(value))
454
+ continue
455
+ if nxt == '"' and quote is None: # $"..." is a translated string
456
+ i += 1
457
+ continue
458
+ if nxt == "(":
459
+ j = _matching_paren(command, i + 1)
460
+ if command.startswith("$((", i):
461
+ out.append("0") # arithmetic expansion
462
+ else:
463
+ out.append(substitution(command[i + 2:j]))
464
+ i = j + 1
465
+ continue
466
+ if c == "`":
467
+ j = i + 1
468
+ while j < n and command[j] != "`":
469
+ j += 2 if command[j] == "\\" else 1
470
+ out.append(substitution(command[i + 1:j].replace("\\`", "`")))
471
+ i = j + 1
472
+ continue
473
+ if quote is None and c in "<>" and i + 1 < n and command[i + 1] == "(":
474
+ j = _matching_paren(command, i + 1)
475
+ prep.procs.append(command[i + 2:j])
476
+ out.append(f" {MARK}P{len(prep.procs) - 1}{MARK} ")
477
+ i = j + 1
478
+ continue
479
+ if quote is None and command.startswith("<<<", i):
480
+ out.append("<<<")
481
+ i += 3
482
+ continue
483
+ if quote is None and command.startswith("<<", i):
484
+ m = _HEREDOC.match(command, i)
485
+ if m:
486
+ delim = next(g for g in (m.group(2), m.group(3), m.group(4)) if g is not None)
487
+ prep.docs.append("")
488
+ pending.append((delim, m.group(1) == "-", len(prep.docs) - 1))
489
+ out.append(f" < {MARK}D{len(prep.docs) - 1}{MARK} ")
490
+ i = m.end()
491
+ continue
492
+ if c == "\n" and quote is None and pending:
493
+ out.append("\n")
494
+ i += 1
495
+ for delim, strip_tabs, idx in pending:
496
+ body: list[str] = []
497
+ while i < n:
498
+ e = command.find("\n", i)
499
+ e = n if e < 0 else e
500
+ line = command[i:e]
501
+ i = e + 1
502
+ if (line.lstrip("\t") if strip_tabs else line) == delim:
503
+ break
504
+ body.append(line)
505
+ prep.docs[idx] = "\n".join(body)
506
+ pending = []
507
+ continue
508
+ if c == "#" and quote is None and (i == 0 or command[i - 1] in " \t\n;&|("):
509
+ e = command.find("\n", i)
510
+ i = n if e < 0 else e
511
+ continue
512
+ out.append(c)
513
+ i += 1
514
+ prep.text = "".join(out)
515
+ return prep
516
+
517
+
518
+ # --------------------------------------------------------------------------
519
+ # Inline scripts
520
+ # --------------------------------------------------------------------------
521
+
522
+ _INTERPRETER = re.compile(
523
+ r"^(?:(python|pypy)[\d.]*|(node|nodejs|bun|deno)|(perl)[\d.]*|(ruby)[\d.]*|(php)[\d.]*|(Rscript|lua[\d.]*|"
524
+ r"osascript|tclsh)|(awk|gawk|mawk|nawk))$"
525
+ )
526
+ _LANGS = ("python", "node", "perl", "ruby", "php", "other", "awk")
527
+
528
+ # Calls whose first argument names a file to open, copy or connect to.
529
+ _PATH_CALL = re.compile(
530
+ r"(?<![\w$])(?:open|io\.open|codecs\.open|os\.open|Path|PurePath|PosixPath|pathlib\.Path|readFileSync|"
531
+ r"readFile|createReadStream|openSync|fopen|file_get_contents|readfile|read_csv|read_excel|read_json|"
532
+ r"read_table|read_parquet|read_fwf|read_feather|read_pickle|read_sas|read_stata|loadtxt|genfromtxt|np\.load|numpy\.load|torch\.load|"
533
+ r"connect|copy|copyfile|copy2|copytree|move|File\.read|File\.open|File\.readlines|File\.foreach|IO\.read|"
534
+ r"IO\.readlines|CSV\.read|CSV\.foreach|CSV\.open|Pathname\.new|Bun\.file|Deno\.readTextFile|"
535
+ r"Deno\.readFile)\s*\("
536
+ )
537
+ # Constructs that reach files whose names are not written in the script.
538
+ _COMPUTED = re.compile(
539
+ r"(?<![\w$])(?:os\.walk|os\.listdir|os\.scandir|scandir|listdir|glob|iglob|iterdir|rglob|readdirSync|readdir|"
540
+ r"opendir|Dir\.glob|Dir\.entries|Dir\[|Find\.find|File::Find|os\.path\.join|path\.join|path\.resolve|"
541
+ r"Path\.cwd|Path\.home|subprocess|os\.system|os\.popen|os\.exec\w*|popen|check_output|child_process|"
542
+ r"execSync|spawnSync|exec|eval|system|shell_exec|passthru|proc_open|IO\.popen|Open3|__import__|importlib|"
543
+ r"getline|qx|readlink)\b|<\s*\$|<\*|%x[\(\[{]|`"
544
+ )
545
+ _NETWORK_API = re.compile(
546
+ r"(?<![\w$])(?:urllib|requests|httpx|http\.client|aiohttp|socket|smtplib|ftplib|paramiko|fetch|XMLHttpRequest|"
547
+ r"axios|https?\.request|https?\.get|net\.connect|net\.Socket|Net::HTTP|LWP|IO::Socket|HTTP::Tiny|curl_exec|"
548
+ r"fsockopen|Deno\.connect|WebSocket)\b"
549
+ )
550
+ _STRING = re.compile(r"""(?<![\w$])[rRbBuU]{0,2}(?:'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*")""")
551
+
552
+
553
+ def _first_argument(script: str, start: int) -> str:
554
+ depth = 0
555
+ out: list[str] = []
556
+ for ch in script[start:start + 2000]:
557
+ if ch in "([{":
558
+ depth += 1
559
+ elif ch in ")]}":
560
+ if depth == 0:
561
+ break
562
+ depth -= 1
563
+ elif ch == "," and depth == 0:
564
+ break
565
+ out.append(ch)
566
+ return "".join(out)
567
+
568
+
569
+ def script_access(script: str, lang: str) -> tuple[bool, bool]:
570
+ """(reaches a computed path, uses the network) for an inline script.
571
+
572
+ A script reaches only literal paths when every file-opening call takes a
573
+ single string literal (those paths are scanned like any other) and it
574
+ uses nothing that lists directories, joins paths, or runs other programs.
575
+ """
576
+ text = script[:MAX_EMBEDDED_SCAN]
577
+ network = bool(_NETWORK_API.search(text))
578
+ if lang == "awk":
579
+ computed = bool(re.search(r"getline\s*(?:\w+\s*)?<\s*(?![\"'][^\"']*[\"']\s*[);}\n]|[\"'][^\"']*[\"']\s*$)", text)
580
+ or re.search(r"\bsystem\s*\(|\|\s*getline", text))
581
+ return computed, network
582
+ if _COMPUTED.search(text):
583
+ return True, network
584
+ flat = _STRING.sub("S", text)
585
+ for m in _PATH_CALL.finditer(flat):
586
+ arg = _first_argument(flat, m.end()).strip()
587
+ if lang == "perl" and re.match(r"^(?:my\s+)?[$\w]+$", arg):
588
+ # open(FH, MODE, PATH): every argument after the handle must be literal
589
+ rest = flat[m.end():m.end() + 2000].split(")", 1)[0].split(",")[1:]
590
+ if all(a.strip() in ("S", "") for a in rest):
591
+ continue
592
+ return True, network
593
+ if arg in ("S", "") or arg.isdigit():
594
+ continue
595
+ return True, network
596
+ return False, network
597
+
598
+
599
+ def _inline_scripts(lang: str, args: list[str]) -> tuple[list[str], bool]:
600
+ """(scripts given on the command line, whether stdin is the script)."""
601
+ scripts: list[str] = []
602
+ i = 0
603
+ positional: list[str] = []
604
+ while i < len(args):
605
+ a = args[i]
606
+ nxt = args[i + 1] if i + 1 < len(args) else None
607
+ if lang == "python" and re.fullmatch(r"-[A-Za-z]*c", a) and nxt is not None:
608
+ scripts.append(nxt)
609
+ i += 2
610
+ continue
611
+ if lang == "python" and re.fullmatch(r"-[A-Za-z]*m", a):
612
+ return scripts, False # runs a module: not analysed
613
+ if lang == "node" and (a in ("-e", "-p", "--eval", "--print", "-pe") or re.fullmatch(r"-[A-Za-z]*[ep]", a)) \
614
+ and nxt is not None:
615
+ scripts.append(nxt)
616
+ i += 2
617
+ continue
618
+ if lang == "node" and a == "eval" and nxt is not None:
619
+ scripts.append(nxt)
620
+ i += 2
621
+ continue
622
+ if lang in ("perl", "ruby") and re.fullmatch(r"-[A-Za-z]*[eE]", a) and nxt is not None:
623
+ scripts.append(nxt)
624
+ i += 2
625
+ continue
626
+ if lang == "php" and a == "-r" and nxt is not None:
627
+ scripts.append(nxt)
628
+ i += 2
629
+ continue
630
+ if lang == "other" and a == "-e" and nxt is not None:
631
+ scripts.append(nxt)
632
+ i += 2
633
+ continue
634
+ if lang == "awk":
635
+ if a == "-f":
636
+ return scripts, False
637
+ if a in ("-F", "-v") and nxt is not None:
638
+ i += 2
639
+ continue
640
+ if not a.startswith("-") or a == "-":
641
+ positional.append(a)
642
+ i += 1
643
+ if lang == "awk" and positional:
644
+ return [positional[0]], False
645
+ stdin_script = not scripts and (not positional or positional[0] == "-")
646
+ return scripts, stdin_script
647
+
648
+
649
+ # --------------------------------------------------------------------------
650
+ # git
651
+ # --------------------------------------------------------------------------
652
+
653
+ GIT_GLOBAL_VALUE = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env",
654
+ "--super-prefix", "--attr-source", "--list-cmds"}
655
+ COMMIT_LIKE = {"commit", "commit-tree", "update-ref", "merge", "cherry-pick", "revert", "am", "rebase"}
656
+ GIT_BUILTINS = GIT_NON_READING | COMMIT_LIKE | {
657
+ "am", "annotate", "apply", "bisect", "blame", "bundle", "check-attr", "check-ignore", "cherry", "clone",
658
+ "difftool", "fast-import", "fsck", "hash-object", "ls-remote", "mergetool", "notes", "read-tree", "repack",
659
+ "replace", "send-email", "show-branch", "sparse-checkout", "submodule", "update-index", "var",
660
+ "verify-commit", "maintenance", "request-pull", "filter-branch", "unpack-file", "checkout-index",
661
+ }
662
+ _PATCH_FLAG = re.compile(
663
+ r"^(?:-p|-u|--patch|--patch-with-stat|--patch-with-raw|-U\d*|--unified(?:=\d+)?|--cc|-c|--word-diff.*|"
664
+ r"--color-words.*|-W|--function-context|--binary|--textconv|--ext-diff|-L.*|--full-diff|-m)$"
665
+ )
666
+ _NO_PATCH_FLAG = re.compile(
667
+ r"^(?:-s|--no-patch|--stat.*|--shortstat|--numstat|--name-only|--name-status|--raw|--summary|--dirstat.*|"
668
+ r"-X.*|--compact-summary|--quiet)$"
669
+ )
670
+ HISTORY_SUBS = {"bundle", "show", "log", "whatchanged", "reflog", "diff", "diff-tree", "diff-index", "diff-files", "stash",
671
+ "grep", "archive", "cat-file", "blame", "annotate", "fast-export", "format-patch", "range-diff"}
672
+
673
+ _alias_cache: dict[str, dict[str, str]] = {}
674
+
675
+
676
+ def _parse_git(args: list[str]) -> tuple[list[tuple[str, str]], list[str], str, list[str]]:
677
+ """(-c config pairs, -C directories, subcommand, subcommand arguments)."""
678
+ configs: list[tuple[str, str]] = []
679
+ dirs: list[str] = []
680
+ j = 0
681
+ while j < len(args) and args[j].startswith("-"):
682
+ a = args[j]
683
+ if a in ("-c", "--config-env") and j + 1 < len(args):
684
+ key, _, value = args[j + 1].partition("=")
685
+ configs.append((key.lower(), value))
686
+ j += 2
687
+ continue
688
+ if a == "-C" and j + 1 < len(args):
689
+ dirs.append(args[j + 1])
690
+ j += 2
691
+ continue
692
+ if a in GIT_GLOBAL_VALUE:
693
+ j += 2
694
+ continue
695
+ if a.startswith("--config-env="):
696
+ key, _, value = a[len("--config-env="):].partition("=")
697
+ configs.append((key.lower(), value))
698
+ j += 1
699
+ if j < len(args):
700
+ return configs, dirs, args[j], args[j + 1:]
701
+ return configs, dirs, "", []
702
+
703
+
704
+ def _configured_aliases(cwd: Path) -> dict[str, str]:
705
+ key = str(cwd)
706
+ if key not in _alias_cache:
707
+ aliases: dict[str, str] = {}
708
+ try:
709
+ res = subprocess.run(["git", "config", "--get-regexp", r"^alias\."], cwd=cwd, capture_output=True,
710
+ text=True, timeout=GIT_TIMEOUT)
711
+ for line in res.stdout.splitlines():
712
+ name, _, value = line.partition(" ")
713
+ aliases[name[len("alias."):].lower()] = value
714
+ except (OSError, subprocess.SubprocessError):
715
+ pass
716
+ _alias_cache[key] = aliases
717
+ return _alias_cache[key]
718
+
719
+
720
+ def _git_top(cwd: Path) -> Path | None:
721
+ try:
722
+ res = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd, capture_output=True, text=True,
723
+ timeout=GIT_TIMEOUT)
724
+ except (OSError, subprocess.SubprocessError):
725
+ return None
726
+ return Path(res.stdout.strip()) if res.returncode == 0 and res.stdout.strip() else None
727
+
728
+
729
+ def _shows_content(sub: str, args: list[str]) -> bool:
730
+ opts = [a for a in itertools.takewhile(lambda x: x != "--", args) if a.startswith("-")]
731
+ patch = any(_PATCH_FLAG.match(a) for a in opts)
732
+ quiet = any(_NO_PATCH_FLAG.match(a) for a in opts)
733
+ if sub in ("log", "whatchanged", "reflog", "diff-tree", "diff-index", "diff-files", "fast-export"):
734
+ return patch or sub == "fast-export"
735
+ if sub in ("show", "diff"):
736
+ return patch or not quiet
737
+ if sub == "stash":
738
+ action = args[0] if args else ""
739
+ return action in ("show", "list") and patch
740
+ if sub == "cat-file":
741
+ return not any(a in ("-t", "-s", "-e") or a.startswith("--batch-check") for a in opts)
742
+ return True
743
+
744
+
745
+ # --------------------------------------------------------------------------
746
+ # Analysis
747
+ # --------------------------------------------------------------------------
748
+
749
+
750
+ def _unresolved(word: str) -> bool:
751
+ return f"{MARK}S" in word or bool(_UNEXPANDED.search(word))
752
+
753
+
754
+ def _is_plugin_entry(word: str, base: Path, path_overridden: bool) -> bool:
755
+ raw = _lit(word)
756
+ try:
757
+ if "/" in raw:
758
+ return _abs(_expand_vars(raw, base), base).resolve() == PLUGIN_ENTRY.resolve()
759
+ if path_overridden:
760
+ return False
761
+ found = shutil.which(raw)
762
+ if found is None:
763
+ # Claude Code puts the plugin's bin/ on the Bash tool's PATH, which
764
+ # the hook's own PATH may lack.
765
+ return PLUGIN_ENTRY.is_file()
766
+ return Path(found).resolve() == PLUGIN_ENTRY.resolve()
767
+ except (OSError, ValueError):
768
+ return False
769
+
770
+
771
+ def _merge(result: Analysis, sub: Analysis) -> None:
772
+ result.read_files += sub.read_files
773
+ result.read_dirs += sub.read_dirs
774
+ result.commits += sub.commits
775
+ result.writes += sub.writes
776
+ result.created += sub.created
777
+ result.egress += sub.egress
778
+ result.unresolved += sub.unresolved
779
+ result.git_reads += sub.git_reads
780
+ result.moves += sub.moves
781
+ result.stages_changes |= sub.stages_changes
782
+ result.disables_hooks |= sub.disables_hooks
783
+ result.shows_staged |= sub.shows_staged
784
+
785
+
786
+ def _operands(args: list[str], value_opts: set[str]) -> tuple[list[str], str | None]:
787
+ """(non-option words, -t/--target-directory value) for cp-like commands."""
788
+ out: list[str] = []
789
+ target: str | None = None
790
+ i = 0
791
+ end = False
792
+ while i < len(args):
793
+ a = args[i]
794
+ if end or not a.startswith("-") or a == "-":
795
+ out.append(a)
796
+ elif a == "--":
797
+ end = True
798
+ elif a in ("-t", "--target-directory") and i + 1 < len(args):
799
+ target = args[i + 1]
800
+ i += 1
801
+ elif a.startswith("--target-directory="):
802
+ target = a.split("=", 1)[1]
803
+ elif a in value_opts:
804
+ i += 1
805
+ i += 1
806
+ return out, target
807
+
808
+
809
+ _GREP_VALUE_OPTS = {"-e", "-f", "-A", "-B", "-C", "-m", "-d", "-D", "--regexp", "--file", "--include", "--exclude",
810
+ "--exclude-dir", "--context", "--max-count", "--label", "-g", "--glob", "-t", "--type", "-T",
811
+ "--type-not", "-j", "--threads", "-M", "--max-columns", "-E", "--encoding", "--iglob", "--pre",
812
+ "--sort", "--sortr", "--max-depth", "--max-filesize", "--type-add", "--ignore-dir", "-r",
813
+ "--replace", "--colors", "--path-separator"}
814
+
815
+
816
+ def _search_operands(name: str, args: list[str]) -> list[str]:
817
+ """Path operands of grep, rg, ag or ack: positional words after the pattern."""
818
+ positional: list[str] = []
819
+ has_pattern_opt = False
820
+ skip = False
821
+ end = False
822
+ for a in args:
823
+ if skip:
824
+ skip = False
825
+ continue
826
+ if end or not a.startswith("-") or a == "-":
827
+ positional.append(a)
828
+ continue
829
+ if a == "--":
830
+ end = True
831
+ continue
832
+ if a in ("-e", "-f", "--regexp", "--file") or a.startswith(("--regexp=", "--file=")) \
833
+ or re.fullmatch(r"-[A-Za-z]*[ef]\S*", a) and not a.startswith("--"):
834
+ has_pattern_opt = True
835
+ value_opts = _GREP_VALUE_OPTS - ({"-r"} if name in GREP_FAMILY else set())
836
+ if a in value_opts and "=" not in a:
837
+ skip = True
838
+ return positional if has_pattern_opt else positional[1:]
839
+
840
+
841
+ def analyze(command: str, cwd: Path, depth: int = 0, feeds: bool = False, literal_only: bool = False) -> Analysis:
842
+ """Analyse a command run in `cwd`.
843
+
844
+ `feeds` marks a command whose output becomes another command's arguments
845
+ (inside $(...) or piped to xargs), so a listing counts as a read.
846
+ `literal_only` analyses text that is data rather than a command (a heredoc
847
+ fed to cat, for example): literal paths in it count, unknown ones do not.
848
+ """
849
+ result = Analysis()
850
+ if "core.hookspath" in command.lower():
851
+ result.disables_hooks = True
852
+ feeds = feeds or "xargs" in command
853
+ top = _git_top(cwd) if "rev-parse --show-toplevel" in command else None
854
+ prep = _prepare(command, top)
855
+ bases = [cwd]
856
+ oldpwd = cwd
857
+ stack: list[Path] = []
858
+ base_unknown = False
859
+ variables: dict[str, list[str]] = {}
860
+ path_overridden = False
861
+ entry_shadowed = bool(re.search(r"(?:^|[\s;&|(])(?:function\s+)?phi-gate\s*\(\s*\)|alias\s+phi-gate=", command))
862
+ reading_text: list[str] = []
863
+ done_placeholders: set[str] = set()
864
+ prev_seg: list[str] = []
865
+ prev_name = ""
866
+
867
+ def recurse(text: str, base: Path, *, as_feed: bool = False, literal: bool = False) -> None:
868
+ if depth >= MAX_DEPTH:
869
+ if not literal and not literal_only:
870
+ result.unresolved.append("nesting too deep to follow")
871
+ return
872
+ _merge(result, analyze(text, base, depth + 1, feeds=as_feed or feeds, literal_only=literal or literal_only))
873
+
874
+ def expand(word: str) -> list[str]:
875
+ """The words `word` can become. An unknown variable stays in place,
876
+ so the word reads as unresolved, and also expands to nothing, as
877
+ an unset variable does."""
878
+ word = _expand_vars(word, bases[-1], oldpwd)
879
+ choices: list[list[str]] = []
880
+ pos = 0
881
+ for m in _VAR_REF.finditer(word):
882
+ choices.append([word[pos:m.start()]])
883
+ var = m.group(1) or m.group(4)
884
+ values = variables.get(var)
885
+ if values is None and var in os.environ and var not in ("PWD", "OLDPWD"):
886
+ values = [os.environ[var]]
887
+ op, default = m.group(2), m.group(3) or ""
888
+ if values is None and op in (":-", "-", ":=", "="):
889
+ values = [default]
890
+ elif values is not None and op in (":+", "+"):
891
+ values = [default]
892
+ choices.append(values if values is not None else [m.group(0), ""])
893
+ pos = m.end()
894
+ if not choices:
895
+ return [word]
896
+ choices.append([word[pos:]])
897
+ return ["".join(parts) for parts in itertools.islice(itertools.product(*choices), MAX_VALUES)]
898
+
899
+ def place_holders(words: list[str], base: Path) -> None:
900
+ for w in words:
901
+ for m in _PLACEHOLDER.finditer(w):
902
+ key = m.group(0)
903
+ if key in done_placeholders:
904
+ continue
905
+ done_placeholders.add(key)
906
+ kind, k = m.group(1), int(m.group(2))
907
+ if kind == "S" and k >= len(prep.subs) or kind == "P" and k >= len(prep.procs):
908
+ continue # a placeholder from an enclosing command, recorded there
909
+ if kind == "S":
910
+ recurse(prep.subs[k], base, as_feed=True)
911
+ elif kind == "P":
912
+ recurse(prep.procs[k], base)
913
+
914
+ for sep, seg in _segments(tokenize(prep.text)):
915
+ piped = sep in ("|", "|&")
916
+ base = bases[-1]
917
+ place_holders(seg, base)
918
+
919
+ if seg[0] in ("for", "select") and len(seg) >= 3 and seg[2] == "in":
920
+ values: list[str] = []
921
+ for w in seg[3:]:
922
+ values += expand(w)
923
+ variables[seg[1]] = [_lit(v) for v in values][:MAX_VALUES]
924
+ prev_seg, prev_name = seg, seg[0]
925
+ continue
926
+
927
+ name, idx = _command_word(seg)
928
+ via_xargs = any(os.path.basename(_lit(w)) == "xargs" for w in seg[:idx])
929
+ args = seg[idx + 1:]
930
+ for w in seg[:idx]: # VAR=value prefixes and bare assignments
931
+ m = _ASSIGN.match(w)
932
+ if m:
933
+ variables[m.group(1)] = [_lit(v) for v in expand(m.group(3))][:MAX_VALUES]
934
+ path_overridden |= m.group(1) == "PATH"
935
+ if name in ("export", "declare", "readonly", "local", "typeset"):
936
+ for w in args:
937
+ m = _ASSIGN.match(w)
938
+ if m:
939
+ variables[m.group(1)] = [_lit(v) for v in expand(m.group(3))][:MAX_VALUES]
940
+ path_overridden |= m.group(1) == "PATH"
941
+
942
+ # split redirections from words
943
+ words: list[str] = []
944
+ inputs: list[str] = []
945
+ outputs: list[str] = []
946
+ stdin_texts: list[str] = []
947
+ i = idx + 1
948
+ while i < len(seg):
949
+ tok = seg[i]
950
+ if tok == "<<<":
951
+ if i + 1 < len(seg):
952
+ stdin_texts.append(_lit(seg[i + 1]))
953
+ i += 2
954
+ continue
955
+ if re.fullmatch(r"\d*[<>]+&?|&>>?|>\|", tok):
956
+ if i + 1 < len(seg):
957
+ target = seg[i + 1]
958
+ doc = _PLACEHOLDER.fullmatch(target)
959
+ if doc and doc.group(1) == "D":
960
+ stdin_texts.append(prep.docs[int(doc.group(2))])
961
+ elif "<" in tok:
962
+ inputs += expand(target)
963
+ elif not re.fullmatch(r"\d+|-", target) or "&" not in tok:
964
+ outputs += expand(target)
965
+ i += 2
966
+ continue
967
+ if tok.isdigit() and i + 1 < len(seg) and re.fullmatch(r"[<>]+&?", seg[i + 1]):
968
+ i += 1 # "2" in "2>/dev/null"
969
+ continue
970
+ words += expand(tok)
971
+ i += 1
972
+
973
+ # nested shells: bash -c "...", eval "...", bash <<EOF
974
+ if name in SHELLS or name == "eval":
975
+ inner = [a for a in args if " " in a or "\n" in a] or args[-1:]
976
+ for script in inner:
977
+ if f"{MARK}S" in script and not literal_only:
978
+ result.unresolved.append(f"{name} runs a script built when the command runs")
979
+ recurse(_PLACEHOLDER.sub(" ", _lit(script)), base)
980
+ for text in stdin_texts:
981
+ recurse(text, base)
982
+ if piped and prev_name in ("echo", "printf"):
983
+ recurse(" ".join(_lit(w) for w in prev_seg[1:]), base)
984
+ else:
985
+ for text in stdin_texts: # data for the command; literal paths in it still count
986
+ recurse(text, base, literal=True)
987
+
988
+ if name in ("cd", "pushd", "popd"):
989
+ if name == "popd":
990
+ if stack:
991
+ oldpwd = bases[-1]
992
+ bases.append(stack.pop())
993
+ continue
994
+ targets = [w for w in words if not w.startswith("-") or w == "-"]
995
+ if not targets:
996
+ target_word = str(Path.home()) if name == "cd" else ""
997
+ else:
998
+ target_word = targets[0]
999
+ if not target_word or target_word.startswith("+"):
1000
+ base_unknown = True
1001
+ continue
1002
+ if target_word == "-":
1003
+ new = oldpwd
1004
+ elif _unresolved(target_word):
1005
+ base_unknown = True
1006
+ continue
1007
+ else:
1008
+ new = _abs(_lit(target_word), base)
1009
+ if name == "pushd":
1010
+ stack.append(base)
1011
+ oldpwd = base
1012
+ bases.append(new)
1013
+ continue
1014
+
1015
+ reading = bool(name) and (name not in NON_READING or feeds)
1016
+ if name in KEYWORDS or name in ("done", "fi", "esac", "}"):
1017
+ reading = bool(inputs)
1018
+ egress = name in NETWORK
1019
+ if name == ENTRY_NAME:
1020
+ # The plugin's own read-only commands print locations or redacted
1021
+ # text, so the files they name are not exposed. --map writes the
1022
+ # original values out, so it counts as a read.
1023
+ action = _lit(args[0]) if args else ""
1024
+ trusted = not entry_shadowed and _is_plugin_entry(seg[idx], base, path_overridden)
1025
+ reading = not trusted or action not in PHI_GATE_SAFE or any(_lit(a).startswith("--map") for a in args)
1026
+ extra_bases: list[Path] = []
1027
+ read_words: list[str] | None = None # when set, only these words are reads
1028
+ recursive_flag = any(
1029
+ a in ("--recursive", "--archive") or re.fullmatch(r"-[A-Za-z]*[rR][A-Za-z]*", a) for a in words
1030
+ )
1031
+ recursive = name in RECURSIVE_READERS or recursive_flag
1032
+
1033
+ if name == "git":
1034
+ reading, shell_alias = _analyze_git(words, base, result, literal_only)
1035
+ if shell_alias:
1036
+ recurse(shell_alias, base)
1037
+ top_dir = _git_top(base)
1038
+ if top_dir is not None:
1039
+ extra_bases.append(top_dir)
1040
+ reading = reading or feeds
1041
+ if name == "find":
1042
+ reading = feeds or any(a in FIND_EXEC for a in words)
1043
+ starts = list(itertools.takewhile(lambda w: not w.startswith(("-", "(", "!")), words))
1044
+ if reading and not starts:
1045
+ result.read_dirs.append(base)
1046
+ if "-delete" in words:
1047
+ result.writes += _resolve(starts, bases, must_exist=False)
1048
+
1049
+ if name in COPIERS:
1050
+ operands, target_dir = _operands(words, COPY_VALUE_OPTS.get(name, {"-S", "--suffix"}))
1051
+ if target_dir is not None:
1052
+ sources, dest = operands, target_dir
1053
+ elif len(operands) >= 2:
1054
+ sources, dest = operands[:-1], operands[-1]
1055
+ else:
1056
+ sources, dest = operands, None
1057
+ if dest is not None:
1058
+ result.created += _resolve([dest], bases, must_exist=False)
1059
+ if re.match(r"^[^/]*[^/\\]:", dest) or dest.startswith(("rsync://", "sftp://")):
1060
+ egress = True
1061
+ recursive = recursive or name == "ln" or any(re.fullmatch(r"-[A-Za-z]*a[A-Za-z]*", w) for w in words)
1062
+ if name == "mv":
1063
+ if dest is not None:
1064
+ result.moves.append(Move(_resolve(sources, bases), _abs(_lit(dest), base)))
1065
+ read_words = []
1066
+ reading = False
1067
+ else:
1068
+ read_words = sources
1069
+ reading = True
1070
+ if name in MODIFIERS or (name == "sed" and any(w.startswith("-i") or w == "--in-place" for w in words)):
1071
+ result.created += _resolve([w for w in words if not w.startswith("-")], bases, must_exist=False)
1072
+ if name == "dd":
1073
+ result.created += _resolve([w[3:] for w in words if w.startswith("of=")], bases, must_exist=False)
1074
+ result.created += _resolve(outputs, bases, must_exist=False)
1075
+
1076
+ if any(re.match(r"^/dev/(?:tcp|udp)/", _lit(o)) for o in outputs + inputs):
1077
+ egress = True
1078
+ if any(_DEV_STREAM.match(_lit(w)) for w in words + outputs):
1079
+ reading = True # cp file /dev/stdout prints it
1080
+
1081
+ # interpreters: inline scripts and scripts on stdin
1082
+ lang = _language(name)
1083
+ scripts: list[str] = []
1084
+ if lang is not None:
1085
+ inline, from_stdin = _inline_scripts(lang, [_lit(w) for w in words])
1086
+ scripts += inline
1087
+ if from_stdin:
1088
+ scripts += stdin_texts
1089
+ if piped and prev_name in ("echo", "printf"):
1090
+ scripts.append(" ".join(_lit(w) for w in prev_seg[1:]))
1091
+ for script in scripts:
1092
+ computed, network = script_access(script, lang or "other")
1093
+ if computed and not literal_only:
1094
+ result.unresolved.append(f"{name} script builds a file path at run time")
1095
+ if network:
1096
+ egress = True
1097
+ reading_text.append(script)
1098
+ if egress:
1099
+ result.egress.append(name or "redirection")
1100
+
1101
+ candidates: list[str] = []
1102
+ source_words = words if read_words is None else read_words
1103
+ for w in source_words:
1104
+ if w.startswith("-") and w != "-":
1105
+ if "=" in w:
1106
+ candidates.append(w.split("=", 1)[1])
1107
+ continue
1108
+ candidates.append(w.split("=", 1)[1] if _ASSIGN.match(w) else w)
1109
+ if name in ("curl", "wget", "http", "https", "xh") and "@" in w:
1110
+ candidates.append(w.split("@", 1)[1])
1111
+ if " " in w or "(" in w or "'" in w or '"' in w: # inline script: look for paths inside
1112
+ candidates += [m.group(0) for m in _EMBEDDED_PATH.finditer(_lit(w)[:MAX_EMBEDDED_SCAN])]
1113
+ if name in ("curl", "wget"):
1114
+ candidates += [w.split("@", 1)[1] for w in words if w.startswith("-") and "@" in w]
1115
+
1116
+ search = bases + extra_bases
1117
+ literal_candidates = [_lit(c) for c in candidates if not _unresolved(c)]
1118
+ literal_inputs = [_lit(c) for c in inputs if not _unresolved(c)]
1119
+ resolved_reads = _resolve(literal_candidates + literal_inputs, search)
1120
+ if name in WRITE_WORDS:
1121
+ result.writes += _resolve([_lit(c) for c in candidates], bases, must_exist=False)
1122
+ result.writes += _resolve([_lit(o) for o in outputs], bases, must_exist=False)
1123
+
1124
+ if via_xargs and (reading or egress) and not literal_only:
1125
+ result.unresolved.append(f"xargs {name}: arguments read from standard input")
1126
+ if (reading or egress) and not literal_only and name != "git":
1127
+ unknown = [c for c in candidates + inputs if _unresolved(c)]
1128
+ if unknown:
1129
+ result.unresolved.append(f"{name}: {_lit(unknown[0])[:80]}")
1130
+ elif base_unknown and any(not os.path.isabs(_lit(c)) for c in candidates + inputs if c):
1131
+ result.unresolved.append(f"{name} after a change to an unknown directory")
1132
+ elif inputs and not literal_only and any(_unresolved(c) for c in inputs):
1133
+ result.unresolved.append(f"{name}: input {_lit(inputs[0])[:80]}")
1134
+
1135
+ if reading and (name in GREP_FAMILY and recursive_flag or name in ALWAYS_RECURSIVE):
1136
+ # rg and similar search standard input when it is a pipe or a file; --files lists names only
1137
+ no_tree = name in ALWAYS_RECURSIVE and bool(piped or inputs or stdin_texts or "--files" in words)
1138
+ if not _search_operands(name, [_lit(w) for w in words]) and not no_tree:
1139
+ result.read_dirs.append(base)
1140
+
1141
+ input_paths = {os.path.normpath(str(_abs(_lit(x), base))) for x in inputs}
1142
+ if reading:
1143
+ reading_text.append(" ".join(_lit(w) for w in (seg if read_words is None else read_words)))
1144
+ for p in resolved_reads:
1145
+ try:
1146
+ if p.is_file():
1147
+ if reading or os.path.normpath(str(p)) in input_paths:
1148
+ result.read_files.append(p)
1149
+ elif p.is_dir() and reading and recursive:
1150
+ result.read_dirs.append(p)
1151
+ except OSError:
1152
+ continue
1153
+ prev_seg, prev_name = seg, name
1154
+
1155
+ # placeholders that no segment reached (inside heredoc bodies, for example)
1156
+ leftovers = [f"{MARK}S{k}{MARK}" for k in range(len(prep.subs))] + \
1157
+ [f"{MARK}P{k}{MARK}" for k in range(len(prep.procs))]
1158
+ place_holders(leftovers, bases[-1])
1159
+
1160
+ # Paths anywhere in the text of reading segments: heredoc bodies, inline
1161
+ # scripts, arguments the per-word analysis could not attribute.
1162
+ if reading_text:
1163
+ joined = "\n".join(reading_text)[:MAX_EMBEDDED_SCAN]
1164
+ embedded = [m.group(0) for m in _EMBEDDED_PATH.finditer(joined)]
1165
+ for p in _resolve(embedded, bases):
1166
+ try:
1167
+ if p.is_file():
1168
+ result.read_files.append(p)
1169
+ except OSError:
1170
+ continue
1171
+
1172
+ result.read_files = _dedupe(result.read_files)
1173
+ result.read_dirs = _dedupe(result.read_dirs)
1174
+ result.writes += result.created
1175
+ return result
1176
+
1177
+
1178
+ def _language(name: str) -> str | None:
1179
+ m = _INTERPRETER.match(name)
1180
+ if not m:
1181
+ return None
1182
+ return next(lang for lang, g in zip(_LANGS, m.groups(), strict=True) if g is not None)
1183
+
1184
+
1185
+ def _analyze_git(words: list[str], base: Path, result: Analysis, literal_only: bool) -> tuple[bool, str]:
1186
+ """Record commits, history reads and hook changes for one git invocation.
1187
+ Returns (whether the generic file-read analysis applies, a shell alias
1188
+ command to analyse)."""
1189
+ lits = [_lit(w) for w in words]
1190
+ for w in lits:
1191
+ if "core.hookspath" in w.lower():
1192
+ result.disables_hooks = True
1193
+ configs, dirs, sub, sub_args = _parse_git(lits)
1194
+ git_base = base
1195
+ for d in dirs:
1196
+ git_base = _abs(d, git_base)
1197
+ inline = {k[len("alias."):]: v for k, v in configs if k.startswith("alias.")}
1198
+ shell_alias = ""
1199
+ for _ in range(5):
1200
+ if not sub or sub in GIT_BUILTINS:
1201
+ break
1202
+ value = inline.get(sub.lower())
1203
+ if value is None:
1204
+ value = _configured_aliases(git_base).get(sub.lower())
1205
+ if value is None:
1206
+ break
1207
+ if "core.hookspath" in value.lower():
1208
+ result.disables_hooks = True
1209
+ if value.startswith("!"):
1210
+ shell_alias = value[1:] + " " + " ".join(shlex.quote(a) for a in sub_args)
1211
+ return False, shell_alias
1212
+ try:
1213
+ parts = shlex.split(value)
1214
+ except ValueError:
1215
+ parts = value.split()
1216
+ if not parts:
1217
+ break
1218
+ if parts[0].startswith("-"):
1219
+ more, more_dirs, sub, rest = _parse_git(parts)
1220
+ configs += more
1221
+ for d in more_dirs:
1222
+ git_base = _abs(d, git_base)
1223
+ sub_args = rest + sub_args
1224
+ else:
1225
+ sub, sub_args = parts[0], parts[1:] + sub_args
1226
+
1227
+ if sub in COMMIT_LIKE:
1228
+ if sub == "commit":
1229
+ bypass, include_all = commit_flags(sub_args)
1230
+ else:
1231
+ bypass, include_all = _long_no_verify(sub_args), False
1232
+ result.commits.append(Commit(sub_args, bypass, include_all, sub))
1233
+ if sub == "add":
1234
+ result.stages_changes = True
1235
+ if sub == "diff" and ("--cached" in sub_args or "--staged" in sub_args):
1236
+ result.shows_staged = True
1237
+ if sub == "clean" and any(a.startswith("-") and not a.startswith("--") and ("x" in a or "X" in a)
1238
+ for a in sub_args):
1239
+ result.writes += [git_base / name for name in CLEAN_PROTECTED]
1240
+ if sub in HISTORY_SUBS and not literal_only and _shows_content(sub, sub_args):
1241
+ unresolved = any(_unresolved(w) for w in words)
1242
+ result.git_reads.append(GitRead(sub, list(sub_args), git_base, unresolved))
1243
+ return sub not in GIT_NON_READING, shell_alias
1244
+
1245
+
1246
+ _DEV_STREAM = re.compile(r"^/(?:dev/(?:std(?:out|err)|fd/|tty)|proc/self/fd/)")
1247
+ FIND_EXEC = {"-exec", "-execdir", "-ok", "-okdir"}
1248
+ PHI_GATE_SAFE = {"scan", "redact", "status", "explain", "--version", "--help", "-h"}
1249
+
1250
+
1251
+ def _abs(word: str, base: Path) -> Path:
1252
+ p = Path(word)
1253
+ return p if p.is_absolute() else base / p
1254
+
1255
+
1256
+ def _resolve(words: list[str], bases: list[Path], must_exist: bool = True) -> list[Path]:
1257
+ out: list[Path] = []
1258
+ for raw in words:
1259
+ if not raw or MARK in raw:
1260
+ continue
1261
+ for base in bases:
1262
+ word = _expand_vars(raw, base)
1263
+ for w in _expand_braces(word):
1264
+ if any(ch in w for ch in "*?["):
1265
+ pattern = w if os.path.isabs(w) else str(base / w)
1266
+ try:
1267
+ out += [Path(m) for m in glob.glob(pattern)[:500]]
1268
+ except (OSError, ValueError):
1269
+ pass
1270
+ if not must_exist:
1271
+ out.append(_abs(w, base))
1272
+ else:
1273
+ try:
1274
+ p = _abs(w, base)
1275
+ if not must_exist or p.exists():
1276
+ out.append(p)
1277
+ except (OSError, ValueError):
1278
+ continue
1279
+ return out
1280
+
1281
+
1282
+ def _dedupe(paths: list[Path]) -> list[Path]:
1283
+ seen: set[str] = set()
1284
+ out = []
1285
+ for p in paths:
1286
+ key = os.path.normpath(str(p))
1287
+ if key not in seen:
1288
+ seen.add(key)
1289
+ out.append(Path(key))
1290
+ return out
1291
+
1292
+
1293
+ # --------------------------------------------------------------------------
1294
+ # What a git history read prints
1295
+ # --------------------------------------------------------------------------
1296
+
1297
+
1298
+ @dataclass
1299
+ class Blob:
1300
+ """A file a git command would print: an object in the repository (`sha`)
1301
+ or the working-tree file (`sha` is None). `path` is relative to the top
1302
+ of the working tree."""
1303
+
1304
+ path: str
1305
+ sha: str | None
1306
+
1307
+
1308
+ class Undetermined(Exception):
1309
+ """The set of files a git command would print could not be determined."""
1310
+
1311
+
1312
+ ZERO_SHA = re.compile(r"^0+$")
1313
+ _KEEP_LOG_OPTS = re.compile(
1314
+ r"^(?:-\d+|--max-count=\d+|--skip=\d+|--(?:since|after|until|before|author|committer|grep|glob|"
1315
+ r"branches|tags|remotes)(?:=.*)?|--all|-g|--walk-reflogs|--first-parent|--no-merges|--merges|--follow|"
1316
+ r"-[SG].+|--cached|--staged|--merge-base|-R|--root)$"
1317
+ )
1318
+ _LOG_VALUE_OPTS = {"-n", "--max-count", "--skip", "--since", "--after", "--until", "--before", "--author",
1319
+ "--committer", "--grep", "-S", "-G", "-L", "--format", "--pretty", "--date", "-O", "--stat-width",
1320
+ "--diff-filter", "--relative", "-U", "--unified", "--encoding"}
1321
+
1322
+
1323
+ def _git(args: list[str], cwd: Path, input_text: str | None = None) -> str:
1324
+ try:
1325
+ res = subprocess.run(["git", "-c", "core.quotepath=off", *args], cwd=cwd, capture_output=True, text=True,
1326
+ input=input_text, timeout=GIT_TIMEOUT, errors="surrogateescape")
1327
+ except subprocess.TimeoutExpired as exc:
1328
+ raise Undetermined("git took too long to list the files") from exc
1329
+ except (OSError, subprocess.SubprocessError) as exc:
1330
+ raise Undetermined(f"git could not run ({exc})") from exc
1331
+ if res.returncode != 0:
1332
+ raise Undetermined(f"git could not resolve the arguments ({res.stderr.strip()[:120]})")
1333
+ return res.stdout
1334
+
1335
+
1336
+ def _split_paths(args: list[str]) -> tuple[list[str], list[str]]:
1337
+ if "--" in args:
1338
+ k = args.index("--")
1339
+ return args[:k], args[k + 1:]
1340
+ return args, []
1341
+
1342
+
1343
+ def _log_args(args: list[str]) -> tuple[list[str], list[str], list[str]]:
1344
+ """(options kept, revision words, paths) from log-like arguments."""
1345
+ before, paths = _split_paths(args)
1346
+ opts: list[str] = []
1347
+ words: list[str] = []
1348
+ i = 0
1349
+ while i < len(before):
1350
+ a = before[i]
1351
+ if a.startswith("-") and a != "-":
1352
+ if a == "-n" and i + 1 < len(before):
1353
+ opts += [a, before[i + 1]]
1354
+ i += 2
1355
+ continue
1356
+ if a == "-L" and i + 1 < len(before) or a.startswith("-L") and len(a) > 2:
1357
+ spec = before[i + 1] if a == "-L" else a[2:]
1358
+ paths.append(spec.rsplit(":", 1)[-1])
1359
+ i += 2 if a == "-L" else 1
1360
+ continue
1361
+ if a in _LOG_VALUE_OPTS and i + 1 < len(before):
1362
+ if _KEEP_LOG_OPTS.match(f"{a}={before[i + 1]}"):
1363
+ opts.append(f"{a}={before[i + 1]}")
1364
+ i += 2
1365
+ continue
1366
+ if _KEEP_LOG_OPTS.match(a):
1367
+ opts.append(a)
1368
+ i += 1
1369
+ continue
1370
+ words.append(a)
1371
+ i += 1
1372
+ return opts, words, paths
1373
+
1374
+
1375
+ def _raw_blobs(text: str) -> list[Blob]:
1376
+ out: list[Blob] = []
1377
+ for line in text.splitlines():
1378
+ if not line.startswith(":") or "\t" not in line:
1379
+ continue
1380
+ meta, _, names = line.partition("\t")
1381
+ fields = meta.lstrip(":").split()
1382
+ if len(fields) < 5:
1383
+ continue
1384
+ old_mode, new_mode, old_sha, new_sha = fields[:4]
1385
+ parts = names.split("\t")
1386
+ old_path, new_path = parts[0], parts[-1]
1387
+ if old_mode != "160000" and not ZERO_SHA.match(old_sha):
1388
+ out.append(Blob(old_path, old_sha))
1389
+ if new_mode == "160000":
1390
+ continue
1391
+ if ZERO_SHA.match(new_sha):
1392
+ if new_mode != "000000":
1393
+ out.append(Blob(new_path, None)) # working-tree side of a diff
1394
+ else:
1395
+ out.append(Blob(new_path, new_sha))
1396
+ return out
1397
+
1398
+
1399
+ def _tree_blobs(tree: str, paths: list[str], cwd: Path) -> list[Blob]:
1400
+ out: list[Blob] = []
1401
+ for line in _git(["ls-tree", "-r", "--full-name", tree, "--", *paths], cwd).splitlines():
1402
+ meta, _, path = line.partition("\t")
1403
+ fields = meta.split()
1404
+ if len(fields) >= 3 and fields[1] == "blob":
1405
+ out.append(Blob(path, fields[2]))
1406
+ return out
1407
+
1408
+
1409
+ def _object_type(obj: str, cwd: Path) -> str:
1410
+ return _git(["cat-file", "-t", obj], cwd).strip()
1411
+
1412
+
1413
+ def _log_blobs(opts: list[str], words: list[str], paths: list[str], cwd: Path, extra: list[str] | None = None) -> list[Blob]:
1414
+ args = ["log", "--format=", "--raw", "--no-abbrev", "-r", "-m", *(extra or []), *opts, *words]
1415
+ if paths:
1416
+ args += ["--", *paths]
1417
+ return _raw_blobs(_git(args, cwd))
1418
+
1419
+
1420
+ def _object_blobs(obj: str, paths: list[str], cwd: Path) -> list[Blob]:
1421
+ if ".." in obj or obj.startswith("^"):
1422
+ return _log_blobs([], [obj], paths, cwd)
1423
+ if ":" in obj and not obj.startswith(":/"):
1424
+ sha = _git(["rev-parse", "--verify", "--quiet", obj], cwd).strip()
1425
+ kind = _object_type(sha, cwd)
1426
+ if kind == "tree":
1427
+ return _tree_blobs(sha, [], cwd)
1428
+ return [Blob(obj.split(":", 1)[1].removeprefix("./") or obj, sha)] if kind == "blob" else []
1429
+ kind = _object_type(obj + "^{}", cwd)
1430
+ if kind == "commit":
1431
+ return _log_blobs([], ["-1", obj], paths, cwd)
1432
+ if kind == "tree":
1433
+ return _tree_blobs(obj + "^{}", paths, cwd)
1434
+ if kind == "blob":
1435
+ return [Blob(obj, _git(["rev-parse", obj], cwd).strip())]
1436
+ return []
1437
+
1438
+
1439
+ def _stash_blobs(rev: str, cwd: Path) -> list[Blob]:
1440
+ blobs = _raw_blobs(_git(["diff", "--raw", "--no-abbrev", f"{rev}^1", rev], cwd))
1441
+ try:
1442
+ blobs += _tree_blobs(f"{rev}^3", [], cwd)
1443
+ except Undetermined:
1444
+ pass # the stash has no untracked files
1445
+ return blobs
1446
+
1447
+
1448
+ def _positional(args: list[str], value_opts: set[str]) -> list[str]:
1449
+ out: list[str] = []
1450
+ skip = False
1451
+ for a in args:
1452
+ if skip:
1453
+ skip = False
1454
+ continue
1455
+ if a.startswith("-") and a != "-":
1456
+ if a in value_opts:
1457
+ skip = True
1458
+ continue
1459
+ out.append(a)
1460
+ return out
1461
+
1462
+
1463
+ def history_blobs(read: GitRead) -> list[Blob]:
1464
+ """The files `read` would print. Raises Undetermined when that cannot be
1465
+ worked out without running the command."""
1466
+ if read.unresolved:
1467
+ raise Undetermined("its arguments are computed when the command runs")
1468
+ sub, args, cwd = read.sub, read.args, read.cwd
1469
+ if sub == "bundle":
1470
+ if args[:1] != ["create"]:
1471
+ return []
1472
+ opts, words, paths = _log_args(args[2:])
1473
+ return _log_blobs(opts, words, paths, cwd)
1474
+ if sub in ("log", "whatchanged", "fast-export", "range-diff"):
1475
+ opts, words, paths = _log_args(args)
1476
+ return _log_blobs(opts, words, paths, cwd)
1477
+ if sub == "reflog":
1478
+ rest = args[1:] if args[:1] == ["show"] else args
1479
+ opts, words, paths = _log_args(rest)
1480
+ return _log_blobs(opts, words, paths, cwd, extra=["-g"])
1481
+ if sub == "format-patch":
1482
+ opts, words, paths = _log_args(args)
1483
+ words = [w if ".." in w else f"{w}..HEAD" for w in words]
1484
+ if not words and any(re.fullmatch(r"-\d+", o) or o == "-n" for o in opts):
1485
+ words = ["HEAD"]
1486
+ return _log_blobs(opts, words, paths, cwd)
1487
+ if sub == "show":
1488
+ before, paths = _split_paths(args)
1489
+ objs = _positional(before, {"--format", "--pretty", "-O"}) or ["HEAD"]
1490
+ out: list[Blob] = []
1491
+ for obj in objs:
1492
+ out += _object_blobs(obj, paths, cwd)
1493
+ return out
1494
+ if sub in ("diff", "diff-tree", "diff-index", "diff-files"):
1495
+ opts, words, paths = _log_args(args)
1496
+ if "--no-index" in args:
1497
+ return []
1498
+ keep = [o for o in opts if o in ("--cached", "--staged", "--merge-base", "-R")]
1499
+ cmd = [sub, "--raw", "--no-abbrev", *(["-r"] if sub == "diff-tree" else []), *keep, *words]
1500
+ if paths:
1501
+ cmd += ["--", *paths]
1502
+ return _raw_blobs(_git(cmd, cwd))
1503
+ if sub == "stash":
1504
+ action, rest = (args[0], args[1:]) if args else ("show", [])
1505
+ if action == "list":
1506
+ refs = _git(["stash", "list", "--format=%gd"], cwd).split()
1507
+ out = []
1508
+ for ref in refs[:50]:
1509
+ out += _stash_blobs(ref, cwd)
1510
+ return out
1511
+ revs = _positional(rest, set())
1512
+ rev = revs[0] if revs else "stash@{0}"
1513
+ if rev.isdigit():
1514
+ rev = f"stash@{{{rev}}}"
1515
+ return _stash_blobs(rev, cwd)
1516
+ if sub == "grep":
1517
+ return _grep_blobs(args, cwd)
1518
+ if sub == "archive":
1519
+ before, paths = _split_paths(args)
1520
+ if any(a == "--remote" or a.startswith("--remote=") for a in before):
1521
+ return []
1522
+ pos = _positional(before, {"--format", "-o", "--output", "--prefix", "--exec", "--add-file", "--mtime"})
1523
+ if not pos:
1524
+ raise Undetermined("no tree was named")
1525
+ out = _tree_blobs(pos[0], pos[1:] + paths, cwd)
1526
+ out += [Blob(a.split("=", 1)[1], None) for a in before if a.startswith("--add-file=")]
1527
+ return out
1528
+ if sub == "cat-file":
1529
+ if any(a.startswith("--batch") for a in args):
1530
+ raise Undetermined("it reads object names from standard input")
1531
+ pos = _positional(args, {"--path", "--filters"})
1532
+ if not pos:
1533
+ return []
1534
+ obj = pos[-1]
1535
+ sha = _git(["rev-parse", "--verify", "--quiet", obj], cwd).strip()
1536
+ if _object_type(sha, cwd) != "blob":
1537
+ return []
1538
+ return [Blob(obj.split(":", 1)[1] if ":" in obj else obj, sha)]
1539
+ if sub in ("blame", "annotate"):
1540
+ before, paths = _split_paths(args)
1541
+ pos = _positional(before, {"-L", "-S", "--contents", "--since", "--ignore-rev", "--ignore-revs-file"})
1542
+ if not paths and pos:
1543
+ paths = [pos.pop()]
1544
+ if not pos or not paths:
1545
+ return [] # blame of the working-tree file, which the file analysis scans
1546
+ rev = pos[0]
1547
+ sha = _git(["rev-parse", "--verify", "--quiet", f"{rev}:./{paths[0]}"], cwd).strip()
1548
+ return [Blob(paths[0], sha)]
1549
+ return []
1550
+
1551
+
1552
+ def _grep_blobs(args: list[str], cwd: Path) -> list[Blob]:
1553
+ before, paths = _split_paths(args)
1554
+ value_opts = {"-e", "-f", "-A", "-B", "-C", "-m", "--max-count", "--max-depth", "--threads", "-O",
1555
+ "--open-files-in-pager", "--context", "--before-context", "--after-context"}
1556
+ pos = _positional(before, value_opts)
1557
+ if not any(a in ("-e", "-f") or re.fullmatch(r"-[A-Za-z]*[ef]", a) for a in before):
1558
+ pos = pos[1:]
1559
+ revs: list[str] = []
1560
+ for w in pos:
1561
+ try:
1562
+ _git(["rev-parse", "--verify", "--quiet", f"{w}^{{tree}}"], cwd)
1563
+ revs.append(w)
1564
+ except Undetermined:
1565
+ paths.append(w)
1566
+ if revs:
1567
+ out: list[Blob] = []
1568
+ for rev in revs:
1569
+ out += _tree_blobs(rev, paths, cwd)
1570
+ return out
1571
+ if "--cached" in before:
1572
+ out = []
1573
+ for line in _git(["ls-files", "-s", "--full-name", "--", *paths], cwd).splitlines():
1574
+ meta, _, path = line.partition("\t")
1575
+ fields = meta.split()
1576
+ if len(fields) >= 2:
1577
+ out.append(Blob(path, fields[1]))
1578
+ return out
1579
+ listing = ["ls-files", "--full-name"]
1580
+ if "--untracked" in before or "--no-index" in before:
1581
+ listing += ["--cached", "--others", "--exclude-standard"]
1582
+ return [Blob(p, None) for p in _git([*listing, "--", *paths], cwd).splitlines()]