@veda-hq/kavach 0.0.0-stage → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +675 -0
|
@@ -0,0 +1,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
|