@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,4030 @@
1
+ """The shared deterministic shell/path boundary behind the Claude worktree guard.
2
+
3
+ ``worktree_guard.py`` (beside this file) loads this module for its parsing and
4
+ checkout classification. It has no entry point of its own, and it reads no
5
+ settings file: what a consumer trusts (``trusted_request``) is handed in by
6
+ the caller, so the module imports and tests without any configuration.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ import stat
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Protocol, Sequence
20
+
21
+
22
+ # Admission to this set means one thing: NO invocation of the command mutates
23
+ # anything -- no option and no operand can name a file it writes, and none
24
+ # changes system state. That is the whole test. `sort` and `uniq` are absent
25
+ # because `sort -o out` and `uniq in out` name an output; `hostname name` and
26
+ # `date -s` set state, and `date` earns a parser below rather than a place
27
+ # here. Membership is not "feels harmless": a command missing from this set is
28
+ # not refused outright, it falls through to operand analysis, and a command
29
+ # with no parser there fails closed with the misleading "mutation targets could
30
+ # not be resolved". That message, not a boundary hole, is what kept `date +%F`
31
+ # unusable from a worktree.
32
+ #
33
+ # A member is read-only whatever its words are, expansions
34
+ # included, so `find` (`-delete`), `rg` (`--pre`), `file` (`-C` compiles a
35
+ # magic file) and `printf` (`-v` assigns a variable, PATH among them) moved to
36
+ # parsers of their own in `segment_is_read_only`.
37
+ READ_COMMANDS = {
38
+ "[",
39
+ "[[",
40
+ "basename",
41
+ "cmp",
42
+ "cut",
43
+ "diff",
44
+ "dirname",
45
+ "grep",
46
+ "head",
47
+ "id",
48
+ "jq",
49
+ "ls",
50
+ "nl",
51
+ "printenv",
52
+ # Process listings: sessions check for a quiet machine before a heavy
53
+ # emulator run (`pgrep -fl qemu-system`). pkill stays unproven.
54
+ "pgrep",
55
+ "ps",
56
+ "pwd",
57
+ "readlink",
58
+ "realpath",
59
+ "rev",
60
+ "seq",
61
+ "shellcheck",
62
+ "sleep",
63
+ "stat",
64
+ "tail",
65
+ "test",
66
+ "tr",
67
+ "true",
68
+ "type",
69
+ "uname",
70
+ "uptime",
71
+ "wc",
72
+ "which",
73
+ "whoami",
74
+ }
75
+ READ_GIT = {
76
+ "blame",
77
+ "diff",
78
+ "diff-tree",
79
+ "for-each-ref",
80
+ "log",
81
+ "ls-files",
82
+ "merge-base",
83
+ "rev-list",
84
+ "rev-parse",
85
+ "show",
86
+ "show-ref",
87
+ "status",
88
+ }
89
+ SHELL_OPERATORS = {";", "&&", "||", "|", "|&", "&", "(", ")"}
90
+ SHELL_COMMAND_WRAPPERS = {
91
+ "builtin",
92
+ "command",
93
+ "env",
94
+ "exec",
95
+ "nice",
96
+ "noglob",
97
+ "time",
98
+ }
99
+ SHELL_INTERPRETERS = {"bash", "dash", "ksh", "sh", "zsh"}
100
+ NON_SHELL_INLINE_INTERPRETERS = {
101
+ "bun": {"e", "p"},
102
+ "lua": {"e"},
103
+ "node": {"e", "p"},
104
+ "nodejs": {"e", "p"},
105
+ "perl": {"e", "E"},
106
+ "php": {"r"},
107
+ "ruby": {"e"},
108
+ }
109
+ GENERIC_SCOPED_COMMANDS = {
110
+ "bun",
111
+ "bundle",
112
+ "cargo",
113
+ "cmake",
114
+ "curl",
115
+ "eslint",
116
+ "gem",
117
+ "gh",
118
+ "go",
119
+ "gradle",
120
+ "java",
121
+ "jest",
122
+ "make",
123
+ "mise",
124
+ "mvn",
125
+ "npm",
126
+ "npx",
127
+ "pnpm",
128
+ "pod",
129
+ "prettier",
130
+ "rsync",
131
+ "tar",
132
+ "tsc",
133
+ "turbo",
134
+ "vitest",
135
+ "yarn",
136
+ }
137
+ # These reviewed build/package runners intentionally execute repository code.
138
+ # This parser cannot see what their child processes write; it confines only the
139
+ # path operands declared on their command line, and only from an owned worktree.
140
+ #
141
+ # The toolchain managers -- `bundle`, `gem`, `mise`, `pod` -- belong HERE and
142
+ # not in READ_COMMANDS, and the difference is not a matter of degree.
143
+ # Membership above asserts that no invocation writes, so it short-circuits the
144
+ # worktree gate entirely; membership here asserts only that the command is a
145
+ # reviewed runner, and leaves the gate standing. `mise install` and
146
+ # `pod install` plainly write, so the first claim would be false for both. What
147
+ # being listed here buys them is narrower and is the whole fix: an executable
148
+ # absent from every set is treated as a possible interpreter by
149
+ # `has_inline_interpreter_program`, so its targets are unprovable and
150
+ # `mise --version` is refused with a message about mutation targets. Naming
151
+ # them makes their command lines parseable, which is what lets them run from an
152
+ # owned worktree with their declared path operands confined.
153
+ #
154
+ # mise pins Node, pnpm and Python; it replaced fnm, which is no longer
155
+ # listed, so an fnm command is refused again as an unknown executable.
156
+ # No mise subcommand earns a read-only parser the way `node --version` does:
157
+ # measured on 2026-11-08, the first command to read a config file (`ls`,
158
+ # `current`, `which`) records it under `~/.local/state/mise/tracked-configs`,
159
+ # `--version` refreshes a latest-version cache, and `ls-remote` a remote
160
+ # version cache. Each writes state outside any worktree, so each stays behind
161
+ # the worktree gate.
162
+ #
163
+ # State the limit of this set out loud, because it is easy to read the
164
+ # inline-interpreter refusals below as stronger than they are. A reviewed
165
+ # runner can invoke an interpreter as its OWN argument -- `pnpm exec node -e`,
166
+ # `npx node -e`, `npm exec node -e`, or a `make` recipe -- and this hook does
167
+ # not see through it. Those predate the toolchain entries above and behave the
168
+ # same with or without them. `mise exec -- node -e` must not be described that
169
+ # way: before `mise` was listed it was REFUSED, as an unknown executable, and
170
+ # listing `mise` is precisely what admits it. What listing it does not do is
171
+ # widen the shape -- it puts `mise` in the same class as the runners already
172
+ # here, where only the BARE `node -e` is refused. The refusals in
173
+ # NON_SHELL_INLINE_INTERPRETERS therefore constrain the direct spelling, not
174
+ # the capability; what bounds the capability is the worktree gate, which still
175
+ # applies to every command here.
176
+ READ_ONLY_COMMANDS = {"cat", "echo", "printf"}
177
+ READ_COMMANDS |= READ_ONLY_COMMANDS - {"printf"}
178
+ SHELL_CONTROL_WORDS = {"if", "then", "elif", "else", "fi", "while", "until", "do", "done"}
179
+ NAVIGATION_COMMANDS = {"cd", "pushd", "popd"}
180
+ ALL_OPERAND_MUTATORS = {
181
+ "mkdir",
182
+ "mkfifo",
183
+ "mv",
184
+ "rm",
185
+ "rmdir",
186
+ "touch",
187
+ "unlink",
188
+ }
189
+
190
+
191
+ def git_environment() -> dict[str, str]:
192
+ """Keep caller-supplied Git plumbing from redirecting repository checks.
193
+
194
+ Dropping the `GIT_*` prefix covers every environment vector, the config
195
+ injectors (`GIT_CONFIG`, `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_COUNT` and
196
+ its key/value family) included. It deliberately stops there: unlike the
197
+ throwaway fixtures in `test_boundary.py`, these commands inspect the
198
+ REAL checkout, where the machine's configuration — `safe.directory`,
199
+ `core.excludesfile` behind `status --untracked-files=all` — is part of the
200
+ answer the guard is asked for. Nothing here creates a commit, so the
201
+ fixture-teardown race that `hermetic_git_env` exists for cannot start here.
202
+ """
203
+
204
+ return {
205
+ key: value for key, value in os.environ.items() if not key.startswith("GIT_")
206
+ }
207
+
208
+
209
+ def run_git(cwd: Path, *args: str) -> str | None:
210
+ result = subprocess.run(
211
+ ["git", *args],
212
+ cwd=cwd,
213
+ capture_output=True,
214
+ text=True,
215
+ check=False,
216
+ env=git_environment(),
217
+ )
218
+ return result.stdout.strip() if result.returncode == 0 else None
219
+
220
+
221
+ # The probes `checkout_state` needs answered, in the order it needs them.
222
+ # `checkout_failure` walks the SAME tuple, which is the point of naming it: an
223
+ # earlier version of that function re-ran only the first probe, so a failure in
224
+ # any later one was reported as the first one's successful output.
225
+ #
226
+ # One rev-parse answers all three paths, one line each, and runs alongside the
227
+ # worktree listing: this guard gates every Bash, Edit and Write call, and each
228
+ # git start costs ~50-70 ms on a loaded host. The answers are
229
+ # git's own, unchanged; only the number and order of processes moved.
230
+ CHECKOUT_PROBES = (
231
+ (
232
+ "rev-parse", "--path-format=absolute",
233
+ "--show-toplevel", "--git-dir", "--git-common-dir",
234
+ ),
235
+ ("worktree", "list", "--porcelain"),
236
+ )
237
+
238
+
239
+ def run_git_all(calls: list[tuple[Path, tuple[str, ...]]]) -> list[str | None]:
240
+ """`run_git` for each (cwd, probe), all started before any is waited on."""
241
+
242
+ started: list[subprocess.Popen[str]] = []
243
+ try:
244
+ for cwd, probe in calls:
245
+ started.append(
246
+ subprocess.Popen(
247
+ ["git", *probe],
248
+ cwd=cwd,
249
+ stdout=subprocess.PIPE,
250
+ stderr=subprocess.DEVNULL,
251
+ text=True,
252
+ env=git_environment(),
253
+ )
254
+ )
255
+ except BaseException:
256
+ # A launch that fails (no git, a vanished cwd) must not leave the
257
+ # probes already started running unreaped; the error still propagates.
258
+ for process in started:
259
+ try:
260
+ process.kill()
261
+ except ProcessLookupError:
262
+ pass # already exited; communicate() still reaps it
263
+ process.communicate()
264
+ raise
265
+ answers: list[str | None] = []
266
+ for process in started:
267
+ stdout, _ = process.communicate()
268
+ answers.append(stdout.strip() if process.returncode == 0 else None)
269
+ return answers
270
+
271
+
272
+ # The checkout, read from git's own files. Asking git costs two
273
+ # process starts on every Bash, Edit and Write call, one of them listing every
274
+ # worktree to read the first; the three facts the guard needs are written down
275
+ # where git keeps them. Every layout this reader does not model raises
276
+ # GitMustAnswer and the caller asks git, so a file answer is git's answer or
277
+ # none at all.
278
+ #
279
+ # Modelled, after git's discovery: from the physical cwd, each directory's
280
+ # `.git` is examined before stepping up, and the walk never steps onto another
281
+ # filesystem (`git_environment` drops GIT_DISCOVERY_ACROSS_FILESYSTEM and
282
+ # GIT_CEILING_DIRECTORIES, so git stops there too, as "not a git repository").
283
+ # A `.git` file names the git dir on its first line, relative to its own
284
+ # directory; the git dir's `commondir` names the common dir. The top level is
285
+ # the directory holding `.git`; the primary checkout, git's first worktree, is
286
+ # the common dir's parent.
287
+ #
288
+ # Asked of git instead: a symlinked or special `.git`; a directory holding a
289
+ # `HEAD` before any `.git` (it may itself be a git dir); a git dir without
290
+ # `HEAD`, or a common dir without `objects` and `refs`; a cwd inside a git dir;
291
+ # any path owned by another user (`safe.directory`); a common dir not named
292
+ # `.git` (bare repositories, submodules); and repository config that sets
293
+ # `core.worktree` or a true `core.bare`, includes other files, continues a
294
+ # line, or declares a format or extension this reader does not know.
295
+ #
296
+ # Accepted, checked: discovery reads `core.worktree` from the repository's own
297
+ # config files only. Set in a global config file it moves nothing (git 2.54,
298
+ # 2026-10-06), so global and system config are not read here.
299
+ class GitMustAnswer(Exception):
300
+ """The files do not settle where this checkout is; git must answer."""
301
+
302
+
303
+ GIT_EXTENSIONS_READ = frozenset(
304
+ {
305
+ "compatobjectformat",
306
+ "noop",
307
+ "noop-v1",
308
+ "objectformat",
309
+ "partialclone",
310
+ "preciousobjects",
311
+ "refstorage",
312
+ "relativeworktrees",
313
+ "worktreeconfig",
314
+ }
315
+ )
316
+ GIT_FILE_LIMIT = 1024 * 1024
317
+ FALSE_WORDS = {"false", "no", "off", "0"}
318
+
319
+
320
+ def read_git_file(path: Path) -> str | None:
321
+ """A small text file git keeps, or None when it is absent."""
322
+
323
+ try:
324
+ status = path.stat()
325
+ # A FIFO or device would block the read: only a regular file is read.
326
+ if not stat.S_ISREG(status.st_mode):
327
+ raise GitMustAnswer(f"{path} is not a regular file")
328
+ if status.st_size > GIT_FILE_LIMIT:
329
+ raise GitMustAnswer(f"{path} is larger than this reader reads")
330
+ return path.read_text(encoding="utf-8")
331
+ except FileNotFoundError:
332
+ return None
333
+ except (OSError, UnicodeDecodeError) as error:
334
+ raise GitMustAnswer(f"{path}: {error}") from error
335
+
336
+
337
+ def check_repository_config(path: Path) -> None:
338
+ """Raise GitMustAnswer if this config can move the top level or the format.
339
+
340
+ Section-aware on purpose: `extensions.worktreeConfig` and branch names
341
+ contain "worktree", and only `core.worktree` moves the top level.
342
+ """
343
+
344
+ text = read_git_file(path)
345
+ if text is None:
346
+ return
347
+ section = ""
348
+ for raw in text.splitlines():
349
+ line = raw.strip()
350
+ if not line or line[0] in "#;":
351
+ continue
352
+ if line.endswith("\\"):
353
+ raise GitMustAnswer(f"{path} continues a line")
354
+ if line.startswith("["):
355
+ header, closed, rest = line[1:].partition("]")
356
+ if not closed:
357
+ raise GitMustAnswer(f"{path} has an unclosed section header")
358
+ if not header.strip():
359
+ raise GitMustAnswer(f"{path} has an empty section header")
360
+ # `[branch "x"]` -> branch, `[includeIf "..."]` -> includeif, and
361
+ # the old `[core.sub]` spelling -> core (read as core: stricter).
362
+ section = header.strip().split(None, 1)[0].split('"', 1)[0]
363
+ section = section.split(".", 1)[0].lower()
364
+ if section.startswith("include"):
365
+ raise GitMustAnswer(f"{path} includes other config")
366
+ # The section is set before the rest of the line is read, so a
367
+ # comment after `]` (`[core] # x`) still leaves it core.
368
+ line = rest.strip()
369
+ if not line or line[0] in "#;":
370
+ continue
371
+ key, _, value = line.partition("=")
372
+ key = key.strip().lower()
373
+ value = value.split("#", 1)[0].split(";", 1)[0].strip().strip('"').lower()
374
+ if section == "core" and key == "worktree":
375
+ raise GitMustAnswer(f"{path} sets core.worktree")
376
+ # A key with no `=` is git's true.
377
+ if section == "core" and key == "bare" and value not in FALSE_WORDS:
378
+ raise GitMustAnswer(f"{path} sets core.bare")
379
+ if section == "core" and key == "repositoryformatversion" and value not in {"0", "1"}:
380
+ raise GitMustAnswer(f"{path} declares format {value}")
381
+ if section == "extensions" and key not in GIT_EXTENSIONS_READ:
382
+ raise GitMustAnswer(f"{path} declares extensions.{key}")
383
+
384
+
385
+ def owned_by_this_user(path: Path) -> None:
386
+ if path.stat().st_uid != os.geteuid():
387
+ raise GitMustAnswer(f"{path} is owned by another user")
388
+
389
+
390
+ def found_checkout(start: Path, top: Path, dot_git: Path, is_file: bool) -> tuple[Path, Path, Path]:
391
+ if is_file:
392
+ text = read_git_file(dot_git) or ""
393
+ first = text.split("\n", 1)[0].rstrip()
394
+ if not first.startswith("gitdir: "):
395
+ raise GitMustAnswer(f"{dot_git} does not name a git dir")
396
+ git_dir = top / first.removeprefix("gitdir: ")
397
+ else:
398
+ git_dir = dot_git
399
+ git_dir = git_dir.resolve(strict=True)
400
+ pointer = read_git_file(git_dir / "commondir")
401
+ if pointer is not None and not pointer.strip():
402
+ raise GitMustAnswer(f"{git_dir}/commondir is empty")
403
+ common = (git_dir / pointer.strip()).resolve(strict=True) if pointer else git_dir
404
+ if not (git_dir / "HEAD").is_file():
405
+ raise GitMustAnswer(f"{git_dir} has no HEAD")
406
+ if not ((common / "objects").is_dir() and (common / "refs").is_dir()):
407
+ raise GitMustAnswer(f"{common} is not a repository")
408
+ if common.name != ".git":
409
+ raise GitMustAnswer(f"{common} is not a checkout's .git")
410
+ for path in (top, dot_git, git_dir, common):
411
+ owned_by_this_user(path)
412
+ if is_inside(start, git_dir) or is_inside(start, common):
413
+ raise GitMustAnswer(f"{start} is inside a git dir")
414
+ check_repository_config(common / "config")
415
+ check_repository_config(common / "config.worktree")
416
+ check_repository_config(git_dir / "config.worktree")
417
+ return top, git_dir, common
418
+
419
+
420
+ def checkout_from_files(cwd: Path) -> tuple[Path, Path, Path] | None:
421
+ """(top level, git dir, common dir) for `cwd`, or None outside any checkout.
422
+
423
+ Raises GitMustAnswer for every layout the comment above hands to git.
424
+ """
425
+
426
+ try:
427
+ start = cwd.resolve(strict=True)
428
+ if not start.is_dir():
429
+ raise GitMustAnswer(f"{start} is not a directory")
430
+ device = start.stat().st_dev
431
+ directory = start
432
+ while True:
433
+ dot_git = directory / ".git"
434
+ try:
435
+ mode = dot_git.lstat().st_mode
436
+ except FileNotFoundError:
437
+ mode = None
438
+ if mode is not None:
439
+ if stat.S_ISREG(mode) or stat.S_ISDIR(mode):
440
+ return found_checkout(start, directory, dot_git, stat.S_ISREG(mode))
441
+ raise GitMustAnswer(f"{dot_git} is neither a file nor a directory")
442
+ if os.path.lexists(directory / "HEAD"):
443
+ raise GitMustAnswer(f"{directory} may itself be a git dir")
444
+ parent = directory.parent
445
+ if parent == directory or parent.stat().st_dev != device:
446
+ return None
447
+ directory = parent
448
+ except OSError as error:
449
+ raise GitMustAnswer(str(error)) from error
450
+
451
+
452
+ def checkout_state(cwd: Path) -> tuple[Path, bool, Path] | None:
453
+ try:
454
+ found = checkout_from_files(cwd)
455
+ except GitMustAnswer:
456
+ return checkout_state_from_git(cwd)
457
+ if found is None:
458
+ return None
459
+ top, git_dir, common = found
460
+ return top, git_dir != common, common.parent
461
+
462
+
463
+ def checkout_state_from_git(cwd: Path) -> tuple[Path, bool, Path] | None:
464
+ paths, listing = run_git_all([(cwd, probe) for probe in CHECKOUT_PROBES])
465
+ if not paths or not listing:
466
+ return None
467
+ lines = paths.splitlines()
468
+ # Exactly one non-empty line per path asked for; anything else (a path with
469
+ # a newline in it, a git that answers differently) is not read as one.
470
+ if len(lines) != 3 or not all(lines):
471
+ return None
472
+ root, git_dir, common = lines
473
+ first = next(
474
+ (
475
+ line.removeprefix("worktree ")
476
+ for line in listing.splitlines()
477
+ if line.startswith("worktree ")
478
+ ),
479
+ "",
480
+ )
481
+ if not first:
482
+ return None
483
+ return (
484
+ Path(root).resolve(),
485
+ Path(git_dir).resolve() != Path(common).resolve(),
486
+ Path(first).resolve(),
487
+ )
488
+
489
+
490
+ def is_inside(path: Path, directory: Path) -> bool:
491
+ return path == directory or directory in path.parents
492
+
493
+
494
+ def is_session_target(target: Path, root: Path, primary: Path) -> bool:
495
+ """Tracked edits stay local; a linked ``.local`` exposes shared documents.
496
+
497
+ Besides the worktree itself, one directory outside it is a session target:
498
+ the primary checkout's own ``.local`` (a real directory, not a symlink),
499
+ and only when this worktree's ``.local`` is a symlink resolving to it.
500
+ That is how documents shared across a repository's worktrees are linked
501
+ into each one; a link aimed anywhere else exposes nothing.
502
+ """
503
+ target, root, primary = target.resolve(), root.resolve(), primary.resolve()
504
+ if is_inside(target, root):
505
+ return True
506
+ shared = (primary / ".local").resolve()
507
+ link = root / ".local"
508
+ return (
509
+ (primary / ".local").is_dir()
510
+ and not (primary / ".local").is_symlink()
511
+ and link.is_symlink()
512
+ and link.resolve() == shared
513
+ and is_inside(target, shared)
514
+ )
515
+
516
+
517
+ def is_claude_managed(root: Path, primary: Path) -> bool:
518
+ return is_inside(root.resolve(), (primary / ".claude" / "worktrees").resolve())
519
+
520
+
521
+ def deny(message: str) -> None:
522
+ print(f"BLOCKED: {message}", file=sys.stderr)
523
+ raise SystemExit(2)
524
+
525
+
526
+ def run_git_result(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
527
+ return subprocess.run(
528
+ ["git", *args],
529
+ cwd=cwd,
530
+ capture_output=True,
531
+ text=True,
532
+ check=False,
533
+ env=git_environment(),
534
+ )
535
+
536
+
537
+ def checkout_failure(cwd: Path) -> str:
538
+ """Why `checkout_state` could not answer, in git's own words.
539
+
540
+ `run_git` reports failure as None and drops stderr. That is right for the
541
+ callers who treat "not a repository" as an ordinary answer, and wrong for a
542
+ denial. When git fails for a reason that has nothing to do with worktrees
543
+ -- an unaccepted Xcode licence on macOS exits 69 before git does any work,
544
+ a corrupt index, an unreadable object store -- the session is told it
545
+ cannot determine its repository checkout, reads that as an ownership
546
+ problem, and goes looking for the wrong thing entirely. The real cause was
547
+ on stderr the whole time.
548
+
549
+ This re-runs the probes purely to quote them, on the denial path only, so no
550
+ successful call pays for the diagnosis.
551
+
552
+ It walks ALL of them and reports the FIRST that fails, naming it. Re-running
553
+ only the first probe was the original bug and it recreated the very
554
+ confusion this function exists to remove: when `--show-toplevel` succeeds
555
+ and a later probe does not -- a corrupt `.git/worktrees`, an unreadable
556
+ common dir -- falling back to stdout returned the resolved toplevel PATH,
557
+ so the denial read "cannot determine this session's repository checkout:
558
+ /repo/path" and presented a successful answer as the cause. Never report
559
+ stdout as a failure reason; an empty answer counts as a failure here
560
+ because `checkout_state` treats it as one.
561
+ """
562
+
563
+ for probe in CHECKOUT_PROBES:
564
+ result = run_git_result(cwd, *probe)
565
+ if result.returncode == 0 and result.stdout.strip():
566
+ continue
567
+ spelling = " ".join(probe)
568
+ detail = result.stderr.strip().splitlines()
569
+ if detail:
570
+ return f"`git {spelling}` failed: {detail[0]}"
571
+ return (
572
+ f"`git {spelling}` exited {result.returncode} with no message"
573
+ if result.returncode
574
+ else f"`git {spelling}` answered nothing"
575
+ )
576
+ return (
577
+ "every git probe answered on re-run, so the earlier failure did not "
578
+ "reproduce; `git worktree list --porcelain` naming no worktree is the "
579
+ "remaining way this can happen"
580
+ )
581
+
582
+
583
+ class ShellWord(str):
584
+ """Decoded shell text with per-character expansion protection.
585
+
586
+ Keep ordinary string comparisons for command parsers, but preserve this
587
+ metadata explicitly whenever extracting a path from an option value.
588
+ Operators are syntax only when emitted outside quotes by the scanner.
589
+ """
590
+
591
+ protected: tuple[bool, ...]
592
+ operator: bool
593
+ quote_start: int | None
594
+
595
+ def __new__(
596
+ cls, value: str, protected: tuple[bool, ...], *, operator: bool = False,
597
+ quote_start: int | None = None,
598
+ ) -> ShellWord:
599
+ word = super().__new__(cls, value)
600
+ word.protected = protected
601
+ word.operator = operator
602
+ word.quote_start = quote_start
603
+ return word
604
+
605
+
606
+ def shell_slice(word: str, start: int, end: int | None = None) -> str:
607
+ value = word[start:end]
608
+ if isinstance(word, ShellWord):
609
+ return ShellWord(value, word.protected[start:end])
610
+ return value
611
+
612
+
613
+ def shell_option_value(word: str) -> str:
614
+ return shell_slice(word, word.index("=") + 1)
615
+
616
+
617
+ def has_dynamic_markers(word: str) -> bool:
618
+ return any(
619
+ character in "$`*?[{}"
620
+ and (not isinstance(word, ShellWord) or not word.protected[index])
621
+ for index, character in enumerate(word)
622
+ )
623
+
624
+
625
+ def is_shell_operator(word: str) -> bool:
626
+ return isinstance(word, ShellWord) and word.operator and word in SHELL_OPERATORS
627
+
628
+
629
+ # Characters that end an unquoted redirection target.
630
+ REDIRECTION_DELIMITERS = " \t\n;&|)"
631
+
632
+
633
+ def fd_prefix(text: list[str], protected: list[bool], quote_start: int | None) -> bool:
634
+ """Whether the word scanned so far is an unquoted file-descriptor number."""
635
+
636
+ return (
637
+ bool(text)
638
+ and quote_start is None
639
+ and not any(protected)
640
+ # ASCII only: str.isdigit() accepts `²`, which every shell reads as
641
+ # an argument before the redirection.
642
+ and re.fullmatch(r"[0-9]+", "".join(text)) is not None
643
+ )
644
+
645
+
646
+ def redirection_end(command: str, start: int, *, duplicate: bool) -> int | None:
647
+ """The end of a reviewed output redirection whose operator ends at ``start``.
648
+
649
+ Two forms open no file in any checkout, so they contribute nothing to the
650
+ mutation targets and are dropped from the words: duplicating or
651
+ closing a descriptor (`2>&1`, `>&2`, `>&-`) and writing to /dev/null
652
+ (`2>/dev/null`, `>>/dev/null`, `>|/dev/null`, `&>/dev/null`). Every other
653
+ output target, a here-document and an output process substitution still
654
+ fail closed: a file target is a write this scanner does not resolve. Input
655
+ redirection is read in ``tokenize_command``.
656
+ """
657
+
658
+ index = start
659
+ following = command[index : index + 1]
660
+ if duplicate and following == "&":
661
+ # `>&word` with a non-numeric word redirects both streams to a file.
662
+ # bash, zsh and sh all read digits alone as a descriptor (a closed one
663
+ # is "Bad file descriptor", never a file named after it). The move form
664
+ # `>&3-` is not accepted: zsh, the Bash tool's shell here, writes a
665
+ # file named `3-`.
666
+ match = re.match(r"[0-9]+|-", command[index + 1 :])
667
+ if match is None:
668
+ return None
669
+ end = index + 1 + len(match.group())
670
+ if end < len(command) and command[end] not in REDIRECTION_DELIMITERS:
671
+ return None
672
+ return end
673
+ if following in {">", "|"}:
674
+ index += 1
675
+ while command[index : index + 1] in {" ", "\t"}:
676
+ index += 1
677
+ end = index + len(os.devnull)
678
+ if command[index:end] != os.devnull or (
679
+ end < len(command) and command[end] not in REDIRECTION_DELIMITERS
680
+ ):
681
+ return None
682
+ return end
683
+
684
+
685
+ def substitution_end(command: str, start: int) -> int | None:
686
+ """Index of the `)` closing a `$(` or `<(` whose body starts at ``start``.
687
+
688
+ Quotes, backslashes and nested parentheses are followed as bash and zsh
689
+ follow them. Anything whose end those shells could place differently is
690
+ refused rather than guessed: backticks, `$'` and `$"` quoting, here
691
+ documents, `case` (its patterns end in a bare `)`) and comments.
692
+ """
693
+
694
+ depth = 0
695
+ index = start
696
+ quote = ""
697
+ word_start = True
698
+ while index < len(command):
699
+ character = command[index]
700
+ following = command[index + 1 : index + 2]
701
+ if quote == "'":
702
+ if character == "'":
703
+ quote = ""
704
+ index += 1
705
+ continue
706
+ if character == "\\":
707
+ index += 2
708
+ word_start = False
709
+ continue
710
+ if character == "`":
711
+ return None
712
+ if character == "$" and following in {"'", '"'} and not quote:
713
+ return None
714
+ if character == "$" and following == "(":
715
+ end = substitution_end(command, index + 2)
716
+ if end is None:
717
+ return None
718
+ index = end + 1
719
+ word_start = False
720
+ continue
721
+ if quote == '"':
722
+ if character == '"':
723
+ quote = ""
724
+ index += 1
725
+ continue
726
+ if character in {"'", '"'}:
727
+ quote = character
728
+ word_start = False
729
+ index += 1
730
+ continue
731
+ if character == "#" and word_start:
732
+ return None
733
+ if character == "<" and following == "<":
734
+ return None
735
+ if word_start and command.startswith("case", index) and (
736
+ command[index + 4 : index + 5] in {"", " ", "\t", "\n", ";"}
737
+ ):
738
+ return None
739
+ if character == "(":
740
+ depth += 1
741
+ elif character == ")":
742
+ if depth == 0:
743
+ return index
744
+ depth -= 1
745
+ word_start = character in " \t\n;&|()"
746
+ index += 1
747
+ return None
748
+
749
+
750
+ # The commands whose output can never begin with `-` or hold a blank, a glob
751
+ # or a quote: object names and ref names (git refuses a ref name starting with
752
+ # `-` or holding `..`, a blank, `*`, `?` or `[`). Their substitution stands for
753
+ # a plain word, so `git log $(git merge-base HEAD main)..HEAD` reads like the
754
+ # same command with the hash typed out. An argument that is not a literal
755
+ # revision (no option but these) makes the output unknown again.
756
+ OPTION_FREE_GIT = {"rev-parse", "merge-base"}
757
+ OPTION_FREE_FLAGS = {"--verify", "--short", "-q", "--quiet", "--abbrev-ref", "--symbolic-full-name"}
758
+ REVISION_WORD = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/@^~{}:-]*")
759
+
760
+
761
+ def substitution_is_option_free(body: str) -> bool:
762
+ """Whether a read-only substitution prints only object or ref names."""
763
+
764
+ segments = split_commands(body)
765
+ if segments is None or len(segments) != 1:
766
+ return False
767
+ words = segments[0]
768
+ if len(words) < 3 or words[0] != "git" or words[1] not in OPTION_FREE_GIT:
769
+ return False
770
+ for word in words[2:]:
771
+ if has_dynamic_markers(word):
772
+ return False
773
+ if word in OPTION_FREE_FLAGS or re.fullmatch(r"--short=[0-9]{1,2}", word):
774
+ continue
775
+ if REVISION_WORD.fullmatch(word) is None:
776
+ return False
777
+ return True
778
+
779
+
780
+ # What a read-only substitution leaves in the word: an object name when its
781
+ # output is option-free, otherwise an expansion this parser cannot read.
782
+ OPTION_FREE_STAND_IN = "0" * 40
783
+ SUBSTITUTION_DEPTH_LIMIT = 8
784
+
785
+
786
+ def tokenize_command(command: str, depth: int = 0) -> list[str] | None:
787
+ """Scan the reviewed shell subset without evaluating any expansions.
788
+
789
+ Reject redirections other than the reviewed ones in ``redirection_end``
790
+ and the input forms below, and unsupported quoting, rather than guessing
791
+ their effects. Nested shell programs are scanned afresh.
792
+
793
+ A command substitution `$(...)` or an input process substitution `<(...)`
794
+ is admitted only when its body is itself read-only (``shell_is_read_only``):
795
+ it then writes nothing, and the word it sits in is an expansion whose value
796
+ is unknown (the `$` stays unprotected, so ``has_dynamic_markers`` sees it)
797
+ or, for `<(...)`, the `/dev/fd` path bash passes. A body that may write
798
+ makes the whole command unresolved, as before.
799
+
800
+ Input redirection opens a file for reading only, so `< file`, `N< file`,
801
+ `<<< word`, `<&N` and `<&-` drop their target. `<<` (a here document) and
802
+ `<>` (which creates its file) stay unresolved.
803
+ """
804
+ if "\0" in command or depth > SUBSTITUTION_DEPTH_LIMIT:
805
+ return None
806
+ tokens: list[str] = []
807
+ text: list[str] = []
808
+ protected: list[bool] = []
809
+ quote = ""
810
+ quote_start: int | None = None
811
+ started = False
812
+ drop_next = False
813
+ index = 0
814
+
815
+ def finish() -> None:
816
+ nonlocal started, quote_start, drop_next
817
+ if started:
818
+ if drop_next:
819
+ drop_next = False
820
+ else:
821
+ tokens.append(ShellWord("".join(text), tuple(protected), quote_start=quote_start))
822
+ text.clear()
823
+ protected.clear()
824
+ started = False
825
+ quote_start = None
826
+
827
+ def substituted(body_start: int, *, quoted: bool, process: bool) -> int | None:
828
+ """Admit one read-only substitution into the current word."""
829
+ nonlocal started
830
+ end = substitution_end(command, body_start)
831
+ if end is None:
832
+ return None
833
+ body = command[body_start:end]
834
+ if not shell_is_read_only(body, depth=depth + 1):
835
+ return None
836
+ if process:
837
+ stand_in, guarded = "/dev/fd/63", True
838
+ elif substitution_is_option_free(body):
839
+ stand_in, guarded = OPTION_FREE_STAND_IN, True
840
+ else:
841
+ stand_in, guarded = "$(" + body + ")", quoted
842
+ text.extend(stand_in)
843
+ protected.extend(
844
+ [guarded] * len(stand_in) if process or stand_in == OPTION_FREE_STAND_IN
845
+ else [False] + [guarded] * (len(stand_in) - 1)
846
+ )
847
+ started = True
848
+ return end + 1
849
+
850
+ while index < len(command):
851
+ character = command[index]
852
+ following = command[index + 1 : index + 2]
853
+ if quote == "'":
854
+ if character == "'":
855
+ quote = ""
856
+ else:
857
+ text.append(character)
858
+ protected.append(True)
859
+ index += 1
860
+ continue
861
+ if character == "\\":
862
+ if not following:
863
+ return None
864
+ if following == "\n":
865
+ index += 2
866
+ continue
867
+ if not quote or following in '$`"\\':
868
+ if quote_start is None:
869
+ quote_start = len(text)
870
+ text.append(following)
871
+ protected.append(True)
872
+ started = True
873
+ index += 2
874
+ continue
875
+ # In double quotes a backslash before other characters is literal.
876
+ text.append(character)
877
+ protected.append(True)
878
+ index += 1
879
+ continue
880
+ # Line continuations can join a dollar to substitution/quote syntax.
881
+ # Inspect the effective next character without rewriting literal text.
882
+ lookahead = index + 1
883
+ while command[lookahead : lookahead + 2] == "\\\n":
884
+ lookahead += 2
885
+ effective_next = command[lookahead : lookahead + 1]
886
+ if character == "`" or (
887
+ character == "$" and effective_next == "(" and lookahead != index + 1
888
+ ):
889
+ return None
890
+ if character == "$" and following == "(":
891
+ if command[index + 2 : index + 3] == "(":
892
+ # `$((...))` is arithmetic, which may assign; still unproven.
893
+ return None
894
+ resumed = substituted(index + 2, quoted=quote == '"', process=False)
895
+ if resumed is None:
896
+ return None
897
+ index = resumed
898
+ continue
899
+ if not quote and character == "$" and effective_next in {"'", '"'}:
900
+ return None
901
+ if quote == '"':
902
+ if character == '"':
903
+ quote = ""
904
+ else:
905
+ text.append(character)
906
+ protected.append(character != "$")
907
+ index += 1
908
+ continue
909
+ if character in {"'", '"'}:
910
+ if quote_start is None:
911
+ quote_start = len(text)
912
+ quote = character
913
+ started = True
914
+ elif character in " \t":
915
+ finish()
916
+ elif character == "#" and not started:
917
+ newline = command.find("\n", index)
918
+ index = len(command) if newline < 0 else newline
919
+ continue
920
+ elif character == ">" or (character == "&" and following == ">"):
921
+ if drop_next and not started:
922
+ return None
923
+ if character == ">" and fd_prefix(text, protected, quote_start):
924
+ # `2>`: an unquoted all-digit word right before `>` is the
925
+ # descriptor, not an argument.
926
+ text.clear()
927
+ protected.clear()
928
+ started = False
929
+ quote_start = None
930
+ else:
931
+ finish()
932
+ end = redirection_end(
933
+ command, index + (1 if character == ">" else 2),
934
+ duplicate=character == ">",
935
+ )
936
+ if end is None:
937
+ return None
938
+ index = end
939
+ continue
940
+ elif character == "<" and following == "(":
941
+ # `< <(cmd)`: the substitution is the input redirection's target.
942
+ resumed = substituted(index + 2, quoted=False, process=True)
943
+ if resumed is None:
944
+ return None
945
+ index = resumed
946
+ continue
947
+ elif character == "<":
948
+ if drop_next and not started:
949
+ return None
950
+ if fd_prefix(text, protected, quote_start):
951
+ # `2<file`: the digits are the descriptor, not an argument.
952
+ text.clear()
953
+ protected.clear()
954
+ started = False
955
+ quote_start = None
956
+ else:
957
+ finish()
958
+ rest = command[index + 1 :]
959
+ if rest.startswith("<<"):
960
+ # `<<< word`: the word is the input, as with `< file`.
961
+ index += 3
962
+ elif rest.startswith(("<", ">")):
963
+ return None
964
+ elif rest.startswith("&"):
965
+ match = re.match(r"[0-9]+|-", rest[1:])
966
+ end = index + 2 + (len(match.group()) if match else 0)
967
+ if match is None or (
968
+ end < len(command) and command[end] not in REDIRECTION_DELIMITERS
969
+ ):
970
+ return None
971
+ index = end
972
+ continue
973
+ else:
974
+ index += 1
975
+ drop_next = True
976
+ continue
977
+ elif character in ";&|()\n":
978
+ if drop_next and not started:
979
+ return None
980
+ finish()
981
+ operator = ";" if character == "\n" else character
982
+ if character + following in {"&&", "||", "|&"}:
983
+ operator += following
984
+ index += 1
985
+ elif character + following in {";;", ";&"}:
986
+ return None
987
+ tokens.append(ShellWord(operator, (False,) * len(operator), operator=True))
988
+ else:
989
+ text.append(character)
990
+ protected.append(False)
991
+ started = True
992
+ index += 1
993
+ if quote:
994
+ return None
995
+ finish()
996
+ if drop_next:
997
+ return None
998
+ return tokens
999
+
1000
+
1001
+ def split_commands(command: str, depth: int = 0) -> list[list[str]] | None:
1002
+ tokens = tokenize_command(command, depth)
1003
+ if tokens is None:
1004
+ return None
1005
+ segments: list[list[str]] = []
1006
+ current: list[str] = []
1007
+ for token in tokens:
1008
+ if is_shell_operator(token):
1009
+ if current:
1010
+ segments.append(current)
1011
+ current = []
1012
+ else:
1013
+ current.append(token)
1014
+ if current:
1015
+ segments.append(current)
1016
+ return segments
1017
+
1018
+
1019
+ def effective_segment(words: list[str]) -> tuple[list[str], bool, bool] | None:
1020
+ """Return the command plus wrapper and executable-lookup metadata."""
1021
+
1022
+ index = 0
1023
+ wrapped = False
1024
+ lookup_modified = False
1025
+ assignments_allowed = True
1026
+ while index < len(words):
1027
+ name = os.path.basename(words[index])
1028
+ if words[index] != name and (
1029
+ name in SHELL_CONTROL_WORDS
1030
+ or name in SHELL_COMMAND_WRAPPERS
1031
+ or name == "!"
1032
+ ):
1033
+ # Wrapper and control semantics belong only to the reviewed bare
1034
+ # shell token. A path-qualified lookalike is arbitrary executable
1035
+ # code and must not be stripped before executable validation.
1036
+ return None
1037
+ if name in SHELL_CONTROL_WORDS | {"!", "noglob"}:
1038
+ if wrapped or (
1039
+ isinstance(words[index], ShellWord)
1040
+ and words[index].quote_start is not None
1041
+ ):
1042
+ # These words are shell syntax only in an unquoted prefix.
1043
+ # After an executable wrapper they instead name a program.
1044
+ return None
1045
+ if name in SHELL_CONTROL_WORDS:
1046
+ index += 1
1047
+ continue
1048
+ if name in SHELL_COMMAND_WRAPPERS or name == "!":
1049
+ assignments_allowed = False
1050
+ assignment = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)=.*", words[index])
1051
+ if assignment:
1052
+ word = words[index]
1053
+ if (
1054
+ not assignments_allowed or (
1055
+ isinstance(word, ShellWord)
1056
+ and word.quote_start is not None
1057
+ and word.quote_start <= word.index("=")
1058
+ )
1059
+ ):
1060
+ # Quoting in the name or equals sign makes this an executable
1061
+ # word, even when the quoted fragment is empty.
1062
+ return None
1063
+ wrapped = True
1064
+ lookup_modified = lookup_modified or assignment.group(1) == "PATH"
1065
+ index += 1
1066
+ continue
1067
+ if name == "!":
1068
+ wrapped = True
1069
+ index += 1
1070
+ continue
1071
+ if name == "command":
1072
+ wrapped = True
1073
+ index += 1
1074
+ if index < len(words) and words[index] in {"-v", "-V"}:
1075
+ return ["true"], False, lookup_modified
1076
+ while index < len(words) and words[index] in {"-p", "--"}:
1077
+ lookup_modified = lookup_modified or words[index] == "-p"
1078
+ index += 1
1079
+ continue
1080
+ if name == "env":
1081
+ wrapped = True
1082
+ index += 1
1083
+ while index < len(words):
1084
+ option = words[index]
1085
+ if option in {"-C", "--chdir"} or option.startswith("--chdir="):
1086
+ return None
1087
+ if option in {"-S", "--split-string"} or option.startswith(
1088
+ "--split-string="
1089
+ ):
1090
+ return None
1091
+ if option in {"-u", "--unset"}:
1092
+ if index + 1 >= len(words):
1093
+ return None
1094
+ lookup_modified = lookup_modified or words[index + 1] == "PATH"
1095
+ index += 2
1096
+ continue
1097
+ if option in {"-i", "--ignore-environment", "--"}:
1098
+ lookup_modified = lookup_modified or option != "--"
1099
+ index += 1
1100
+ continue
1101
+ if option.startswith("--unset="):
1102
+ lookup_modified = (
1103
+ lookup_modified or option.split("=", 1)[1] == "PATH"
1104
+ )
1105
+ index += 1
1106
+ continue
1107
+ assignment = re.fullmatch(
1108
+ r"([A-Za-z_][A-Za-z0-9_]*)=.*", option
1109
+ )
1110
+ if assignment:
1111
+ lookup_modified = (
1112
+ lookup_modified or assignment.group(1) == "PATH"
1113
+ )
1114
+ index += 1
1115
+ continue
1116
+ if option.startswith("-"):
1117
+ return None
1118
+ break
1119
+ continue
1120
+ if name == "exec":
1121
+ wrapped = True
1122
+ index += 1
1123
+ while index < len(words):
1124
+ option = words[index]
1125
+ if option == "-a":
1126
+ if index + 1 >= len(words):
1127
+ return None
1128
+ index += 2
1129
+ continue
1130
+ if option in {"-c", "-l", "--"}:
1131
+ lookup_modified = lookup_modified or option == "-c"
1132
+ index += 1
1133
+ continue
1134
+ if option.startswith("-"):
1135
+ return None
1136
+ break
1137
+ continue
1138
+ if name == "time":
1139
+ wrapped = True
1140
+ index += 1
1141
+ while index < len(words):
1142
+ option = words[index]
1143
+ if option in {"-p", "--"}:
1144
+ index += 1
1145
+ continue
1146
+ if option.startswith("-"):
1147
+ return None
1148
+ break
1149
+ continue
1150
+ if name == "nice":
1151
+ wrapped = True
1152
+ index += 1
1153
+ while index < len(words):
1154
+ option = words[index]
1155
+ if option == "--":
1156
+ index += 1
1157
+ break
1158
+ if option in {"-n", "--adjustment"}:
1159
+ if (
1160
+ index + 1 >= len(words)
1161
+ or re.fullmatch(r"[+-]?\d+", words[index + 1]) is None
1162
+ ):
1163
+ return None
1164
+ index += 2
1165
+ continue
1166
+ if option.startswith("--adjustment="):
1167
+ if re.fullmatch(r"[+-]?\d+", option.split("=", 1)[1]) is None:
1168
+ return None
1169
+ index += 1
1170
+ continue
1171
+ if re.fullmatch(r"-\d+", option):
1172
+ index += 1
1173
+ continue
1174
+ if option.startswith(("-", "+")):
1175
+ return None
1176
+ break
1177
+ continue
1178
+ if name in {"builtin", "noglob"}:
1179
+ wrapped = True
1180
+ index += 1
1181
+ if index < len(words) and words[index] == "--":
1182
+ index += 1
1183
+ continue
1184
+ break
1185
+ return words[index:], wrapped, lookup_modified
1186
+
1187
+
1188
+ def contains_nested_git_c(words: list[str]) -> bool:
1189
+ return any(
1190
+ os.path.basename(word) == "git" and "-C" in words[index + 1 :]
1191
+ for index, word in enumerate(words[1:], start=1)
1192
+ )
1193
+
1194
+
1195
+ def git_is_read_only(words: list[str]) -> bool:
1196
+ if any(
1197
+ word in {"-c", "--config-env"}
1198
+ or word.startswith("--config-env=")
1199
+ for word in words[1:]
1200
+ ):
1201
+ return False
1202
+ index, _target = git_command(words, None)
1203
+ if index is None:
1204
+ return False
1205
+ if index >= len(words):
1206
+ return True
1207
+ verb = words[index]
1208
+ args = words[index + 1 :]
1209
+ if verb == "branch":
1210
+ return branch_is_read_only(args)
1211
+ if verb == "remote":
1212
+ return remote_is_read_only(args)
1213
+ if verb == "tag":
1214
+ return tag_is_read_only(args)
1215
+ if verb == "stash" and args[:1] in (["list"], ["show"]):
1216
+ # Only push, pop, apply, drop, clear, store and create change state;
1217
+ # list and show take log and diff options, read like those.
1218
+ return git_inspection_outputs([*words[:index], *words[index + 1 :]], index) == []
1219
+ if verb in READ_GIT:
1220
+ return git_inspection_outputs(words, index) == []
1221
+ return (
1222
+ verb == "worktree"
1223
+ and bool(args)
1224
+ and args[0] == "list"
1225
+ )
1226
+
1227
+
1228
+ def git_inspection_outputs(words: list[str], command_index: int) -> list[str] | None:
1229
+ """Classify reviewed inspection flags and collect every explicit output.
1230
+
1231
+ Unknown options fail closed, including Git's abbreviated long options.
1232
+ A pathspec after -- is data, even when it looks like an unsafe option.
1233
+ """
1234
+ index = 1
1235
+ while index < command_index:
1236
+ option = words[index]
1237
+ if option == "-C":
1238
+ index += 2
1239
+ elif option in {"--no-pager", "--no-optional-locks", "--literal-pathspecs", "--no-replace-objects"}:
1240
+ index += 1
1241
+ else:
1242
+ return None
1243
+ # Exact names only: never accept --out as an abbreviation of --output.
1244
+ flags = {
1245
+ "--all", "--binary", "--cached", "--check", "--count", "--compact-summary",
1246
+ "--date-order", "--decorate", "--exit-code", "--first-parent", "--follow",
1247
+ "--full-history", "--full-index", "--graph", "--header", "--left-right",
1248
+ "--name-only", "--name-status", "--no-abbrev", "--no-color", "--no-decorate",
1249
+ # Negates the revisions after it (`HEAD --not origin/main`); names no file.
1250
+ "--not",
1251
+ "--no-ext-diff", "--no-merges", "--no-patch", "--no-renames", "--no-textconv",
1252
+ "--numstat", "--objects", "--oneline", "--parents", "--patch", "--porcelain",
1253
+ "--quiet", "--raw", "--relative", "--reverse", "--short", "--shortstat",
1254
+ "--show-toplevel", "--git-dir", "--git-common-dir", "--show-prefix",
1255
+ "--is-inside-work-tree", "--is-bare-repository", "--symbolic-full-name",
1256
+ "--abbrev-ref", "--verify", "--staged", "--stat", "--summary", "--topo-order",
1257
+ "--unmerged", "--deleted", "--modified", "--others", "--exclude-standard",
1258
+ "--recurse-submodules", "--text", "--ignore-all-space", "--ignore-space-change",
1259
+ # `git status` display options; none names an output.
1260
+ "--branch", "--ahead-behind", "--no-ahead-behind", "--show-stash",
1261
+ "--untracked-files",
1262
+ }
1263
+ values = {
1264
+ "--abbrev", "--author", "--before", "--after", "--since", "--until",
1265
+ "--color", "--date", "--decorate", "--diff-filter", "--diff-algorithm",
1266
+ "--format", "--pretty", "--grep", "--max-count", "--skip", "--sort",
1267
+ "--path-format", "--unified", "--find-renames", "--find-copies",
1268
+ "--output-indicator-new", "--output-indicator-old", "--output-indicator-context",
1269
+ }
1270
+ outputs: list[str] = []
1271
+ index = command_index + 1
1272
+ while index < len(words):
1273
+ option = words[index]
1274
+ if option == "--":
1275
+ break
1276
+ if option == "--output" or option.startswith("--output="):
1277
+ if "=" in option:
1278
+ value = shell_option_value(option)
1279
+ else:
1280
+ index += 1
1281
+ if index >= len(words):
1282
+ return None
1283
+ value = words[index]
1284
+ if not value or value.startswith("-"):
1285
+ return None
1286
+ outputs.append(value)
1287
+ elif option in flags:
1288
+ pass
1289
+ elif option in values or option in {"-n", "-L", "-S", "-G", "-O"}:
1290
+ index += 1
1291
+ if index >= len(words) or words[index].startswith("-"):
1292
+ return None
1293
+ elif "=" in option and option.split("=", 1)[0] in values | flags:
1294
+ pass
1295
+ elif re.fullmatch(r"-(?:[0-9]+|[purszabw]+|[UMCBn][0-9]+)", option):
1296
+ pass
1297
+ elif option.startswith("-"):
1298
+ return None
1299
+ index += 1
1300
+ return outputs
1301
+
1302
+
1303
+ def git_command(words: list[str], cwd: Path | None) -> tuple[int | None, Path | None]:
1304
+ """Return the subcommand index and cumulative ``git -C`` directory."""
1305
+
1306
+ index = 1
1307
+ target = cwd
1308
+ while index < len(words) and words[index].startswith("-"):
1309
+ option = words[index]
1310
+ if option == "-C":
1311
+ if index + 1 >= len(words):
1312
+ return None, None
1313
+ if target is not None:
1314
+ target = resolve_shell_path(words[index + 1], target, empty_is_current=True)
1315
+ if target is None:
1316
+ return None, None
1317
+ index += 2
1318
+ continue
1319
+ if option in {"-c", "--config-env", "--git-dir", "--work-tree"}:
1320
+ if index + 1 >= len(words):
1321
+ return None, None
1322
+ index += 2
1323
+ continue
1324
+ if any(
1325
+ option.startswith(f"{prefix}=")
1326
+ for prefix in ("--config-env", "--git-dir", "--work-tree")
1327
+ ):
1328
+ index += 1
1329
+ continue
1330
+ index += 1
1331
+ return index, target
1332
+
1333
+
1334
+ def resolve_shell_path(
1335
+ raw_value: str, base: Path, *, empty_is_current: bool = False
1336
+ ) -> Path | None:
1337
+ if empty_is_current and not raw_value:
1338
+ return base.resolve()
1339
+ if not raw_value or raw_value == "-" or has_dynamic_markers(raw_value):
1340
+ return None
1341
+ raw = Path(raw_value)
1342
+ literal_tilde = isinstance(raw_value, ShellWord) and raw_value.protected[0]
1343
+ if not literal_tilde:
1344
+ raw = raw.expanduser()
1345
+ if "~" in raw.as_posix():
1346
+ return None
1347
+ return (raw if raw.is_absolute() else base / raw).resolve()
1348
+
1349
+
1350
+ def branch_is_read_only(args: list[str]) -> bool:
1351
+ if not args:
1352
+ return True
1353
+ if args == ["--show-current"]:
1354
+ return True
1355
+ safe_flags = {
1356
+ "-a",
1357
+ "--all",
1358
+ "-r",
1359
+ "--remotes",
1360
+ "--list",
1361
+ "--no-color",
1362
+ "--ignore-case",
1363
+ "--omit-empty",
1364
+ "-v",
1365
+ "-vv",
1366
+ "--verbose",
1367
+ }
1368
+ value_options = {
1369
+ "--contains",
1370
+ "--no-contains",
1371
+ "--merged",
1372
+ "--no-merged",
1373
+ "--points-at",
1374
+ "--format",
1375
+ "--sort",
1376
+ "--color",
1377
+ "--column",
1378
+ }
1379
+ list_mode = any(
1380
+ arg in {"-a", "--all", "-r", "--remotes", "--list"}
1381
+ or any(arg == option or arg.startswith(f"{option}=") for option in value_options)
1382
+ for arg in args
1383
+ )
1384
+ return option_list_is_safe(args, safe_flags, value_options, list_mode)
1385
+
1386
+
1387
+ def remote_is_read_only(args: list[str]) -> bool:
1388
+ if not args or all(arg in {"-v", "--verbose"} for arg in args):
1389
+ return True
1390
+ prefix = 0
1391
+ while prefix < len(args) and args[prefix] in {"-v", "--verbose"}:
1392
+ prefix += 1
1393
+ if prefix >= len(args):
1394
+ return True
1395
+ verb = args[prefix]
1396
+ rest = args[prefix + 1 :]
1397
+ if verb == "get-url":
1398
+ return bool(rest) and all(
1399
+ item in {"--all", "--push"} or not item.startswith("-") for item in rest
1400
+ )
1401
+ if verb == "show":
1402
+ return bool(rest) and all(
1403
+ item == "-n" or not item.startswith("-") for item in rest
1404
+ )
1405
+ return False
1406
+
1407
+
1408
+ def tag_is_read_only(args: list[str]) -> bool:
1409
+ if not args:
1410
+ return True
1411
+ safe_flags = {
1412
+ "-l",
1413
+ "--list",
1414
+ "--ignore-case",
1415
+ "--omit-empty",
1416
+ "--no-column",
1417
+ }
1418
+ value_options = {
1419
+ "--contains",
1420
+ "--no-contains",
1421
+ "--merged",
1422
+ "--no-merged",
1423
+ "--points-at",
1424
+ "--format",
1425
+ "--sort",
1426
+ "--color",
1427
+ "--column",
1428
+ }
1429
+ list_mode = any(arg in {"-l", "--list"} for arg in args)
1430
+ return option_list_is_safe(args, safe_flags, value_options, list_mode)
1431
+
1432
+
1433
+ def option_list_is_safe(
1434
+ args: list[str],
1435
+ safe_flags: set[str],
1436
+ value_options: set[str],
1437
+ allow_positionals: bool,
1438
+ ) -> bool:
1439
+ index = 0
1440
+ while index < len(args):
1441
+ item = args[index]
1442
+ if item in safe_flags:
1443
+ index += 1
1444
+ continue
1445
+ matching = next(
1446
+ (option for option in value_options if item == option),
1447
+ None,
1448
+ )
1449
+ if matching:
1450
+ if index + 1 >= len(args):
1451
+ return False
1452
+ index += 2
1453
+ continue
1454
+ if any(item.startswith(f"{option}=") for option in value_options):
1455
+ index += 1
1456
+ continue
1457
+ if item.startswith("-") or not allow_positionals:
1458
+ return False
1459
+ index += 1
1460
+ return True
1461
+
1462
+
1463
+ def date_is_read_only(args: list[str]) -> bool:
1464
+ """`date` reports a clock unless an argument sets one.
1465
+
1466
+ It needs a parser rather than a place in READ_COMMANDS because two
1467
+ unrelated spellings set the system clock: GNU's `-s` / `--set=`, and BSD's
1468
+ bare operand (`date 202609121200`), which carries no option at all. Spotting
1469
+ the setters is therefore not enough -- an unrecognised word IS a setter --
1470
+ so admission is an allowlist and everything else fails closed.
1471
+
1472
+ Deliberately narrow to the forms that mean the same thing on both GNU and
1473
+ BSD. `-d`, `-f`, `-j`, `-r` and `-v` are all excluded because they diverge:
1474
+ BSD's `-d` sets daylight saving and its `-f` parses a new date and then sets
1475
+ the clock, while GNU's `-d` merely displays a given date. The long
1476
+ `--date=` / `--reference=` spellings are GNU-only and unambiguous, so they
1477
+ stay. This is the same test that keeps `hostname` out.
1478
+ """
1479
+
1480
+ flags = {
1481
+ "-u", "--utc", "--universal", "-R", "--rfc-email",
1482
+ "--help", "--version",
1483
+ }
1484
+ for item in args:
1485
+ if item.startswith("+"):
1486
+ continue
1487
+ if item in flags:
1488
+ continue
1489
+ if item == "-I" or re.fullmatch(r"--iso-8601(?:=\w+)?", item):
1490
+ continue
1491
+ if re.fullmatch(r"-I\w+|--rfc-(?:3339)=\w+", item):
1492
+ continue
1493
+ if re.fullmatch(r"--(?:date|reference)=.+", item):
1494
+ continue
1495
+ return False
1496
+ return True
1497
+
1498
+
1499
+ SED_LONG_OPTIONS = {
1500
+ "expression": "program",
1501
+ "file": "unknown",
1502
+ "in-place": "writes",
1503
+ "line-length": "value",
1504
+ "null-data": "flag",
1505
+ "zero-terminated": "flag",
1506
+ "posix": "flag",
1507
+ "quiet": "flag",
1508
+ "silent": "flag",
1509
+ "regexp-extended": "flag",
1510
+ "separate": "flag",
1511
+ "sandbox": "flag",
1512
+ "unbuffered": "flag",
1513
+ "debug": "flag",
1514
+ "follow-symlinks": "flag",
1515
+ "binary": "flag",
1516
+ "help": "flag",
1517
+ "version": "flag",
1518
+ }
1519
+ SED_BARE_COMMANDS = set("=dDgGhHnNpPxzFlLqQ")
1520
+
1521
+
1522
+ def sed_is_read_only(args: list[str]) -> bool:
1523
+ """Whether `sed` only prints: no in-place edit and no program that writes.
1524
+
1525
+ A sed program writes files itself (`w`/`W`, the `w` flag of `s`) or runs
1526
+ commands (`e`, the `e` flag of `s`), so the program is parsed too. A
1527
+ script file (`-f`) is unseen code, and a program this parser cannot read
1528
+ is refused rather than guessed at.
1529
+ """
1530
+ programs: list[str] = []
1531
+ index = 0
1532
+ operands: list[str] = []
1533
+ while index < len(args):
1534
+ item = args[index]
1535
+ index += 1
1536
+ if item == "--":
1537
+ operands.extend(args[index:])
1538
+ break
1539
+ if item.startswith("--"):
1540
+ name, has_value, attached = item[2:].partition("=")
1541
+ kinds = {kind for option, kind in SED_LONG_OPTIONS.items() if option.startswith(name)}
1542
+ if not name or not kinds or kinds & {"writes", "unknown"} or len(kinds) > 1 and "program" in kinds:
1543
+ return False
1544
+ if "program" in kinds or "value" in kinds:
1545
+ if has_value:
1546
+ value = attached
1547
+ elif index < len(args):
1548
+ value = args[index]
1549
+ index += 1
1550
+ else:
1551
+ return False
1552
+ if "program" in kinds:
1553
+ programs.append(value)
1554
+ continue
1555
+ if item.startswith("-") and item != "-":
1556
+ cluster = item[1:]
1557
+ for position, flag in enumerate(cluster):
1558
+ if flag in "iIf":
1559
+ return False
1560
+ if flag in "el":
1561
+ value = cluster[position + 1 :]
1562
+ if not value:
1563
+ if index >= len(args):
1564
+ return False
1565
+ value = args[index]
1566
+ index += 1
1567
+ if flag == "e":
1568
+ programs.append(value)
1569
+ break
1570
+ if flag not in "nsuzrEb":
1571
+ return False
1572
+ continue
1573
+ operands.append(item)
1574
+ if not programs:
1575
+ if not operands:
1576
+ return False
1577
+ programs.append(operands[0])
1578
+ script = "\n".join(programs)
1579
+ # GNU sed ends a regex at the first unescaped delimiter; BSD sed skips
1580
+ # delimiters inside a bracket expression. Both readings must be safe.
1581
+ return all(sed_program_is_read_only(script, brackets) for brackets in (False, True))
1582
+
1583
+
1584
+ def sed_delimited(script: str, index: int, delimiter: str, brackets: bool) -> int | None:
1585
+ """Index just past the part of `script` ending at `delimiter`, or None."""
1586
+ while index < len(script):
1587
+ char = script[index]
1588
+ if char == "\\":
1589
+ index += 2
1590
+ continue
1591
+ if char == "\n" and delimiter != "\n":
1592
+ return None
1593
+ if brackets and char == "[":
1594
+ start = index + 1
1595
+ if script[start : start + 1] == "^":
1596
+ start += 1
1597
+ if script[start : start + 1] == "]":
1598
+ start += 1
1599
+ close = script.find("]", start)
1600
+ if close == -1:
1601
+ return None
1602
+ index = close + 1
1603
+ continue
1604
+ if char == delimiter:
1605
+ return index + 1
1606
+ index += 1
1607
+ return None
1608
+
1609
+
1610
+ def sed_line_end(script: str, index: int) -> int:
1611
+ """Index of the end of a text argument, following `\\`-newline continuations."""
1612
+ while index < len(script):
1613
+ if script[index] == "\\":
1614
+ index += 2
1615
+ continue
1616
+ if script[index] == "\n":
1617
+ return index
1618
+ index += 1
1619
+ return index
1620
+
1621
+
1622
+ def sed_program_is_read_only(script: str, brackets: bool) -> bool:
1623
+ index = 0
1624
+ length = len(script)
1625
+ while index < length:
1626
+ char = script[index]
1627
+ if char in " \t\n;{}!":
1628
+ index += 1
1629
+ continue
1630
+ if char == "#":
1631
+ index = sed_line_end(script, index)
1632
+ continue
1633
+ # Addresses: a line number, `$`, `first~step`, `+N`, or a regex.
1634
+ if char.isdigit() or char in "$,~+":
1635
+ index += 1
1636
+ continue
1637
+ if char in "/\\":
1638
+ if char == "\\":
1639
+ if index + 1 >= length:
1640
+ return False
1641
+ delimiter = script[index + 1]
1642
+ index += 2
1643
+ else:
1644
+ delimiter = "/"
1645
+ index += 1
1646
+ end = sed_delimited(script, index, delimiter, brackets)
1647
+ if end is None:
1648
+ return False
1649
+ index = end
1650
+ while index < length and script[index] in "IM":
1651
+ index += 1
1652
+ continue
1653
+ index += 1
1654
+ if char in "wWe":
1655
+ return False
1656
+ if char in SED_BARE_COMMANDS:
1657
+ while index < length and script[index] in " \t0123456789":
1658
+ index += 1
1659
+ continue
1660
+ if char in "aicrR:bTtv":
1661
+ index = sed_line_end(script, index) if char in "aicrR" else sed_label_end(script, index)
1662
+ continue
1663
+ if char in "sy":
1664
+ if index >= length or script[index] in "\\\n":
1665
+ return False
1666
+ delimiter = script[index]
1667
+ index += 1
1668
+ for part in range(2):
1669
+ end = sed_delimited(script, index, delimiter, brackets and part == 0 and char == "s")
1670
+ if end is None:
1671
+ return False
1672
+ index = end
1673
+ if char == "s":
1674
+ while index < length and (script[index].isdigit() or script[index] in "gpiImMwe"):
1675
+ if script[index] in "we":
1676
+ return False
1677
+ index += 1
1678
+ continue
1679
+ return False
1680
+ return True
1681
+
1682
+
1683
+ def sed_label_end(script: str, index: int) -> int:
1684
+ """End of a label: GNU stops at `;`, so the rest is parsed as commands."""
1685
+ while index < len(script) and script[index] not in ";\n}":
1686
+ index += 1
1687
+ return index
1688
+
1689
+
1690
+ def node_is_read_only(args: list[str]) -> bool:
1691
+ """Node asked only to identify itself.
1692
+
1693
+ Node is an interpreter, and the guard already knows it:
1694
+ NON_SHELL_INLINE_INTERPRETERS denies `-e` and `-p`, and
1695
+ `mutation_path_operands` denies a script file because no parser can prove
1696
+ what opaque program text writes. Both denials are correct and both stay.
1697
+ This admits the version and help queries alone, which run no program, and
1698
+ requires at least one of them -- a bare `node` opens a REPL that can
1699
+ execute anything.
1700
+
1701
+ The spellings are NOT shared with the shell interpreters below. For node
1702
+ `-v` is the version; for bash `-v` is verbose mode, which still executes
1703
+ every command. One set covering both would quietly allow `bash -v`.
1704
+ """
1705
+
1706
+ return bool(args) and all(
1707
+ item in {"-v", "--version", "-h", "--help"} for item in args
1708
+ )
1709
+
1710
+
1711
+ def shell_interpreter_is_read_only(args: list[str]) -> bool:
1712
+ """A shell that reports itself, or parses a program without running it.
1713
+
1714
+ `-n` is noexec: the shell reads its program, parses it, and never runs a
1715
+ command. That is the only way a session can syntax-check the shell
1716
+ libraries in the repository that it is editing, and it writes nothing.
1717
+
1718
+ The exclusions carry the proof. Option scanning stops at the first
1719
+ operand, because a `-n` AFTER the script name is a positional parameter
1720
+ and the program then executes normally. `-i` is refused because an
1721
+ interactive shell IGNORES `-n`. `-o`, `+o`, `-O`, `+O` and every long
1722
+ option but the two queries are refused because their values name options
1723
+ this parser does not interpret, and `-c` is refused so that the reviewed
1724
+ spelling cannot become an arbitrary program if the noexec requirement is
1725
+ ever relaxed.
1726
+
1727
+ Only `--version` and `--help` count as queries: bash's `-v` is verbose.
1728
+ """
1729
+
1730
+ if args and all(item in {"--version", "--help"} for item in args):
1731
+ return True
1732
+ noexec = False
1733
+ for item in args:
1734
+ if item in {"--", "-"} or not item.startswith("-"):
1735
+ break
1736
+ if not re.fullmatch(r"-[nvx]+", item):
1737
+ return False
1738
+ noexec = noexec or "n" in item
1739
+ return noexec
1740
+
1741
+
1742
+ # adb drives an Android device; it runs nothing on the host but itself. What
1743
+ # it can do to the host is fixed by its subcommand, so it gets a parser of its
1744
+ # own rather than a place in READ_COMMANDS or GENERIC_SCOPED_COMMANDS: the
1745
+ # first would claim `adb pull` writes nothing, the second would read the
1746
+ # device path in `adb push app.apk /data/local/tmp` as a host destination.
1747
+ #
1748
+ # Three tiers. `adb_is_read_only` admits what changes neither the host nor the
1749
+ # device, from any checkout. `adb_mutation_paths` names what the rest write on
1750
+ # the HOST -- `pull`'s local destination, a bugreport, a backup, a key pair --
1751
+ # so those are confined like any other operand. Everything else it lists
1752
+ # changes only the device (install, uninstall, push, `shell pm clear`, port
1753
+ # forwards, reboot); those write nothing in any checkout, and run from this
1754
+ # session's own worktree, which is where the reviewed runners run (a
1755
+ # deliberate choice: a device is no checkout, but its writes stay behind the
1756
+ # same ownership gate as every other runner's).
1757
+ #
1758
+ # Every adb client starts the adb server when none is running. Its state lives
1759
+ # under ~/.android and $TMPDIR, never in a checkout, and no operand names it --
1760
+ # but the environment can (ANDROID_USER_HOME, TMPDIR), which is why
1761
+ # `mutation_directories` takes an adb behind an assignment or wrapper out of
1762
+ # the read-only tier.
1763
+ ADB_FLAGS = {"-d", "-e"}
1764
+ ADB_VALUE_OPTIONS = {"-s", "-t", "-H", "-P"}
1765
+ ADB_READ_SUBCOMMANDS = {
1766
+ "devices",
1767
+ "features",
1768
+ "get-devpath",
1769
+ "get-serialno",
1770
+ "get-state",
1771
+ "help",
1772
+ "host-features",
1773
+ "version",
1774
+ }
1775
+ ADB_WAIT = re.compile(
1776
+ r"wait-for(?:-(?:usb|local|any))?"
1777
+ r"-(?:device|recovery|rescue|sideload|bootloader|disconnect)"
1778
+ )
1779
+ # Subcommands whose only host effect is reading host files or talking to the
1780
+ # server. `emu` and `ppp` are absent on purpose: the emulator console can
1781
+ # write host files (`screenrecord start <file>`), and `ppp` runs a host pppd.
1782
+ ADB_DEVICE_SUBCOMMANDS = ADB_READ_SUBCOMMANDS | {
1783
+ "connect",
1784
+ "disable-verity",
1785
+ "disconnect",
1786
+ "enable-verity",
1787
+ "exec-in",
1788
+ "exec-out",
1789
+ "forward",
1790
+ "install",
1791
+ "install-multi-package",
1792
+ "install-multiple",
1793
+ "jdwp",
1794
+ "kill-server",
1795
+ "logcat",
1796
+ "mdns",
1797
+ "pair",
1798
+ "push",
1799
+ "reboot",
1800
+ "reboot-bootloader",
1801
+ "reconnect",
1802
+ "remount",
1803
+ "restore",
1804
+ "reverse",
1805
+ "root",
1806
+ "shell",
1807
+ "sideload",
1808
+ "start-server",
1809
+ "sync",
1810
+ "tcpip",
1811
+ "track-devices",
1812
+ "uninstall",
1813
+ "unroot",
1814
+ "usb",
1815
+ }
1816
+ # logcat options that leave the device's log buffers as they are. Mutators are
1817
+ # absent and so refused: -c clears, -G and -P set the buffer size and prune
1818
+ # list, -f/-r/-n write a file ON THE DEVICE (logcat runs there; it is not a
1819
+ # host write). `-g` and `-p` only print what `-G` and `-P` would set.
1820
+ LOGCAT_FLAGS = set("BDLSdgsp")
1821
+ LOGCAT_VALUE_FLAGS = set("Tbemtv")
1822
+ LOGCAT_LONG = re.compile(
1823
+ r"--(?:binary|dividers|last|print|statistics|wrap"
1824
+ r"|(?:buffer|format|max-count|pid|regex|uid)=.+)"
1825
+ )
1826
+ # The device's own shell re-parses `adb shell`'s words joined by spaces, with
1827
+ # no escaping, so one quoted word can carry `;`, `|`, `>` or a newline to the
1828
+ # device. A device command is read-only only when the joined line holds none of
1829
+ # that: the reviewed characters plus the space, and nothing else.
1830
+ DEVICE_LINE = re.compile(r"[A-Za-z0-9_.:/=,@%+ -]*")
1831
+ PACKAGE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+")
1832
+ PM_LISTS = {
1833
+ "features",
1834
+ "instrumentation",
1835
+ "libraries",
1836
+ "packages",
1837
+ "permission-groups",
1838
+ "permissions",
1839
+ "users",
1840
+ }
1841
+
1842
+
1843
+ # Variables that move where the adb server writes on ANY call: its log and its
1844
+ # key pair. A value given one of these is a destination like an operand.
1845
+ ADB_STATE_ASSIGNMENT = re.compile(
1846
+ r"(?:TMPDIR|HOME|ANDROID_SDK_HOME|ANDROID_USER_HOME|ANDROID_PREFS_ROOT"
1847
+ r"|ANDROID_ADB_LOG_PATH)=(.*)",
1848
+ re.DOTALL,
1849
+ )
1850
+
1851
+
1852
+ def adb_state_paths(words: list[str], cwd: Path) -> list[Path] | None:
1853
+ """Destinations named by state-moving assignments among ``words``.
1854
+
1855
+ Applied to an adb command's own prefix, and to an assignment-only segment
1856
+ whatever follows it: TMPDIR and HOME are already exported, so `TMPDIR=x;`
1857
+ reaches every later command on the line, nested shells included. A value
1858
+ that cannot be resolved (empty, or a `$VAR`) fails closed.
1859
+ """
1860
+
1861
+ values = [
1862
+ match.group(1)
1863
+ for word in words
1864
+ if (match := ADB_STATE_ASSIGNMENT.fullmatch(word))
1865
+ ]
1866
+ return resolve_mutation_paths(values, cwd)
1867
+
1868
+
1869
+ def adb_subcommand(args: list[str]) -> int | None:
1870
+ """Index of adb's subcommand past the reviewed global options, or None.
1871
+
1872
+ `-L` (a server socket, which may be a host filesystem path) and `-a` (the
1873
+ server listens on every interface) are not reviewed, so they fail closed
1874
+ along with any other unrecognised option. So does an attached value
1875
+ (`-sSERIAL`): adb's own reading of `-s<word>` depends on the word, and a
1876
+ prefix match here would take `-shell` for `-s hell`.
1877
+ """
1878
+
1879
+ index = 0
1880
+ while index < len(args):
1881
+ item = args[index]
1882
+ if item in ADB_FLAGS:
1883
+ index += 1
1884
+ continue
1885
+ if item in ADB_VALUE_OPTIONS:
1886
+ if index + 1 >= len(args):
1887
+ return None
1888
+ index += 2
1889
+ continue
1890
+ if item.startswith("-") and item not in {"--version", "--help"}:
1891
+ return None
1892
+ return index
1893
+ return None
1894
+
1895
+
1896
+ def logcat_is_read_only(args: list[str]) -> bool:
1897
+ """logcat asked to print, never to clear, resize, prune or write a file.
1898
+
1899
+ An allowlist over each short-option cluster: `-dc` must not pass because
1900
+ its first letter does. A value option ends its cluster, taking the rest of
1901
+ the word or the next one. Positionals are filterspecs (`ReactNativeJS:V`,
1902
+ `*:S`); adb escapes each before the device shell sees it, so they carry no
1903
+ command.
1904
+ """
1905
+
1906
+ index = 0
1907
+ while index < len(args):
1908
+ item = args[index]
1909
+ index += 1
1910
+ if item.startswith("--"):
1911
+ if not LOGCAT_LONG.fullmatch(item):
1912
+ return False
1913
+ continue
1914
+ if not item.startswith("-") or item == "-":
1915
+ continue
1916
+ for position, letter in enumerate(item[1:], start=1):
1917
+ if letter in LOGCAT_VALUE_FLAGS:
1918
+ if position == len(item) - 1:
1919
+ if index >= len(args):
1920
+ return False
1921
+ index += 1
1922
+ break
1923
+ if letter not in LOGCAT_FLAGS:
1924
+ return False
1925
+ return True
1926
+
1927
+
1928
+ def dumpsys_is_read_only(args: list[str]) -> bool:
1929
+ """dumpsys listing services, or dumping one, without a service command.
1930
+
1931
+ A service reads dumpsys's trailing words as its own arguments, and several
1932
+ act on them: `battery set level 5`, `battery unplug`, `deviceidle
1933
+ force-idle`, `gfxinfo <pkg> reset`, `batterystats --reset`. So past the
1934
+ service name only a package name and the plain dump options are admitted;
1935
+ any other word sends the command to the device-mutation tier.
1936
+ """
1937
+
1938
+ if not args or args == ["-l"]:
1939
+ return True
1940
+ service, rest = args[0], args[1:]
1941
+ return not service.startswith("-") and all(
1942
+ PACKAGE_NAME.fullmatch(item) or item in {"-a", "-c", "-h", "--checkin"}
1943
+ for item in rest
1944
+ )
1945
+
1946
+
1947
+ def device_command_is_read_only(line: str) -> bool:
1948
+ """A device shell command line that only reads.
1949
+
1950
+ Split here exactly as the device shell will split it, which is only sound
1951
+ because `DEVICE_LINE` leaves nothing in the line but words and spaces.
1952
+ """
1953
+
1954
+ if not DEVICE_LINE.fullmatch(line):
1955
+ return False
1956
+ words = line.split()
1957
+ if not words:
1958
+ return False
1959
+ name, args = words[0], words[1:]
1960
+ if name == "getprop":
1961
+ return len(args) <= 2
1962
+ if name == "pm":
1963
+ if args[:1] == ["path"]:
1964
+ return len(args) >= 2
1965
+ return args[:1] == ["list"] and len(args) >= 2 and args[1] in PM_LISTS
1966
+ if name == "dumpsys":
1967
+ return dumpsys_is_read_only(args)
1968
+ if name == "logcat":
1969
+ return logcat_is_read_only(args)
1970
+ return name in {"pidof", "ps"}
1971
+
1972
+
1973
+ def adb_is_read_only(args: list[str]) -> bool:
1974
+ """adb that changes neither this host's files nor the device.
1975
+
1976
+ `logcat` without `-d` streams until interrupted. That is a long command,
1977
+ not a mutation, so it is admitted; it is the caller's job not to wait on
1978
+ it.
1979
+ """
1980
+
1981
+ index = adb_subcommand(args)
1982
+ if index is None:
1983
+ return False
1984
+ subcommand, rest = args[index], args[index + 1 :]
1985
+ if subcommand in {"--version", "--help", "help", "version"}:
1986
+ return not rest
1987
+ if subcommand == "devices":
1988
+ return all(item == "-l" for item in rest)
1989
+ if subcommand in ADB_READ_SUBCOMMANDS or ADB_WAIT.fullmatch(subcommand):
1990
+ return not rest
1991
+ if subcommand == "logcat":
1992
+ return logcat_is_read_only(rest)
1993
+ if subcommand in {"shell", "exec-out"}:
1994
+ while subcommand == "shell" and rest[:1] and rest[0] in {"-n", "-T", "-t", "-x"}:
1995
+ rest = rest[1:]
1996
+ return device_command_is_read_only(" ".join(rest))
1997
+ return False
1998
+
1999
+
2000
+ def adb_mutation_paths(args: list[str], cwd: Path) -> list[Path] | None:
2001
+ """HOST paths an adb command writes; None when it is not reviewed.
2002
+
2003
+ Device paths are never resolved: `/sdcard/x` in `adb pull /sdcard/x` names
2004
+ the phone. The directory the command runs in is already counted by the
2005
+ caller, which covers every default destination (`pull` with one operand,
2006
+ a bare `bugreport`, `backup.ab`).
2007
+ """
2008
+
2009
+ index = adb_subcommand(args)
2010
+ if index is None:
2011
+ return None
2012
+ subcommand, rest = args[index], args[index + 1 :]
2013
+ if subcommand == "pull":
2014
+ # Only pull's own options, each with its arity: an unreviewed option
2015
+ # that took a value could hide the real destination among the
2016
+ # positionals, so it fails closed instead of being skipped.
2017
+ positionals: list[str] = []
2018
+ position = 0
2019
+ while position < len(rest):
2020
+ item = rest[position]
2021
+ if item == "-z":
2022
+ if position + 1 >= len(rest):
2023
+ return None
2024
+ position += 2
2025
+ continue
2026
+ if item in {"-a", "-Z"}:
2027
+ position += 1
2028
+ continue
2029
+ if item.startswith("-"):
2030
+ return None
2031
+ positionals.append(item)
2032
+ position += 1
2033
+ return resolve_mutation_paths(positionals[-1:] if len(positionals) >= 2 else [], cwd)
2034
+ if subcommand == "bugreport":
2035
+ if len(rest) > 1 or rest[:1] and rest[0].startswith("-"):
2036
+ return None
2037
+ return resolve_mutation_paths(rest, cwd)
2038
+ if subcommand == "backup":
2039
+ if any(item.startswith("-f") and item != "-f" for item in rest):
2040
+ return None
2041
+ if "-f" not in rest:
2042
+ return []
2043
+ position = rest.index("-f")
2044
+ if position + 1 >= len(rest):
2045
+ return None
2046
+ return resolve_mutation_paths([rest[position + 1]], cwd)
2047
+ if subcommand == "keygen":
2048
+ if len(rest) != 1:
2049
+ return None
2050
+ return resolve_mutation_paths([rest[0], f"{rest[0]}.pub"], cwd)
2051
+ if subcommand in ADB_DEVICE_SUBCOMMANDS or ADB_WAIT.fullmatch(subcommand):
2052
+ return []
2053
+ return None
2054
+
2055
+
2056
+ def segment_is_read_only(words: list[str]) -> bool:
2057
+ if not words:
2058
+ return True
2059
+ name = os.path.basename(words[0])
2060
+ if words[0] != name:
2061
+ # A path-qualified executable may be a repository-controlled shim. It
2062
+ # cannot inherit any trusted bare command's read-only behavior.
2063
+ return False
2064
+ if has_dynamic_markers(words[0]) and words[0] not in {"[", "[["}:
2065
+ # `$cmd args` runs whatever the expansion names. A lone `[` or `[[`
2066
+ # opens no glob: the test builtin and keyword.
2067
+ return False
2068
+ if name in READ_COMMANDS:
2069
+ # No option and no operand of these writes, so no expansion can.
2070
+ return True
2071
+ if name not in OWN_EXPANSION_RULES and not expansions_are_inert(name, words[1:]):
2072
+ return False
2073
+ if name in {"awk", "gawk", "mawk", "nawk"}:
2074
+ return awk_is_read_only(words[1:])
2075
+ if name == "sort":
2076
+ return sort_is_read_only(words[1:])
2077
+ if name == "uniq":
2078
+ return uniq_is_read_only(words[1:])
2079
+ if name == "xargs":
2080
+ return xargs_is_read_only(words[1:])
2081
+ if name == "tee":
2082
+ return tee_is_read_only(words[1:])
2083
+ if name == "gh":
2084
+ return gh_is_read_only(words[1:])
2085
+ if name == "file":
2086
+ return file_is_read_only(words[1:])
2087
+ if name == "printf":
2088
+ return printf_is_read_only(words[1:])
2089
+ if name == "cd":
2090
+ return (len(words) == 2 and words[1] != "--") or (
2091
+ len(words) == 3 and words[1] == "--"
2092
+ )
2093
+ if name == "git":
2094
+ return git_is_read_only(words)
2095
+ if name == "env":
2096
+ # Bare `env` prints the environment, as `printenv` does. With any
2097
+ # operand it is a wrapper that runs a program, which stays unproven.
2098
+ return len(words) == 1
2099
+ if name == "find":
2100
+ write_actions = {
2101
+ "-delete",
2102
+ "-exec",
2103
+ "-execdir",
2104
+ "-ok",
2105
+ "-okdir",
2106
+ "-fls",
2107
+ "-fprint",
2108
+ "-fprint0",
2109
+ "-fprintf",
2110
+ }
2111
+ return not any(word in write_actions for word in words[1:])
2112
+ if name == "rg":
2113
+ executable_options = {"--pre", "--hostname-bin"}
2114
+ return not any(
2115
+ word in executable_options
2116
+ or any(word.startswith(f"{option}=") for option in executable_options)
2117
+ for word in words[1:]
2118
+ )
2119
+ if name == "sed":
2120
+ return sed_is_read_only(words[1:])
2121
+ if name == "date":
2122
+ return date_is_read_only(words[1:])
2123
+ if name in {"node", "nodejs"}:
2124
+ return node_is_read_only(words[1:])
2125
+ if name == "adb":
2126
+ return adb_is_read_only(words[1:])
2127
+ if name in SHELL_INTERPRETERS:
2128
+ return shell_interpreter_is_read_only(words[1:])
2129
+ if name == "for":
2130
+ return for_header_is_read_only(words)
2131
+ if name == "read":
2132
+ return read_is_read_only(words[1:])
2133
+ return False
2134
+
2135
+
2136
+ # zsh's own lowercase parameters (`print -l ${(k)parameters}` with zsh/parameter
2137
+ # and zsh/mapfile loaded). Several steer what runs or what is written: `path`
2138
+ # and `commands` command lookup, `cdpath` where cd lands, `functions` and
2139
+ # `aliases` what a word means, `mapfile` writes files.
2140
+ SHELL_SPECIAL_LOWERCASE = frozenset(
2141
+ "_ aliases argv builtins cdpath commands dirstack dis_aliases dis_builtins "
2142
+ "dis_functions dis_functions_source dis_galiases dis_patchars dis_reswords "
2143
+ "dis_saliases fignore fpath funcfiletrace funcsourcetrace funcstack functions "
2144
+ "functions_source functrace galiases histchars history historywords jobdirs "
2145
+ "jobstates jobtexts keymaps mailpath manpath mapfile module_path modules "
2146
+ "nameddirs options parameters patchars path pipestatus prompt psvar reswords "
2147
+ "saliases signals status termcap terminfo userdirs usergroups watch widgets "
2148
+ "zsh_eval_context zsh_scheduled_events".split()
2149
+ )
2150
+
2151
+
2152
+ def assignable_name(name: str) -> bool:
2153
+ """A variable a loop or `read` may set without changing anything but itself.
2154
+
2155
+ Lowercase only: bash's and zsh's lookup and repository variables (PATH,
2156
+ CDPATH, BASH_ENV, GIT_DIR) are uppercase. Not one of zsh's lowercase
2157
+ specials, and not exported here: an exported name (`npm_config_prefix`)
2158
+ reaches every child process. This hook cannot see what the Bash tool's
2159
+ shell exports, so `mutation_directories` also refuses any write on a
2160
+ line that sets one; only all-read lines rely on this check alone.
2161
+ """
2162
+
2163
+ return (
2164
+ re.fullmatch(r"[a-z_][a-z0-9_]*", name) is not None
2165
+ and name not in SHELL_SPECIAL_LOWERCASE
2166
+ and name not in os.environ
2167
+ and not name.startswith(("npm_", "pnpm_"))
2168
+ )
2169
+
2170
+
2171
+ def for_header_is_read_only(words: list[str]) -> bool:
2172
+ """`for NAME in WORDS` only sets a shell variable; the body is its own segments.
2173
+
2174
+ The listed words are expanded, never run (backticks are refused by the
2175
+ scanner, and a `$(...)` it admits is read-only), so the header writes
2176
+ nothing. Only
2177
+ the bare unquoted keyword is syntax: `"for"` runs a program named for.
2178
+ The arithmetic form `for ((...))` stays unproven. The name must be
2179
+ `assignable_name`.
2180
+ """
2181
+
2182
+ keyword = words[0]
2183
+ if not isinstance(keyword, ShellWord) or keyword.quote_start is not None:
2184
+ return False
2185
+ if len(words) < 2 or not assignable_name(words[1]):
2186
+ return False
2187
+ return len(words) == 2 or words[2] == "in"
2188
+
2189
+
2190
+ def read_is_read_only(args: list[str]) -> bool:
2191
+ """The `read` builtin reads stdin into `assignable_name` variables.
2192
+
2193
+ Only `-r` and `-s`, the flags bash and zsh read alike and that take no
2194
+ value. The rest differ: zsh's `-p` reads the coprocess, so the next word
2195
+ is a name and not bash's prompt; its `-e` echoes instead of assigning;
2196
+ `-a` is bash's array and not a zsh option. With no name it sets REPLY.
2197
+ zsh's `read name?prompt` form is not an identifier, so it stays unproven.
2198
+ """
2199
+
2200
+ index = 0
2201
+ while index < len(args) and args[index].startswith("-"):
2202
+ option = args[index]
2203
+ if option == "--":
2204
+ index += 1
2205
+ break
2206
+ if re.fullmatch(r"-[rs]+", option) is None:
2207
+ return False
2208
+ index += 1
2209
+ return all(assignable_name(name) for name in args[index:])
2210
+
2211
+
2212
+ def long_option_kinds(word: str, table: dict[str, str]) -> tuple[str, str | None] | None:
2213
+ """The kind of a GNU long option, its abbreviations included, and its value.
2214
+
2215
+ GNU getopt accepts any unambiguous prefix (`--out` is `--output`), so a
2216
+ prefix of a writing option is refused even when it is ambiguous. None when
2217
+ the word names no option, more than one, or one that writes.
2218
+ """
2219
+
2220
+ name, has_value, value = word[2:].partition("=")
2221
+ if not name:
2222
+ return None
2223
+ kinds = {kind for option, kind in table.items() if option.startswith(name)}
2224
+ exact = table.get(name)
2225
+ if "writes" in kinds or not kinds:
2226
+ return None
2227
+ if exact is None and len(kinds) > 1:
2228
+ return None
2229
+ kind = exact or next(iter(kinds))
2230
+ return kind, value if has_value else None
2231
+
2232
+
2233
+ def option_words_are_read_only(
2234
+ args: list[str],
2235
+ *,
2236
+ flags: str,
2237
+ values: str,
2238
+ writes: str,
2239
+ long_options: dict[str, str],
2240
+ ) -> tuple[list[str], int] | None:
2241
+ """Operands of a getopt command when no option it is given writes.
2242
+
2243
+ ``flags`` take no value, ``values`` take one (attached or the next word),
2244
+ ``writes`` are refused. Long options map to `flag`, `value`, `optional`
2245
+ or `writes`. Returns the operands, and how many came after `--`.
2246
+ """
2247
+
2248
+ operands: list[str] = []
2249
+ index = 0
2250
+ while index < len(args):
2251
+ item = args[index]
2252
+ index += 1
2253
+ if item == "--":
2254
+ return operands + list(args[index:]), len(args) - index
2255
+ if item.startswith("--"):
2256
+ parsed = long_option_kinds(item, long_options)
2257
+ if parsed is None:
2258
+ return None
2259
+ kind, value = parsed
2260
+ if kind == "value" and value is None:
2261
+ if index >= len(args):
2262
+ return None
2263
+ index += 1
2264
+ continue
2265
+ if item.startswith("-") and item != "-":
2266
+ cluster = item[1:]
2267
+ for position, flag in enumerate(cluster):
2268
+ if flag in writes:
2269
+ return None
2270
+ if flag in values:
2271
+ # getopt takes the rest of the word as the value, but a
2272
+ # value that spells a writing option is refused anyway:
2273
+ # no reading here rests on one getopt's choice.
2274
+ if any(letter in writes for letter in cluster[position + 1:]):
2275
+ return None
2276
+ if position == len(cluster) - 1:
2277
+ if index >= len(args):
2278
+ return None
2279
+ index += 1
2280
+ break
2281
+ if flag not in flags:
2282
+ return None
2283
+ continue
2284
+ operands.append(item)
2285
+ return operands, 0
2286
+
2287
+
2288
+ # "writes" refuses the option. `--files0-from` is among them: the operands it
2289
+ # reads from a file are words this parser never sees.
2290
+ SORT_LONG_OPTIONS = {
2291
+ "batch-size": "value", "buffer-size": "value", "check": "optional",
2292
+ "compress-program": "writes", "debug": "flag", "dictionary-order": "flag",
2293
+ "field-separator": "value", "files0-from": "writes",
2294
+ "general-numeric-sort": "flag", "help": "flag", "human-numeric-sort": "flag",
2295
+ "ignore-case": "flag", "ignore-leading-blanks": "flag",
2296
+ "ignore-nonprinting": "flag", "key": "value", "merge": "flag",
2297
+ "month-sort": "flag", "numeric-sort": "flag", "output": "writes",
2298
+ "parallel": "value", "random-sort": "flag", "random-source": "value",
2299
+ "reverse": "flag", "sort": "value", "stable": "flag",
2300
+ "temporary-directory": "writes", "unique": "flag", "version": "flag",
2301
+ "version-sort": "flag", "zero-terminated": "flag",
2302
+ }
2303
+
2304
+
2305
+ def sort_is_read_only(args: list[str]) -> bool:
2306
+ """`sort` prints unless `-o` names an output, `-T` a directory it writes
2307
+ temporary files in, or `--compress-program` a program it runs. A short
2308
+ cluster is read as getopt reads it: in `-to` the `o` is `-t`'s value."""
2309
+
2310
+ return (
2311
+ option_words_are_read_only(
2312
+ args, flags="bcCdfghiMmnRrsuVz", values="kSt", writes="oT",
2313
+ long_options=SORT_LONG_OPTIONS,
2314
+ )
2315
+ is not None
2316
+ )
2317
+
2318
+
2319
+ UNIQ_LONG_OPTIONS = {
2320
+ "all-repeated": "optional", "check-chars": "value", "count": "flag",
2321
+ "group": "optional", "help": "flag", "ignore-case": "flag",
2322
+ "repeated": "flag", "skip-chars": "value", "skip-fields": "value",
2323
+ "unique": "flag", "version": "flag", "zero-terminated": "flag",
2324
+ }
2325
+
2326
+
2327
+ def uniq_is_read_only(args: list[str]) -> bool:
2328
+ """`uniq [input [output]]`: a second operand is the file it writes."""
2329
+
2330
+ parsed = option_words_are_read_only(
2331
+ args, flags="cdDiuz", values="fsw", writes="", long_options=UNIQ_LONG_OPTIONS,
2332
+ )
2333
+ return parsed is not None and len(parsed[0]) <= 1
2334
+
2335
+
2336
+ XARGS_LONG_OPTIONS = {
2337
+ "arg-file": "value", "delimiter": "value", "eof": "optional", "exit": "flag",
2338
+ "help": "flag", "interactive": "flag", "max-args": "value",
2339
+ "max-chars": "value", "max-lines": "optional", "max-procs": "value",
2340
+ "no-run-if-empty": "flag", "null": "flag", "open-tty": "flag",
2341
+ "process-slot-var": "writes", "replace": "optional", "show-limits": "flag",
2342
+ "verbose": "flag", "version": "flag",
2343
+ }
2344
+
2345
+
2346
+ def xargs_is_read_only(args: list[str]) -> bool:
2347
+ """`xargs` runs one command with words from its input appended.
2348
+
2349
+ Those words are unknown, so the command must be read-only whatever its
2350
+ words are: a bare member of READ_COMMANDS. With no command it runs echo.
2351
+ The value-taking options are read as GNU and BSD take them; `-i` and `-l`
2352
+ take theirs attached only.
2353
+ """
2354
+
2355
+ def literal(word: str) -> bool:
2356
+ # `-I {}`: an empty brace pair is no brace expansion in bash or zsh.
2357
+ return not has_dynamic_markers(word.replace("{}", ""))
2358
+
2359
+ index = 0
2360
+ while index < len(args):
2361
+ item = args[index]
2362
+ if not literal(item):
2363
+ return False
2364
+ if item == "--":
2365
+ index += 1
2366
+ break
2367
+ if item.startswith("--"):
2368
+ parsed = long_option_kinds(item, XARGS_LONG_OPTIONS)
2369
+ if parsed is None:
2370
+ return False
2371
+ kind, value = parsed
2372
+ index += 1
2373
+ if kind == "value" and value is None:
2374
+ if index >= len(args) or not literal(args[index]):
2375
+ return False
2376
+ index += 1
2377
+ continue
2378
+ if not item.startswith("-") or item == "-":
2379
+ break
2380
+ cluster = item[1:]
2381
+ index += 1
2382
+ for position, flag in enumerate(cluster):
2383
+ if flag in "nLPsdEIJRSa":
2384
+ if position == len(cluster) - 1:
2385
+ if index >= len(args) or not literal(args[index]):
2386
+ return False
2387
+ index += 1
2388
+ break
2389
+ if flag in "il":
2390
+ break
2391
+ if flag not in "0rtpxo":
2392
+ return False
2393
+ if index == len(args):
2394
+ return True
2395
+ command = args[index]
2396
+ return (
2397
+ os.path.basename(command) == command
2398
+ and not has_dynamic_markers(command)
2399
+ and command in READ_COMMANDS
2400
+ )
2401
+
2402
+
2403
+ def tee_is_read_only(args: list[str]) -> bool:
2404
+ """`tee` with /dev/null as every file only copies stdin to stdout."""
2405
+
2406
+ files: list[str] = []
2407
+ options = True
2408
+ for item in args:
2409
+ if options and item == "--":
2410
+ options = False
2411
+ continue
2412
+ if options and (
2413
+ item in {"--append", "--ignore-interrupts", "--output-error"}
2414
+ or item.startswith("--output-error=")
2415
+ or re.fullmatch(r"-[aip]+", item)
2416
+ ):
2417
+ continue
2418
+ if options and item.startswith("-"):
2419
+ return False
2420
+ files.append(item)
2421
+ return all(item == os.devnull and expansion_risk(item) is None for item in files)
2422
+
2423
+
2424
+ def file_is_read_only(args: list[str]) -> bool:
2425
+ """`file` reports types; `-C` compiles a magic file, which it writes."""
2426
+
2427
+ for item in args:
2428
+ if item == "--":
2429
+ return True
2430
+ if item.startswith("--") and len(item) > 2 and "--compile".startswith(item.partition("=")[0]):
2431
+ return False
2432
+ if item.startswith("-") and not item.startswith("--") and "C" in item[1:]:
2433
+ return False
2434
+ return True
2435
+
2436
+
2437
+ def printf_is_read_only(args: list[str]) -> bool:
2438
+ """`printf` prints, unless `-v name` assigns the result to a variable.
2439
+
2440
+ `-v` is its only option, so any first word that is an option (a cluster
2441
+ such as `-xv` included) is refused; `--` before the format is fine.
2442
+ """
2443
+
2444
+ if not args:
2445
+ return True
2446
+ first = args[0]
2447
+ return expansions_are_inert("printf", args[:1]) and not (
2448
+ first.startswith("-") and first not in {"-", "--"}
2449
+ )
2450
+
2451
+
2452
+ AWK_KEYWORDS_BEFORE_OPERAND = {
2453
+ "print", "printf", "return", "in", "if", "while", "for", "do", "else",
2454
+ "delete", "case", "getline",
2455
+ }
2456
+
2457
+
2458
+ def awk_is_read_only(args: list[str]) -> bool:
2459
+ """`awk` with a program that writes no file and runs no command.
2460
+
2461
+ `-F` and `-v` are the only options: `-f` is unseen code, and gawk's `-i`,
2462
+ `-l`, `-e` and long options load or add code (`-i inplace` rewrites its
2463
+ operands). Operands are input files or `name=value` assignments.
2464
+ """
2465
+
2466
+ index = 0
2467
+ while index < len(args):
2468
+ item = args[index]
2469
+ if expansion_risk(item) == "split":
2470
+ # It may become several words, `-f file` among them.
2471
+ return False
2472
+ if item == "--":
2473
+ # Not every awk reads `--` as the end of options; where the
2474
+ # program sits would then differ by implementation.
2475
+ return False
2476
+ if item in {"-F", "-v"}:
2477
+ if index + 1 < len(args) and expansion_risk(args[index + 1]) == "split":
2478
+ return False
2479
+ index += 2
2480
+ continue
2481
+ if item.startswith(("-F", "-v")):
2482
+ index += 1
2483
+ continue
2484
+ if item.startswith("-") and item != "-":
2485
+ return False
2486
+ break
2487
+ if index >= len(args):
2488
+ return False
2489
+ program = args[index]
2490
+ return not has_dynamic_markers(program) and awk_program_is_read_only(program)
2491
+
2492
+
2493
+ def awk_program_is_read_only(program: str) -> bool:
2494
+ """Whether an awk program has no output redirection, pipe or `system`.
2495
+
2496
+ It is lexed as awk lexes it: strings, regular expressions (a `/` where an
2497
+ operand is expected starts one, as gawk decides) and comments are skipped.
2498
+ Within a `print` or `printf` statement any `>` is refused, the
2499
+ redirections `>` and `>>` and a parenthesised comparison alike; elsewhere
2500
+ `>` compares. Any `|` but `||` is a pipe or a coprocess; `@` loads code in
2501
+ gawk; anything that does not close (a string, a regex, a parenthesis) is
2502
+ refused rather than guessed at.
2503
+ """
2504
+
2505
+ index = 0
2506
+ length = len(program)
2507
+ operand = False
2508
+ in_print = False
2509
+ parens = 0
2510
+ braces = 0
2511
+ while index < length:
2512
+ character = program[index]
2513
+ if character == '"':
2514
+ index += 1
2515
+ while index < length and program[index] != '"':
2516
+ if program[index] == "\n":
2517
+ return False
2518
+ index += 2 if program[index] == "\\" else 1
2519
+ if index >= length:
2520
+ return False
2521
+ index += 1
2522
+ operand = True
2523
+ continue
2524
+ if character == "#":
2525
+ newline = program.find("\n", index)
2526
+ index = length if newline < 0 else newline
2527
+ continue
2528
+ if character in " \t\r":
2529
+ index += 1
2530
+ continue
2531
+ if character == "/" and not operand:
2532
+ index += 1
2533
+ while index < length and program[index] != "/":
2534
+ if program[index] == "\n":
2535
+ return False
2536
+ if program[index] == "[":
2537
+ close = program.find("]", index + 2)
2538
+ if close < 0:
2539
+ return False
2540
+ index = close + 1
2541
+ continue
2542
+ index += 2 if program[index] == "\\" else 1
2543
+ if index >= length:
2544
+ return False
2545
+ index += 1
2546
+ operand = True
2547
+ continue
2548
+ if character.isalpha() or character == "_":
2549
+ end = index
2550
+ while end < length and (program[end].isalnum() or program[end] == "_"):
2551
+ end += 1
2552
+ word = program[index:end]
2553
+ if word == "system":
2554
+ return False
2555
+ if word in {"print", "printf"}:
2556
+ in_print = True
2557
+ operand = word not in AWK_KEYWORDS_BEFORE_OPERAND
2558
+ index = end
2559
+ continue
2560
+ if character.isdigit() or (character == "." and program[index + 1 : index + 2].isdigit()):
2561
+ while index < length and (program[index].isalnum() or program[index] == "."):
2562
+ index += 1
2563
+ operand = True
2564
+ continue
2565
+ if character in "@`\\":
2566
+ return False
2567
+ if character == "|":
2568
+ if program[index + 1 : index + 2] != "|":
2569
+ return False
2570
+ index += 2
2571
+ operand = False
2572
+ continue
2573
+ if character == ">" and in_print:
2574
+ return False
2575
+ if character in "+-" and program[index + 1 : index + 2] == character:
2576
+ # `x++` keeps an operand before it; `++x` had none to keep.
2577
+ index += 2
2578
+ continue
2579
+ if character == "(":
2580
+ parens += 1
2581
+ elif character == ")":
2582
+ parens -= 1
2583
+ if parens < 0:
2584
+ return False
2585
+ elif character == "{":
2586
+ braces += 1
2587
+ in_print = False
2588
+ elif character == "}":
2589
+ braces -= 1
2590
+ if braces < 0:
2591
+ return False
2592
+ in_print = False
2593
+ elif character in ";\n":
2594
+ in_print = False
2595
+ operand = character in ")]"
2596
+ index += 1
2597
+ return parens == 0 and braces == 0
2598
+
2599
+
2600
+ # `gh` subcommands that only read GitHub. An extension cannot shadow a built-in
2601
+ # command, so the names mean these commands. `--web` opens a browser and is
2602
+ # refused with them. gh's update check writes `~/.local/state/gh/state.yml`
2603
+ # only on a terminal: measured 2026-11-08, `gh issue view` from the Bash tool
2604
+ # left its mtime unchanged.
2605
+ GH_READS = {
2606
+ "issue": {"list", "status", "view"},
2607
+ "label": {"list"},
2608
+ "pr": {"checks", "diff", "list", "status", "view"},
2609
+ "release": {"list", "view"},
2610
+ "repo": {"view"},
2611
+ "run": {"list", "view"},
2612
+ "search": {"code", "commits", "issues", "prs", "repos"},
2613
+ "secret": {"list"},
2614
+ "variable": {"list"},
2615
+ "workflow": {"list", "view"},
2616
+ }
2617
+
2618
+
2619
+ def gh_is_read_only(args: list[str]) -> bool:
2620
+ """`gh` asked only to read: a read subcommand, or `gh api` with GET."""
2621
+
2622
+ if args in (["--version"], ["auth", "status"], ["status"]):
2623
+ return True
2624
+ if any(item in {"-w", "--web"} for item in args):
2625
+ return False
2626
+ if len(args) >= 2 and args[1] in GH_READS.get(args[0], set()):
2627
+ return True
2628
+ if args[:1] == ["api"]:
2629
+ return gh_api_is_read_only(args[1:])
2630
+ return False
2631
+
2632
+
2633
+ def gh_api_is_read_only(args: list[str]) -> bool:
2634
+ """`gh api` with no method but GET, and no field (which makes it POST).
2635
+
2636
+ `--cache` writes a cache directory, `--input` sends a body. A GraphQL
2637
+ request is a POST whatever it does, so `graphql` is read only for a
2638
+ literal query with no `mutation` in it and literal variables.
2639
+ """
2640
+
2641
+ values = {
2642
+ "-H", "--header", "--hostname", "-p", "--preview", "-q", "--jq",
2643
+ "-t", "--template",
2644
+ }
2645
+ flags = {"--paginate", "--slurp", "-i", "--include", "--silent", "--verbose"}
2646
+ fields: list[str] = []
2647
+ endpoint: str | None = None
2648
+ index = 0
2649
+ while index < len(args):
2650
+ item = args[index]
2651
+ index += 1
2652
+ if item in values:
2653
+ # A value option with nothing after it would take the next word
2654
+ # in another reading: refuse rather than guess the endpoint.
2655
+ if index >= len(args):
2656
+ return False
2657
+ index += 1
2658
+ continue
2659
+ if item.startswith("--") and "=" in item and item.split("=", 1)[0] in values:
2660
+ continue
2661
+ if item in flags:
2662
+ continue
2663
+ if item in {"-X", "--method"} or item.startswith("--method="):
2664
+ method = item.split("=", 1)[1] if "=" in item else (
2665
+ args[index] if index < len(args) else ""
2666
+ )
2667
+ index += 0 if "=" in item else 1
2668
+ if method.upper() != "GET":
2669
+ return False
2670
+ continue
2671
+ if item in {"-f", "-F", "--field", "--raw-field"}:
2672
+ if index >= len(args):
2673
+ return False
2674
+ fields.append(args[index])
2675
+ index += 1
2676
+ continue
2677
+ if item.startswith("-"):
2678
+ return False
2679
+ if endpoint is not None:
2680
+ return False
2681
+ endpoint = item
2682
+ if endpoint is None:
2683
+ return False
2684
+ if not fields:
2685
+ return True
2686
+ if endpoint != "graphql":
2687
+ return False
2688
+ query = [field for field in fields if field.startswith("query=")]
2689
+ if len(query) != 1 or has_dynamic_markers(query[0]):
2690
+ return False
2691
+ text = query[0].split("=", 1)[1].lstrip()
2692
+ return (
2693
+ (text.startswith("{") or text.startswith("query"))
2694
+ and "mutation" not in text
2695
+ and "subscription" not in text
2696
+ and all(
2697
+ re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", field, re.DOTALL) is not None
2698
+ and not has_dynamic_markers(field)
2699
+ for field in fields
2700
+ )
2701
+ )
2702
+
2703
+
2704
+ def shell_is_read_only(command: str, depth: int = 0) -> bool:
2705
+ """Whether no segment of ``command`` writes anything.
2706
+
2707
+ Leading control words (`do`, `then`, `done`, ...) are syntax and are
2708
+ dropped first, so a loop or `if` whose every command reads is read-only
2709
+ from any checkout. Loop variables bound to literal words are read with
2710
+ each of their values (``bound_variants``).
2711
+ """
2712
+
2713
+ segments = split_commands(command, depth)
2714
+ if segments is None:
2715
+ return False
2716
+ bindings: dict[str, tuple[str, ...]] = {}
2717
+ for raw in segments:
2718
+ words = without_control_words(raw)
2719
+ if words is None:
2720
+ return False
2721
+ if not segment_is_read_only_bound(words, bindings):
2722
+ return False
2723
+ update_bindings(words, bindings)
2724
+ return True
2725
+
2726
+
2727
+ def without_control_words(words: list[str]) -> list[str] | None:
2728
+ """``words`` past its leading unquoted control words and `!`."""
2729
+
2730
+ index = 0
2731
+ while index < len(words):
2732
+ word = words[index]
2733
+ if word not in SHELL_CONTROL_WORDS and word != "!":
2734
+ break
2735
+ if not isinstance(word, ShellWord) or word.quote_start is not None:
2736
+ # `"do"` runs a program named do.
2737
+ return None
2738
+ index += 1
2739
+ return words[index:]
2740
+
2741
+
2742
+ # A loop value substituted for `$name` must mean the same quoted or not: no
2743
+ # blank, glob, quote, tilde or expansion character.
2744
+ LITERAL_VALUE = re.compile(r"[A-Za-z0-9._/,:+=@%^-]+")
2745
+ VARIANT_LIMIT = 64
2746
+
2747
+
2748
+ def update_bindings(words: list[str], bindings: dict[str, tuple[str, ...]]) -> None:
2749
+ """Record what a `for` header binds; forget whatever else a segment sets."""
2750
+
2751
+ if not words:
2752
+ return
2753
+ if words[0] == "for" and for_header_is_read_only(words):
2754
+ values = tuple(words[3:]) if len(words) > 3 else ()
2755
+ if values and all(
2756
+ not has_dynamic_markers(value) and LITERAL_VALUE.fullmatch(value)
2757
+ for value in values
2758
+ ):
2759
+ bindings[words[1]] = values
2760
+ else:
2761
+ bindings.pop(words[1], None)
2762
+ return
2763
+ if words[0] == "read":
2764
+ bindings.pop("REPLY", None)
2765
+ for word in words:
2766
+ assignment = re.match(r"([A-Za-z_][A-Za-z0-9_]*)=", word)
2767
+ if assignment:
2768
+ bindings.pop(assignment.group(1), None)
2769
+ elif word in bindings:
2770
+ # `read name`, `unset name`, `export name`, ...: set elsewhere.
2771
+ bindings.pop(word, None)
2772
+
2773
+
2774
+ def bound_variants(
2775
+ words: list[str], bindings: dict[str, tuple[str, ...]]
2776
+ ) -> list[list[str]] | None:
2777
+ """``words`` once per combination of the bound loop values they name.
2778
+
2779
+ Only `$name` and `${name}` with an unprotected `$` are replaced, and only
2780
+ for names bound to literal words; anything else stays an expansion. None
2781
+ when the combinations exceed ``VARIANT_LIMIT``.
2782
+ """
2783
+
2784
+ if not bindings:
2785
+ return [words]
2786
+ reference = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
2787
+ used: list[str] = []
2788
+ for word in words:
2789
+ for match in reference.finditer(word):
2790
+ name = match.group(1) or match.group(2)
2791
+ if name in bindings and name not in used and not (
2792
+ isinstance(word, ShellWord) and word.protected[match.start()]
2793
+ ):
2794
+ used.append(name)
2795
+ if not used:
2796
+ return [words]
2797
+ combinations = 1
2798
+ for name in used:
2799
+ combinations *= len(bindings[name])
2800
+ if combinations > VARIANT_LIMIT:
2801
+ return None
2802
+
2803
+ def substitute(word: str, values: dict[str, str]) -> str:
2804
+ if not isinstance(word, ShellWord):
2805
+ return word
2806
+ text: list[str] = []
2807
+ guard: list[bool] = []
2808
+ position = 0
2809
+ for match in reference.finditer(word):
2810
+ name = match.group(1) or match.group(2)
2811
+ if name not in values or word.protected[match.start()]:
2812
+ continue
2813
+ text.extend(word[position : match.start()])
2814
+ guard.extend(word.protected[position : match.start()])
2815
+ text.extend(values[name])
2816
+ guard.extend([True] * len(values[name]))
2817
+ position = match.end()
2818
+ text.extend(word[position:])
2819
+ guard.extend(word.protected[position:])
2820
+ return ShellWord("".join(text), tuple(guard), quote_start=word.quote_start)
2821
+
2822
+ choices: list[dict[str, str]] = [{}]
2823
+ for name in used:
2824
+ choices = [
2825
+ {**choice, name: value} for choice in choices for value in bindings[name]
2826
+ ]
2827
+ return [[substitute(word, choice) for word in words] for choice in choices]
2828
+
2829
+
2830
+ def segment_is_read_only_bound(
2831
+ words: list[str], bindings: dict[str, tuple[str, ...]]
2832
+ ) -> bool:
2833
+ variants = bound_variants(words, bindings)
2834
+ return variants is not None and all(
2835
+ segment_is_read_only(variant) for variant in variants
2836
+ )
2837
+
2838
+
2839
+ def expansion_risk(word: str) -> str | None:
2840
+ """How an expansion in ``word`` may change it: None, quoted, or split.
2841
+
2842
+ `quoted`: every unprotected character is a `$` (`"$x"`, `"$(cmd)"`), so
2843
+ the word stays one word, though its value is unknown. `split`: an unquoted
2844
+ expansion or glob may become several words, each of them an option.
2845
+ """
2846
+
2847
+ if not has_dynamic_markers(word):
2848
+ return None
2849
+ if not isinstance(word, ShellWord):
2850
+ return "split"
2851
+ if all(guard or character == "$" for character, guard in zip(word, word.protected)):
2852
+ return "quoted"
2853
+ return "split"
2854
+
2855
+
2856
+ # Parsers that judge options: an unknown word could be any of them, so these
2857
+ # take no expansion at all.
2858
+ NO_EXPANSION_COMMANDS = {"adb", "date", "env", "node", "nodejs"} | SHELL_INTERPRETERS
2859
+ # After a literal `--` these read every operand as a file or pathspec.
2860
+ OPERANDS_AFTER_DASHES = {"git", "rg", "sed", "sort"}
2861
+ # Parsers that know which of their words can be options and judge expansions
2862
+ # there themselves: `cd` writes nothing whatever its operand, `printf` takes
2863
+ # an option only first, awk none after its program, xargs none after its
2864
+ # command.
2865
+ OWN_EXPANSION_RULES = {
2866
+ "awk", "cd", "for", "gawk", "mawk", "nawk", "printf", "read", "xargs",
2867
+ }
2868
+ # Mutators whose operand parsers pass over a word they do not know, so an
2869
+ # expansion there could be the option that writes (`find /x $X`, X=-delete;
2870
+ # `cp "$x" a b`, x=-t<elsewhere>). The others refuse an expanded file operand
2871
+ # by themselves, and a reviewed runner judges its own expansions
2872
+ # (`generic_mutation_paths`).
2873
+ OPTION_BLIND_MUTATORS = {"cp", "find", "git", "install", "ln", "mv", "sed"}
2874
+
2875
+
2876
+ def glob_keeps_its_prefix(word: str) -> bool:
2877
+ """A glob or brace pattern with no variable, after a literal first character.
2878
+
2879
+ Each word it expands to begins with that character, and it is not `-`, so
2880
+ none is an option. Splitting comes only from `$` expansions, refused here.
2881
+ """
2882
+
2883
+ if not isinstance(word, ShellWord) or not word:
2884
+ return False
2885
+ if any(
2886
+ character in "$`" and not guard for character, guard in zip(word, word.protected)
2887
+ ):
2888
+ return False
2889
+ first = word[0]
2890
+ return first != "-" and (word.protected[0] or first not in "$`*?[{}~")
2891
+
2892
+
2893
+ def expansions_are_inert(name: str, args: list[str]) -> bool:
2894
+ """Whether no expansion in ``args`` can become an option of ``name``.
2895
+
2896
+ A parser judges the words it sees: `X=--output=/elsewhere; git log $X`
2897
+ reads as `git log` with a revision, yet git writes the file. So an
2898
+ expansion is admitted only where it cannot start a new option: inside
2899
+ double quotes and after a literal first character (`"HEAD..$x"`), or after
2900
+ a literal `--` for commands that read every later word as an operand.
2901
+ """
2902
+
2903
+ if name in NO_EXPANSION_COMMANDS:
2904
+ return not any(expansion_risk(word) for word in args)
2905
+ operands = False
2906
+ for word in args:
2907
+ if word == "--" and expansion_risk(word) is None and name in OPERANDS_AFTER_DASHES:
2908
+ operands = True
2909
+ continue
2910
+ risk = expansion_risk(word)
2911
+ if risk is None or operands:
2912
+ continue
2913
+ if risk == "split" and glob_keeps_its_prefix(word):
2914
+ # `codex/*`: every word it expands to starts `codex/`.
2915
+ continue
2916
+ if risk == "split":
2917
+ return False
2918
+ if not isinstance(word, ShellWord) or not word or (
2919
+ word[0] == "$" and not word.protected[0]
2920
+ ):
2921
+ return False
2922
+ return True
2923
+
2924
+
2925
+ def command_positionals(
2926
+ args: list[str],
2927
+ *,
2928
+ value_options: set[str] | None = None,
2929
+ target_options: set[str] | None = None,
2930
+ ) -> tuple[list[str], str | None] | None:
2931
+ """Return positional arguments and an optional explicit target directory."""
2932
+
2933
+ values = value_options or set()
2934
+ targets = target_options or set()
2935
+ positionals: list[str] = []
2936
+ target_directory: str | None = None
2937
+ index = 0
2938
+ options = True
2939
+ while index < len(args):
2940
+ item = args[index]
2941
+ if options and item == "--":
2942
+ options = False
2943
+ index += 1
2944
+ continue
2945
+ matching_target = next(
2946
+ (
2947
+ option
2948
+ for option in targets
2949
+ if item == option
2950
+ or item.startswith(f"{option}=")
2951
+ or (
2952
+ option.startswith("-")
2953
+ and not option.startswith("--")
2954
+ and item.startswith(option)
2955
+ and item != option
2956
+ )
2957
+ ),
2958
+ None,
2959
+ )
2960
+ if options and matching_target:
2961
+ if item == matching_target:
2962
+ if index + 1 >= len(args):
2963
+ return None
2964
+ target_directory = args[index + 1]
2965
+ index += 2
2966
+ elif item.startswith(f"{matching_target}="):
2967
+ target_directory = shell_option_value(item)
2968
+ index += 1
2969
+ else:
2970
+ target_directory = shell_slice(item, len(matching_target))
2971
+ index += 1
2972
+ continue
2973
+ matching_value = next(
2974
+ (
2975
+ option
2976
+ for option in values
2977
+ if item == option
2978
+ or item.startswith(f"{option}=")
2979
+ or (
2980
+ option.startswith("-")
2981
+ and not option.startswith("--")
2982
+ and item.startswith(option)
2983
+ and item != option
2984
+ )
2985
+ ),
2986
+ None,
2987
+ )
2988
+ if options and matching_value:
2989
+ if item == matching_value:
2990
+ if index + 1 >= len(args):
2991
+ return None
2992
+ index += 2
2993
+ else:
2994
+ index += 1
2995
+ continue
2996
+ if options and item.startswith("-"):
2997
+ index += 1
2998
+ continue
2999
+ positionals.append(item)
3000
+ index += 1
3001
+ return positionals, target_directory
3002
+
3003
+
3004
+ def resolve_mutation_paths(raw_values: list[str], cwd: Path) -> list[Path] | None:
3005
+ targets: list[Path] = []
3006
+ for raw_value in raw_values:
3007
+ target = resolve_shell_path(raw_value, cwd)
3008
+ if target is None:
3009
+ return None
3010
+ targets.append(target)
3011
+ return targets
3012
+
3013
+
3014
+ def bun_run_is_package_script(words: list[str], cwd: Path) -> bool:
3015
+ """Whether ``bun run`` names a script in the current package manifest."""
3016
+
3017
+ if words[:2] != ["bun", "run"] or len(words) < 3:
3018
+ return False
3019
+ script = words[2]
3020
+ if (
3021
+ script == "-"
3022
+ or script.startswith(("-", "."))
3023
+ or "/" in script
3024
+ or "\\" in script
3025
+ ):
3026
+ # Bun also accepts source files, stdin, project binaries, and system
3027
+ # commands. Only an unambiguous package.json script gets the reviewed
3028
+ # generic-runner contract.
3029
+ return False
3030
+ manifest_path = cwd / "package.json"
3031
+ try:
3032
+ if manifest_path.is_symlink():
3033
+ return False
3034
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
3035
+ except (OSError, UnicodeError, json.JSONDecodeError):
3036
+ return False
3037
+ scripts = manifest.get("scripts") if isinstance(manifest, dict) else None
3038
+ return isinstance(scripts, dict) and isinstance(scripts.get(script), str)
3039
+
3040
+
3041
+ def generic_mutation_paths(
3042
+ name: str,
3043
+ args: list[str],
3044
+ cwd: Path,
3045
+ *,
3046
+ read_namespaces: tuple[Path, ...] = (),
3047
+ ) -> list[Path] | None:
3048
+ """Conservatively resolve path-like operands for an unknown mutator.
3049
+
3050
+ Every path-shaped operand counts as a destination, because argv semantics
3051
+ are command-specific and this parser cannot tell an input from an output.
3052
+ That is deliberately over-broad, and `read_namespaces` is the one exception:
3053
+ directories a PROVIDER has derived (never accepted from the event) and
3054
+ declared to hold no repository state, so an operand landing inside one is
3055
+ not read as a declared write.
3056
+
3057
+ The exception is narrow in three ways worth keeping. It reaches only this
3058
+ unknown-mutator path, so `rm`, `cp`, `tee` and every other command with a
3059
+ real parser still confine such a path. It removes an operand from the
3060
+ destination list rather than adding an allowance, so it cannot widen any
3061
+ other check. And it applies after the dynamic-marker and attached-option
3062
+ refusals below, so an unexpandable operand still fails closed wherever it
3063
+ would have pointed.
3064
+
3065
+ What it does NOT claim is that the command only reads there. Nothing on a
3066
+ command line can establish that; a scoped runner executes code whose writes
3067
+ this parser never sees, in a namespace or out of it. It claims only that the
3068
+ operand is not EVIDENCE of a write, which is all the operand check ever
3069
+ established for the paths it does confine.
3070
+ """
3071
+
3072
+ destinations: list[str] = []
3073
+ turbo_run = (name == "turbo" and args[:1] == ["run"]) or (
3074
+ name == "pnpm" and args[:2] == ["turbo", "run"]
3075
+ )
3076
+ for item in args:
3077
+ if turbo_run and item.startswith("--filter="):
3078
+ # Turbo filters are graph selectors rather than filesystem paths.
3079
+ continue
3080
+ attached = "=" in item
3081
+ candidate = shell_option_value(item) if attached else item
3082
+ path_like = (
3083
+ candidate in {".", ".."}
3084
+ or candidate.startswith(("/", "./", "../", "~/"))
3085
+ or "/" in candidate
3086
+ )
3087
+ dynamic = has_dynamic_markers(candidate)
3088
+ if dynamic and (attached or path_like or not item.startswith("-")):
3089
+ return None
3090
+ if item.startswith("-") and not attached and path_like:
3091
+ # An attached short option such as curl -o/tmp/out or
3092
+ # tar -cf/tmp/out.tar embeds a destination whose option grammar is
3093
+ # command-specific. Never reinterpret the complete token as a safe
3094
+ # relative path under the worktree.
3095
+ return None
3096
+ if path_like:
3097
+ destinations.append(candidate)
3098
+ resolved = resolve_mutation_paths(destinations, cwd)
3099
+ if resolved is None or not read_namespaces:
3100
+ return resolved
3101
+ return [
3102
+ target
3103
+ for target in resolved
3104
+ if not any(is_inside(target, space) for space in read_namespaces)
3105
+ ]
3106
+
3107
+
3108
+ def nested_shell_program(args: list[str]) -> str | None:
3109
+ """Return a shell interpreter's ``-c`` program, or fail closed."""
3110
+
3111
+ for index, item in enumerate(args):
3112
+ if item == "--":
3113
+ return None
3114
+ if item == "-c" or re.fullmatch(r"-[A-Za-z]*c[A-Za-z]*", item):
3115
+ if index + 1 >= len(args):
3116
+ return None
3117
+ return args[index + 1]
3118
+ if not item.startswith(("-", "+")):
3119
+ # Executing a script delegates mutation semantics to file content
3120
+ # that is outside this command parser's proof boundary.
3121
+ return None
3122
+ return None
3123
+
3124
+
3125
+ def has_inline_interpreter_program(name: str, args: list[str]) -> bool:
3126
+ """Whether a non-shell interpreter executes source from an option."""
3127
+
3128
+ if re.fullmatch(r"python(?:\d+(?:\.\d+)*)?", name):
3129
+ # CPython accepts interpreter flags before ``-c`` in the same short
3130
+ # option word (for example ``-Ic``). ``-W`` and ``-X`` consume the
3131
+ # remainder as their own value, so a later c is not another option.
3132
+ index = 0
3133
+ while index < len(args):
3134
+ item = args[index]
3135
+ if item == "--" or not item.startswith("-"):
3136
+ return False
3137
+ if item in {"-W", "-X"}:
3138
+ if index + 1 >= len(args):
3139
+ return False
3140
+ index += 2
3141
+ continue
3142
+ if item.startswith(("-W", "-X")) and len(item) > 2:
3143
+ index += 1
3144
+ continue
3145
+ if item.startswith("--"):
3146
+ index += 1
3147
+ continue
3148
+ for option in item[1:]:
3149
+ if option == "c":
3150
+ return True
3151
+ if option in {"W", "X"}:
3152
+ break
3153
+ index += 1
3154
+ return False
3155
+
3156
+ short_options = NON_SHELL_INLINE_INTERPRETERS.get(name)
3157
+ if short_options is None:
3158
+ parsed_commands = {
3159
+ "adb",
3160
+ "chmod",
3161
+ "chown",
3162
+ "chgrp",
3163
+ "cp",
3164
+ "dd",
3165
+ "find",
3166
+ "git",
3167
+ "install",
3168
+ "ln",
3169
+ "sed",
3170
+ "tee",
3171
+ "truncate",
3172
+ *ALL_OPERAND_MUTATORS,
3173
+ *NAVIGATION_COMMANDS,
3174
+ *READ_ONLY_COMMANDS,
3175
+ }
3176
+ # An unrecognized executable may itself be an interpreter (for
3177
+ # example pwsh -Command). No option-name heuristic can prove opaque
3178
+ # program text safe, so only commands with an explicit parser or a
3179
+ # reviewed worktree-scoped contract reach generic operand analysis.
3180
+ return name not in parsed_commands | GENERIC_SCOPED_COMMANDS
3181
+ long_options = (
3182
+ {"--eval", "--print"} if name in {"bun", "node", "nodejs"} else set()
3183
+ )
3184
+ for item in args:
3185
+ if item == "--" or not item.startswith("-"):
3186
+ return False
3187
+ if item in long_options or any(
3188
+ item.startswith(f"{option}=") for option in long_options
3189
+ ):
3190
+ return True
3191
+ if item.startswith("--"):
3192
+ continue
3193
+ short_word = item[1:]
3194
+ if short_word and (
3195
+ short_word[0] in short_options or short_word[-1] in short_options
3196
+ ):
3197
+ return True
3198
+ return False
3199
+
3200
+
3201
+ class TrustedEntry(Protocol):
3202
+ """One trusted command, as `config.TrustedCommand` carries it.
3203
+
3204
+ Typed structurally so this module needs no import of the settings loader:
3205
+ `script` is a path relative to the repository root, `pattern` must match
3206
+ the whole command line.
3207
+ """
3208
+
3209
+ script: str
3210
+ pattern: re.Pattern[str]
3211
+
3212
+
3213
+ TRUSTED_LINE_UNSAFE = re.compile(r"[^A-Za-z0-9 ._/=:,@+%-]")
3214
+
3215
+
3216
+ def trusted_request(
3217
+ command: str, cwd: Path, root: Path, trusted: Sequence[TrustedEntry] = ()
3218
+ ) -> bool:
3219
+ """Recognize a consumer's trusted script command, and nothing else.
3220
+
3221
+ Every other python script stays unprovable: its writes cannot be read off
3222
+ its command line, so ``mutation_path_operands`` refuses it. A consumer may
3223
+ name scripts whose writes it reviewed instead, each with the exact command
3224
+ lines that run it (the settings' ``worktree.trustedCommands``). A reviewed
3225
+ worktree-management script is the intended case: one whose every write is
3226
+ one this guard already permits from the owning worktree as a plain ``git``
3227
+ or ``gh`` command, and which refuses the primary checkout and unmanaged
3228
+ worktrees itself. No entries: no script is trusted.
3229
+
3230
+ A pattern should accept no quoting and no shell metacharacter, so word
3231
+ splitting of any string it accepts yields exactly its words: nothing then
3232
+ needs parsing to prove what runs.
3233
+
3234
+ The match is exact so nothing else inherits it: the whole command matches
3235
+ one entry's pattern (no extra words, no path-qualified or versioned
3236
+ interpreter, no environment prefix unless the pattern spells one), run from
3237
+ the worktree root, with that entry's script a regular file of the
3238
+ worktree's own reached through no symlink. Callers must establish
3239
+ ownership first.
3240
+
3241
+ The command may also open the line and be followed only by reviewed
3242
+ redirections and read-only segments, which change nothing:
3243
+ ``... begin --branch claude/x 2>&1 | tail -5; git log -1``.
3244
+ First, so no `cd` before it can move which script runs.
3245
+ """
3246
+
3247
+ if not trusted:
3248
+ return False
3249
+ # A loose pattern (`.*`) must not trust a whole line that hides another
3250
+ # command: the whole-line match holds only for plain words.
3251
+ plain = TRUSTED_LINE_UNSAFE.search(command) is None
3252
+ opening: tuple[str, list[list[str]]] | None = None
3253
+ rest_is_inert: bool | None = None
3254
+ for entry in trusted:
3255
+ if not (plain and entry.pattern.fullmatch(command)):
3256
+ if opening is None:
3257
+ opening = trusted_opening(command)
3258
+ first, rest = opening
3259
+ if not first or not entry.pattern.fullmatch(first):
3260
+ continue
3261
+ if rest_is_inert is None:
3262
+ rest_is_inert = all(segment_is_read_only(words) for words in rest)
3263
+ if not rest_is_inert:
3264
+ continue
3265
+ resolved = root.resolve()
3266
+ script = resolved / entry.script
3267
+ if cwd.resolve() == resolved and script.is_file() and script.resolve() == script:
3268
+ return True
3269
+ return False
3270
+
3271
+
3272
+ def trusted_opening(command: str) -> tuple[str, list[list[str]]]:
3273
+ """The first segment of ``command`` as plain words, and the segments after.
3274
+
3275
+ The words are empty when the line does not parse or when any word of the
3276
+ first segment carries quoting: a pattern then has nothing to match.
3277
+ """
3278
+
3279
+ segments = split_commands(command)
3280
+ if not segments:
3281
+ return "", []
3282
+ first, rest = segments[0], segments[1:]
3283
+ if not all(
3284
+ isinstance(word, ShellWord) and not any(word.protected) for word in first
3285
+ ):
3286
+ return "", []
3287
+ return " ".join(first), rest
3288
+
3289
+
3290
+ def is_non_shell_interpreter(name: str) -> bool:
3291
+ """Whether an executable can run opaque source or script files directly."""
3292
+
3293
+ return name in NON_SHELL_INLINE_INTERPRETERS or bool(
3294
+ re.fullmatch(r"python(?:\d+(?:\.\d+)*)?", name)
3295
+ )
3296
+
3297
+
3298
+ def install_uses_directory_mode(args: list[str]) -> bool:
3299
+ """Recognize ``install -d`` without mistaking attached option values."""
3300
+
3301
+ value_options = {"g", "m", "o", "S", "t", "Z"}
3302
+ for item in args:
3303
+ if item == "--":
3304
+ return False
3305
+ if item == "--directory":
3306
+ return True
3307
+ if item.startswith("--") or not item.startswith("-"):
3308
+ continue
3309
+ for option in item[1:]:
3310
+ if option == "d":
3311
+ return True
3312
+ if option in value_options:
3313
+ break
3314
+ return False
3315
+
3316
+
3317
+ def install_long_options_are_unambiguous(args: list[str]) -> bool:
3318
+ """Reject GNU long-option abbreviations and unsupported long options."""
3319
+
3320
+ flags = {
3321
+ "--backup",
3322
+ "--compare",
3323
+ "--context",
3324
+ "--directory",
3325
+ "--help",
3326
+ "--no-target-directory",
3327
+ "--preserve-context",
3328
+ "--preserve-timestamps",
3329
+ "--strip",
3330
+ "--verbose",
3331
+ "--version",
3332
+ }
3333
+ values = {
3334
+ "--group",
3335
+ "--mode",
3336
+ "--owner",
3337
+ "--strip-program",
3338
+ "--suffix",
3339
+ "--target-directory",
3340
+ }
3341
+ index = 0
3342
+ while index < len(args):
3343
+ item = args[index]
3344
+ if item == "--":
3345
+ return True
3346
+ if not item.startswith("--"):
3347
+ index += 1
3348
+ continue
3349
+ option, separator, value = item.partition("=")
3350
+ if option in flags:
3351
+ if separator and (
3352
+ option not in {"--backup", "--context"} or not value
3353
+ ):
3354
+ return False
3355
+ index += 1
3356
+ continue
3357
+ if option not in values:
3358
+ return False
3359
+ if separator:
3360
+ if not value:
3361
+ return False
3362
+ index += 1
3363
+ continue
3364
+ if index + 1 >= len(args):
3365
+ return False
3366
+ index += 2
3367
+ return True
3368
+
3369
+
3370
+ def sed_mutation_paths(args: list[str], cwd: Path) -> list[Path] | None:
3371
+ """Resolve input files for an in-place sed invocation."""
3372
+
3373
+ positionals: list[str] = []
3374
+ program_from_option = False
3375
+ empty_suffix = False
3376
+ index = 0
3377
+ options = True
3378
+ while index < len(args):
3379
+ item = args[index]
3380
+ if options and item == "--":
3381
+ options = False
3382
+ index += 1
3383
+ continue
3384
+ if options and item in {"-e", "--expression", "-f", "--file"}:
3385
+ if index + 1 >= len(args):
3386
+ return None
3387
+ program_from_option = True
3388
+ index += 2
3389
+ continue
3390
+ if options and any(
3391
+ item.startswith(prefix)
3392
+ for prefix in ("-e", "-f", "--expression=", "--file=")
3393
+ ):
3394
+ program_from_option = True
3395
+ index += 1
3396
+ continue
3397
+ if (
3398
+ options
3399
+ and item == "-i"
3400
+ and index + 1 < len(args)
3401
+ and args[index + 1] == ""
3402
+ ):
3403
+ empty_suffix = True
3404
+ index += 2
3405
+ continue
3406
+ if options and item.startswith("-"):
3407
+ index += 1
3408
+ continue
3409
+ positionals.append(item)
3410
+ index += 1
3411
+ # BSD sed reads `-i ''` as an empty suffix and the next positional as the
3412
+ # program; GNU sed reads `''` as the program and every positional as a
3413
+ # file. Which sed runs is unknown, so both readings' files are resolved.
3414
+ files = (
3415
+ positionals
3416
+ if program_from_option or empty_suffix
3417
+ else positionals[1:]
3418
+ )
3419
+ resolved = resolve_mutation_paths(files, cwd)
3420
+ if resolved and empty_suffix and not program_from_option:
3421
+ # The first positional is BSD's program and only GNU's file. sed -i
3422
+ # rewrites files it could open and creates none, so as a file it can
3423
+ # only be written if it exists: `sed -i '' '/^import/d' f` edits f
3424
+ # alone unless a file /^import/d exists.
3425
+ program = resolved[0]
3426
+ if not os.path.lexists(program):
3427
+ resolved = resolved[1:]
3428
+ return resolved
3429
+
3430
+
3431
+ def find_mutation_paths(args: list[str], cwd: Path) -> list[Path] | None:
3432
+ """Resolve paths changed by supported mutating ``find`` actions."""
3433
+
3434
+ index = 0
3435
+ while index < len(args) and args[index] in {"-H", "-L", "-P"}:
3436
+ index += 1
3437
+ if index < len(args) and args[index] == "-D":
3438
+ if index + 1 >= len(args):
3439
+ return None
3440
+ index += 2
3441
+ if index < len(args) and re.fullmatch(r"-O\d+", args[index]):
3442
+ index += 1
3443
+ if index < len(args) and args[index] == "--":
3444
+ index += 1
3445
+ elif index < len(args) and args[index].startswith("-"):
3446
+ # An unknown leading option makes the path/expression boundary
3447
+ # ambiguous. A mutating invocation must therefore fail closed.
3448
+ return None
3449
+
3450
+ roots: list[str] = []
3451
+ while index < len(args):
3452
+ item = args[index]
3453
+ if item in {"!", "(", ")", ","} or item.startswith("-"):
3454
+ break
3455
+ roots.append(item)
3456
+ index += 1
3457
+
3458
+ expression = args[index:]
3459
+ if any(
3460
+ action in expression
3461
+ for action in {"-exec", "-execdir", "-ok", "-okdir"}
3462
+ ):
3463
+ # The nested command can compute an arbitrary destination.
3464
+ return None
3465
+
3466
+ destinations: list[str] = []
3467
+ if "-delete" in expression:
3468
+ destinations.extend(roots or ["."])
3469
+
3470
+ output_action_values = {
3471
+ "-fls": 1,
3472
+ "-fprint": 1,
3473
+ "-fprint0": 1,
3474
+ "-fprintf": 2,
3475
+ }
3476
+ expression_index = 0
3477
+ while expression_index < len(expression):
3478
+ action = expression[expression_index]
3479
+ value_count = output_action_values.get(action)
3480
+ if value_count is None:
3481
+ expression_index += 1
3482
+ continue
3483
+ if expression_index + value_count >= len(expression):
3484
+ return None
3485
+ destinations.append(expression[expression_index + 1])
3486
+ expression_index += value_count + 1
3487
+
3488
+ return resolve_mutation_paths(destinations, cwd)
3489
+
3490
+
3491
+ def truncate_mutation_paths(args: list[str], cwd: Path) -> list[Path] | None:
3492
+ """Resolve files truncated by a supported GNU ``truncate`` invocation."""
3493
+
3494
+ positionals: list[str] = []
3495
+ index = 0
3496
+ options = True
3497
+ while index < len(args):
3498
+ item = args[index]
3499
+ if options and item == "--":
3500
+ options = False
3501
+ index += 1
3502
+ continue
3503
+ if options and item in {"-r", "--reference", "-s", "--size"}:
3504
+ if index + 1 >= len(args):
3505
+ return None
3506
+ index += 2
3507
+ continue
3508
+ if options and (
3509
+ re.fullmatch(r"-[rs].+", item)
3510
+ or item.startswith("--reference=")
3511
+ or item.startswith("--size=")
3512
+ ):
3513
+ index += 1
3514
+ continue
3515
+ if options and (
3516
+ item in {"--help", "--io-blocks", "--no-create", "--version"}
3517
+ or re.fullmatch(r"-[co]+", item)
3518
+ ):
3519
+ index += 1
3520
+ continue
3521
+ if options and item.startswith("-"):
3522
+ return None
3523
+ positionals.append(item)
3524
+ index += 1
3525
+ return resolve_mutation_paths(positionals, cwd)
3526
+
3527
+
3528
+ def tee_mutation_paths(args: list[str], cwd: Path) -> list[Path] | None:
3529
+ """Resolve output files for a supported GNU ``tee`` invocation."""
3530
+
3531
+ positionals: list[str] = []
3532
+ options = True
3533
+ for item in args:
3534
+ if options and item == "--":
3535
+ options = False
3536
+ continue
3537
+ if options and (
3538
+ item in {
3539
+ "--append",
3540
+ "--help",
3541
+ "--ignore-interrupts",
3542
+ "--output-error",
3543
+ "--version",
3544
+ }
3545
+ or item.startswith("--output-error=")
3546
+ or re.fullmatch(r"-[aip]+", item)
3547
+ ):
3548
+ continue
3549
+ if options and item.startswith("-"):
3550
+ return None
3551
+ if item == os.devnull and expansion_risk(item) is None:
3552
+ # Writing /dev/null changes no file.
3553
+ continue
3554
+ positionals.append(item)
3555
+ return resolve_mutation_paths(positionals, cwd)
3556
+
3557
+
3558
+ def dd_mutation_paths(args: list[str], cwd: Path) -> list[Path] | None:
3559
+ """Resolve the output file for a supported GNU ``dd`` invocation."""
3560
+
3561
+ operands = {
3562
+ "bs",
3563
+ "cbs",
3564
+ "conv",
3565
+ "count",
3566
+ "ibs",
3567
+ "if",
3568
+ "iflag",
3569
+ "obs",
3570
+ "of",
3571
+ "oflag",
3572
+ "oseek",
3573
+ "seek",
3574
+ "skip",
3575
+ "status",
3576
+ }
3577
+ destinations: list[str] = []
3578
+ for item in args:
3579
+ if item in {"--help", "--version"}:
3580
+ continue
3581
+ if "=" not in item:
3582
+ return None
3583
+ name, value = item.split("=", 1)[0], shell_option_value(item)
3584
+ if name not in operands:
3585
+ return None
3586
+ if name == "of":
3587
+ destinations.append(value)
3588
+ return resolve_mutation_paths(destinations, cwd)
3589
+
3590
+
3591
+ def git_worktree_mutation_paths(
3592
+ words: list[str], command_index: int, cwd: Path
3593
+ ) -> list[Path] | None:
3594
+ args = words[command_index + 1 :]
3595
+ if not args:
3596
+ return []
3597
+ action = args[0]
3598
+ parsed = command_positionals(
3599
+ args[1:],
3600
+ value_options={"-b", "-B", "--reason"},
3601
+ )
3602
+ if parsed is None:
3603
+ return None
3604
+ positionals, _target_directory = parsed
3605
+ if action == "add":
3606
+ path_values = positionals[:1]
3607
+ elif action == "move":
3608
+ path_values = positionals[:2]
3609
+ elif action in {"lock", "remove", "unlock"}:
3610
+ path_values = positionals[:1]
3611
+ elif action == "repair":
3612
+ path_values = positionals
3613
+ else:
3614
+ return []
3615
+ return resolve_mutation_paths(path_values, cwd)
3616
+
3617
+
3618
+ def metadata_mutation_paths(
3619
+ name: str, args: list[str], cwd: Path
3620
+ ) -> list[Path] | None:
3621
+ """Resolve files changed by chmod, chown, and chgrp."""
3622
+
3623
+ short_flags = {
3624
+ "chmod": set("RfcvHLP"),
3625
+ "chown": set("RcfhvnHLPx"),
3626
+ "chgrp": set("RcfhvnHLPx"),
3627
+ }[name]
3628
+ long_flags = {
3629
+ "--changes",
3630
+ "--dereference",
3631
+ "--help",
3632
+ "--no-dereference",
3633
+ "--no-preserve-root",
3634
+ "--preserve-root",
3635
+ "--quiet",
3636
+ "--recursive",
3637
+ "--silent",
3638
+ "--verbose",
3639
+ "--version",
3640
+ }
3641
+ value_options = {"--reference"}
3642
+ if name == "chown":
3643
+ value_options.add("--from")
3644
+
3645
+ positionals: list[str] = []
3646
+ reference_mode = False
3647
+ index = 0
3648
+ options = True
3649
+ chmod_mode = re.compile(
3650
+ r"[ugoa]*[-+=][rwxXstugo]*(?:,[ugoa]*[-+=][rwxXstugo]*)*"
3651
+ )
3652
+ while index < len(args):
3653
+ item = args[index]
3654
+ if options and item == "--":
3655
+ options = False
3656
+ index += 1
3657
+ continue
3658
+ if options and name == "chmod" and chmod_mode.fullmatch(item):
3659
+ positionals.append(item)
3660
+ options = False
3661
+ index += 1
3662
+ continue
3663
+ matching_value = next(
3664
+ (
3665
+ option
3666
+ for option in value_options
3667
+ if item == option or item.startswith(f"{option}=")
3668
+ ),
3669
+ None,
3670
+ )
3671
+ if options and matching_value:
3672
+ reference_mode = reference_mode or matching_value == "--reference"
3673
+ if item == matching_value:
3674
+ if index + 1 >= len(args):
3675
+ return None
3676
+ index += 2
3677
+ else:
3678
+ index += 1
3679
+ continue
3680
+ if options and item in long_flags:
3681
+ index += 1
3682
+ continue
3683
+ if options and item.startswith("--"):
3684
+ return None
3685
+ if options and item.startswith("-"):
3686
+ if len(item) == 1 or not set(item[1:]).issubset(short_flags):
3687
+ return None
3688
+ index += 1
3689
+ continue
3690
+ positionals.append(item)
3691
+ index += 1
3692
+
3693
+ path_values = positionals if reference_mode else positionals[1:]
3694
+ return resolve_mutation_paths(path_values, cwd)
3695
+
3696
+
3697
+ def mutation_path_operands(
3698
+ words: list[str],
3699
+ cwd: Path,
3700
+ *,
3701
+ lookup_modified: bool = False,
3702
+ read_namespaces: tuple[Path, ...] = (),
3703
+ ) -> list[Path] | None:
3704
+ """Resolve filesystem operands mutated by a simple command."""
3705
+
3706
+ name = os.path.basename(words[0])
3707
+ if name in GENERIC_SCOPED_COMMANDS | READ_ONLY_COMMANDS and words[0] != name:
3708
+ # A path-qualified executable can be a repository-controlled shim, so
3709
+ # its behavior cannot inherit the reviewed command's basename contract.
3710
+ return None
3711
+ if is_non_shell_interpreter(name) and not (
3712
+ not lookup_modified and bun_run_is_package_script(words, cwd)
3713
+ ):
3714
+ # Script files and interpreter options execute code whose mutation
3715
+ # targets cannot be proven from command operands. Keep the contract
3716
+ # fail-closed for both inline programs and file-backed programs. Bun's
3717
+ # reviewed package-script form instead uses the generic runner contract;
3718
+ # a consumer's trusted commands are matched before this parser runs,
3719
+ # by `trusted_request`.
3720
+ return None
3721
+ if has_inline_interpreter_program(name, words[1:]):
3722
+ return None
3723
+ if name == "printf" and not printf_is_read_only(words[1:]):
3724
+ # `printf -v PATH ...` steers every later lookup.
3725
+ return None
3726
+ if name in READ_ONLY_COMMANDS:
3727
+ # Shell redirections are parsed separately. These commands do not
3728
+ # mutate their path-like operands, so external inputs remain readable.
3729
+ return []
3730
+ if name in NAVIGATION_COMMANDS:
3731
+ # Navigation semantics are handled by mutation_directories before this
3732
+ # operand-only fallback is reached.
3733
+ return []
3734
+ if name == "git":
3735
+ command_index, git_cwd = git_command(words, cwd)
3736
+ if command_index is None or git_cwd is None or command_index >= len(words):
3737
+ return None
3738
+ if words[command_index] == "worktree":
3739
+ return git_worktree_mutation_paths(words, command_index, git_cwd)
3740
+ if words[command_index] in READ_GIT:
3741
+ outputs = git_inspection_outputs(words, command_index)
3742
+ return (
3743
+ resolve_mutation_paths(outputs, git_cwd)
3744
+ if outputs is not None else None
3745
+ )
3746
+ return []
3747
+ if name == "sed":
3748
+ return sed_mutation_paths(words[1:], cwd)
3749
+ if name == "adb":
3750
+ return adb_mutation_paths(words[1:], cwd)
3751
+ if name == "find":
3752
+ return find_mutation_paths(words[1:], cwd)
3753
+ if name == "truncate":
3754
+ return truncate_mutation_paths(words[1:], cwd)
3755
+ if name == "tee":
3756
+ return tee_mutation_paths(words[1:], cwd)
3757
+ if name == "dd":
3758
+ return dd_mutation_paths(words[1:], cwd)
3759
+ if name in {"chmod", "chown", "chgrp"}:
3760
+ return metadata_mutation_paths(name, words[1:], cwd)
3761
+ if name in {"cp", "install", "ln"}:
3762
+ if name == "install" and not install_long_options_are_unambiguous(
3763
+ words[1:]
3764
+ ):
3765
+ return None
3766
+ value_options = {
3767
+ "cp": {"-S", "--suffix"},
3768
+ "install": {
3769
+ "-g",
3770
+ "-m",
3771
+ "-o",
3772
+ "-S",
3773
+ "--group",
3774
+ "--mode",
3775
+ "--owner",
3776
+ "--strip-program",
3777
+ "--suffix",
3778
+ },
3779
+ "ln": {"-S", "--suffix"},
3780
+ }[name]
3781
+ parsed = command_positionals(
3782
+ words[1:],
3783
+ value_options=value_options,
3784
+ target_options={"-t", "--target-directory"},
3785
+ )
3786
+ if parsed is None:
3787
+ return None
3788
+ positionals, target_directory = parsed
3789
+ directory_mode = name == "install" and install_uses_directory_mode(words[1:])
3790
+ if target_directory is not None:
3791
+ destinations = [target_directory]
3792
+ elif directory_mode:
3793
+ destinations = positionals
3794
+ elif len(positionals) >= 2:
3795
+ destinations = positionals[-1:]
3796
+ else:
3797
+ destinations = []
3798
+ return resolve_mutation_paths(destinations, cwd)
3799
+ if name in ALL_OPERAND_MUTATORS:
3800
+ value_options = {
3801
+ "mkdir": {"-m", "--mode"},
3802
+ "mkfifo": {"-m", "--mode"},
3803
+ "mv": {"-S", "--suffix"},
3804
+ "touch": {
3805
+ "-d",
3806
+ "-r",
3807
+ "-t",
3808
+ "--date",
3809
+ "--reference",
3810
+ "--time",
3811
+ },
3812
+ }.get(name, set())
3813
+ target_options = (
3814
+ {"-t", "--target-directory"}
3815
+ if name == "mv"
3816
+ else set()
3817
+ )
3818
+ parsed = command_positionals(
3819
+ words[1:],
3820
+ value_options=value_options,
3821
+ target_options=target_options,
3822
+ )
3823
+ if parsed is None:
3824
+ return None
3825
+ positionals, target_directory = parsed
3826
+ path_values = positionals + (
3827
+ [target_directory] if target_directory is not None else []
3828
+ )
3829
+ return resolve_mutation_paths(path_values, cwd)
3830
+ return generic_mutation_paths(
3831
+ name, words[1:], cwd, read_namespaces=read_namespaces
3832
+ )
3833
+
3834
+
3835
+ def mutation_directories(
3836
+ command: str,
3837
+ cwd: Path,
3838
+ *,
3839
+ nested_depth: int = 0,
3840
+ read_namespaces: tuple[Path, ...] = (),
3841
+ ) -> list[Path] | None:
3842
+ """Resolve the directory in which each mutating segment would run."""
3843
+
3844
+ if nested_depth > 8:
3845
+ return None
3846
+ tokens = tokenize_command(command)
3847
+ if tokens is None:
3848
+ return None
3849
+ # The simple directory model below follows successful sequential `cd`
3850
+ # operations. Directory changes in failure branches or subshells require a
3851
+ # control-flow graph, so actual navigation commands in those forms fail
3852
+ # closed. Ordinary arguments named cd or -C remain valid.
3853
+ complex_control = any(
3854
+ is_shell_operator(token) and token in {"||", "(", ")"}
3855
+ for token in tokens
3856
+ )
3857
+ segments = split_commands(command)
3858
+ if segments is None:
3859
+ return None
3860
+ # A loop body runs once per iteration while this model applies each `cd`
3861
+ # once, so `for n in a b; do cd ..; done` would be read one level short:
3862
+ # loops navigate under the same fail-closed rule.
3863
+ complex_control = complex_control or any(
3864
+ isinstance(words[0], ShellWord)
3865
+ and words[0].quote_start is None
3866
+ and words[0] in {"for", "while", "until"}
3867
+ for words in segments
3868
+ )
3869
+ current = cwd.resolve()
3870
+ mutations: list[Path] = []
3871
+ persistent_lookup_modified = False
3872
+ sets_variable = False
3873
+ bindings: dict[str, tuple[str, ...]] = {}
3874
+ for raw_words in segments:
3875
+ parsed = effective_segment(raw_words)
3876
+ if parsed is None:
3877
+ return None
3878
+ words, wrapped, lookup_modified = parsed
3879
+ prefix = raw_words[: len(raw_words) - len(words)]
3880
+ read_only = segment_is_read_only_bound(words, bindings) if words else True
3881
+ update_bindings(without_control_words(raw_words) or [], bindings)
3882
+ if not words:
3883
+ persistent_lookup_modified = (
3884
+ persistent_lookup_modified or lookup_modified
3885
+ )
3886
+ state = adb_state_paths(prefix, current)
3887
+ if state is None:
3888
+ return None
3889
+ mutations.extend(state)
3890
+ continue
3891
+ lookup_modified = lookup_modified or persistent_lookup_modified
3892
+ name = os.path.basename(words[0])
3893
+ if words[0] != name:
3894
+ # The basename may match a parser below while the actual executable
3895
+ # is a repository-controlled shim with unrelated behavior.
3896
+ return None
3897
+ if lookup_modified:
3898
+ # A PATH-affecting wrapper or prior assignment-only segment can
3899
+ # resolve any reviewed basename to repository-controlled code.
3900
+ # Fail closed before applying command-specific semantics.
3901
+ return None
3902
+ if name in {"pushd", "popd"}:
3903
+ return None
3904
+ if name in {"for", "read"} and read_only:
3905
+ # The variable it sets may be exported in the Bash tool's shell,
3906
+ # which this hook's environment does not show, and so reach every
3907
+ # later child: reads still pass, any later write fails closed.
3908
+ sets_variable = True
3909
+ elif sets_variable and not read_only:
3910
+ return None
3911
+ if (
3912
+ not wrapped
3913
+ and name != "git"
3914
+ and not read_only
3915
+ and contains_nested_git_c(words)
3916
+ ):
3917
+ return None
3918
+ # Only `cd` moves `current`, and it fails closed here, so a `git -C`
3919
+ # under these forms resolves against the same directory as the plain
3920
+ # command and is judged like it: `git -C <own worktree>
3921
+ # commit --amend || true` passes, `-C <main checkout>` is still refused
3922
+ # as a mutation outside the worktree. No accepted wrapper changes
3923
+ # directory (`env -C` and `env -S` already fail closed).
3924
+ if complex_control and name == "cd":
3925
+ return None
3926
+ if wrapped and name == "cd":
3927
+ return None
3928
+ if name == "cd":
3929
+ if len(words) == 2 and words[1] != "--":
3930
+ raw_value = words[1]
3931
+ elif len(words) == 3 and words[1] == "--":
3932
+ raw_value = words[2]
3933
+ else:
3934
+ return None
3935
+ resolved = resolve_shell_path(raw_value, current)
3936
+ if resolved is None:
3937
+ return None
3938
+ current = resolved
3939
+ continue
3940
+ if name in SHELL_INTERPRETERS and read_only:
3941
+ # A shell under `-n`, or asked only for its version, runs no
3942
+ # command and so contributes no mutation. This has to be tested
3943
+ # BEFORE the nested-program branch below, which fails closed on any
3944
+ # shell that is not running a `-c` program. Without it a read-only
3945
+ # shell segment poisons every command it appears in: `rm -f x &&
3946
+ # bash -n y` was refused outright, even though each half is allowed
3947
+ # on its own, because `shell_is_read_only` needs EVERY segment to
3948
+ # be read-only and the whole command therefore arrives here.
3949
+ continue
3950
+ if name in SHELL_INTERPRETERS:
3951
+ program = nested_shell_program(words[1:])
3952
+ if program is None or has_dynamic_markers(program):
3953
+ return None
3954
+ nested = mutation_directories(
3955
+ program,
3956
+ current,
3957
+ nested_depth=nested_depth + 1,
3958
+ read_namespaces=read_namespaces,
3959
+ )
3960
+ if nested is None:
3961
+ return None
3962
+ mutations.extend(nested)
3963
+ continue
3964
+ target = current
3965
+ if name == "git":
3966
+ _index, git_target = git_command(words, current)
3967
+ if _index is None or git_target is None:
3968
+ return None
3969
+ target = git_target
3970
+ if not read_only and any(
3971
+ option in {"-c", "--config-env", "--git-dir", "--work-tree"}
3972
+ or option.startswith(
3973
+ ("--config-env=", "--git-dir=", "--work-tree=")
3974
+ )
3975
+ for option in words[1:_index]
3976
+ ):
3977
+ return None
3978
+ # A read-only adb stays read-only only bare: an assignment can move
3979
+ # the server it may start (ANDROID_USER_HOME, TMPDIR) into a checkout,
3980
+ # so the command needs an owned worktree and the moved state directory
3981
+ # is confined like any other destination.
3982
+ if not read_only or (wrapped and name == "adb"):
3983
+ if has_dynamic_markers(words[0]) or (
3984
+ name in OPTION_BLIND_MUTATORS
3985
+ and not expansions_are_inert(name, words[1:])
3986
+ ):
3987
+ # An expansion that may become an option makes the operands
3988
+ # unknown: `git log $X` with X=--output=<elsewhere> writes
3989
+ # there.
3990
+ return None
3991
+ if name == "adb":
3992
+ state = adb_state_paths(prefix, current)
3993
+ if state is None:
3994
+ return None
3995
+ mutations.extend(state)
3996
+ mutations.append(target)
3997
+ operand_targets = mutation_path_operands(
3998
+ words, current if name == "git" else target,
3999
+ lookup_modified=lookup_modified,
4000
+ read_namespaces=read_namespaces,
4001
+ )
4002
+ if operand_targets is None:
4003
+ return None
4004
+ mutations.extend(operand_targets)
4005
+ return mutations
4006
+
4007
+
4008
+ def tool_command(values: dict[str, object]) -> str:
4009
+ for key in ("command", "cmd", "input", "patch"):
4010
+ value = values.get(key)
4011
+ if isinstance(value, str):
4012
+ return value
4013
+ return ""
4014
+
4015
+
4016
+ def read_input() -> dict[str, object]:
4017
+ try:
4018
+ value = json.load(sys.stdin)
4019
+ # ValueError covers undecodable bytes as well as bad JSON, and deep nesting
4020
+ # raises RecursionError: either escaping here exited 1, which hosts read as
4021
+ # "run the tool anyway".
4022
+ except (ValueError, TypeError, RecursionError, OSError):
4023
+ deny("invalid hook payload; refusing to run without isolation context")
4024
+ if not isinstance(value, dict):
4025
+ deny("invalid hook payload; expected a JSON object")
4026
+ for key, kind in (("cwd", str), ("tool_name", str), ("tool_input", dict)):
4027
+ field = value.get(key)
4028
+ if field is not None and not isinstance(field, kind):
4029
+ deny(f"invalid hook payload; {key} is not a {kind.__name__}")
4030
+ return value