raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/gitguard.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shlex
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Ambiguous(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Forbidden(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
READ = "read"
|
|
15
|
+
REF = "ref"
|
|
16
|
+
PATHS = "paths"
|
|
17
|
+
WHOLE = "whole"
|
|
18
|
+
SUBST = "__raidlock_subst__"
|
|
19
|
+
MAX_DEPTH = 4
|
|
20
|
+
|
|
21
|
+
SEPARATORS = (";", "&&", "||", "|", "&", "|&", ";;", ";&", ";;&")
|
|
22
|
+
REDIRECTS = (">", ">>", "&>", "&>>", ">|", ">&", "<", "<<", "<<<", "<&", "<>")
|
|
23
|
+
GROUP_OPEN = ("(", "{")
|
|
24
|
+
GROUP_CLOSE = (")", "}")
|
|
25
|
+
KEYWORDS = ("if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "time", "{", "}")
|
|
26
|
+
ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
27
|
+
GIT_WORD = re.compile(r"(?<![\w.])git(?![\w.])")
|
|
28
|
+
HEREDOC = re.compile(r"(?<!<)<<(?!<)(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
|
|
29
|
+
EXEC_CONSUMER = re.compile(
|
|
30
|
+
r"(?<![\w.-])(sh|bash|zsh|dash|ksh|mksh|fish|eval|source|xargs|python[\d.]*|perl|ruby|node|"
|
|
31
|
+
r"php|osascript|awk|gawk|lua|deno|bun|pwsh)(?![\w.-])"
|
|
32
|
+
)
|
|
33
|
+
INTERPRETER = re.compile(
|
|
34
|
+
r"^(python[\d.]*|perl[\d.]*|ruby|node|nodejs|php|osascript|awk|gawk|mawk|nawk|lua[\d.]*|"
|
|
35
|
+
r"tclsh|deno|bun|pwsh|powershell|Rscript|expect)$"
|
|
36
|
+
)
|
|
37
|
+
SHELLS = ("sh", "bash", "zsh", "dash", "ksh", "mksh", "fish", "busybox")
|
|
38
|
+
LAUNCHERS = (
|
|
39
|
+
"xargs",
|
|
40
|
+
"sudo",
|
|
41
|
+
"doas",
|
|
42
|
+
"timeout",
|
|
43
|
+
"gtimeout",
|
|
44
|
+
"nice",
|
|
45
|
+
"ionice",
|
|
46
|
+
"stdbuf",
|
|
47
|
+
"setsid",
|
|
48
|
+
"caffeinate",
|
|
49
|
+
"flock",
|
|
50
|
+
"chronic",
|
|
51
|
+
"unbuffer",
|
|
52
|
+
"watch",
|
|
53
|
+
"parallel",
|
|
54
|
+
"find",
|
|
55
|
+
"fd",
|
|
56
|
+
"script",
|
|
57
|
+
"entr",
|
|
58
|
+
"nohup",
|
|
59
|
+
"exec",
|
|
60
|
+
"ssh",
|
|
61
|
+
"su",
|
|
62
|
+
"runuser",
|
|
63
|
+
"chroot",
|
|
64
|
+
"nsenter",
|
|
65
|
+
"unshare",
|
|
66
|
+
"firejail",
|
|
67
|
+
"strace",
|
|
68
|
+
"ltrace",
|
|
69
|
+
"gdb",
|
|
70
|
+
"lldb",
|
|
71
|
+
"make",
|
|
72
|
+
"just",
|
|
73
|
+
"npx",
|
|
74
|
+
"uvx",
|
|
75
|
+
"pipx",
|
|
76
|
+
"bunx",
|
|
77
|
+
"direnv",
|
|
78
|
+
"with",
|
|
79
|
+
"tmux",
|
|
80
|
+
"screen",
|
|
81
|
+
)
|
|
82
|
+
NON_EXEC = (
|
|
83
|
+
"echo",
|
|
84
|
+
"printf",
|
|
85
|
+
"grep",
|
|
86
|
+
"egrep",
|
|
87
|
+
"fgrep",
|
|
88
|
+
"rg",
|
|
89
|
+
"ag",
|
|
90
|
+
"which",
|
|
91
|
+
"type",
|
|
92
|
+
"whereis",
|
|
93
|
+
"man",
|
|
94
|
+
"ls",
|
|
95
|
+
"cat",
|
|
96
|
+
"head",
|
|
97
|
+
"tail",
|
|
98
|
+
"wc",
|
|
99
|
+
"test",
|
|
100
|
+
"[",
|
|
101
|
+
"raidlock",
|
|
102
|
+
"brew",
|
|
103
|
+
"apt",
|
|
104
|
+
"true",
|
|
105
|
+
"false",
|
|
106
|
+
":",
|
|
107
|
+
"diff",
|
|
108
|
+
"less",
|
|
109
|
+
"more",
|
|
110
|
+
)
|
|
111
|
+
SIMPLE_WRAPPERS = ("command", "builtin", "noglob", "nocorrect")
|
|
112
|
+
|
|
113
|
+
GLOBAL_FLAGS = (
|
|
114
|
+
"-p",
|
|
115
|
+
"--paginate",
|
|
116
|
+
"-P",
|
|
117
|
+
"--no-pager",
|
|
118
|
+
"--literal-pathspecs",
|
|
119
|
+
"--no-optional-locks",
|
|
120
|
+
"--no-replace-objects",
|
|
121
|
+
"--glob-pathspecs",
|
|
122
|
+
"--noglob-pathspecs",
|
|
123
|
+
"--icase-pathspecs",
|
|
124
|
+
"--no-advice",
|
|
125
|
+
)
|
|
126
|
+
READ_ONLY = (
|
|
127
|
+
"status",
|
|
128
|
+
"diff",
|
|
129
|
+
"log",
|
|
130
|
+
"show",
|
|
131
|
+
"rev-parse",
|
|
132
|
+
"ls-files",
|
|
133
|
+
"blame",
|
|
134
|
+
"cat-file",
|
|
135
|
+
"ls-tree",
|
|
136
|
+
"rev-list",
|
|
137
|
+
"describe",
|
|
138
|
+
"show-ref",
|
|
139
|
+
"merge-base",
|
|
140
|
+
"shortlog",
|
|
141
|
+
"grep",
|
|
142
|
+
"whatchanged",
|
|
143
|
+
"count-objects",
|
|
144
|
+
"var",
|
|
145
|
+
"check-ignore",
|
|
146
|
+
"check-attr",
|
|
147
|
+
"diff-files",
|
|
148
|
+
"diff-index",
|
|
149
|
+
"diff-tree",
|
|
150
|
+
"help",
|
|
151
|
+
"version",
|
|
152
|
+
)
|
|
153
|
+
REF_ONLY = ("fetch", "push")
|
|
154
|
+
WHOLE_TREE = ("merge", "rebase", "pull", "cherry-pick", "revert", "am")
|
|
155
|
+
FORBIDDEN_SUBS = (
|
|
156
|
+
"difftool",
|
|
157
|
+
"mergetool",
|
|
158
|
+
"bisect",
|
|
159
|
+
"filter-branch",
|
|
160
|
+
"filter-repo",
|
|
161
|
+
"hook",
|
|
162
|
+
"submodule",
|
|
163
|
+
"daemon",
|
|
164
|
+
"instaweb",
|
|
165
|
+
"credential",
|
|
166
|
+
"send-email",
|
|
167
|
+
"web--browse",
|
|
168
|
+
"upload-pack",
|
|
169
|
+
"receive-pack",
|
|
170
|
+
"remote-ext",
|
|
171
|
+
"remote-fd",
|
|
172
|
+
"shell",
|
|
173
|
+
"archimport",
|
|
174
|
+
"cvsimport",
|
|
175
|
+
"svn",
|
|
176
|
+
"p4",
|
|
177
|
+
)
|
|
178
|
+
EXEC_OPTIONS = (
|
|
179
|
+
"--upload-pack",
|
|
180
|
+
"--receive-pack",
|
|
181
|
+
"--exec",
|
|
182
|
+
"--extcmd",
|
|
183
|
+
"--open-files-in-pager",
|
|
184
|
+
"--tool",
|
|
185
|
+
"--ext-diff",
|
|
186
|
+
"--textconv",
|
|
187
|
+
)
|
|
188
|
+
OUTPUT_OPTIONS = ("--output",)
|
|
189
|
+
REV = re.compile(r"^(HEAD|@|ORIG_HEAD|FETCH_HEAD|MERGE_HEAD|[0-9a-f]{7,40})([~^][0-9~^]*)?$|[~^]")
|
|
190
|
+
GLOB = re.compile(r"[*?\[{]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def mentions_git(text):
|
|
194
|
+
return bool(GIT_WORD.search(text or ""))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def strip_heredocs(text):
|
|
198
|
+
lines = text.split("\n")
|
|
199
|
+
out, i = [], 0
|
|
200
|
+
while i < len(lines):
|
|
201
|
+
line = lines[i]
|
|
202
|
+
out.append(line)
|
|
203
|
+
i += 1
|
|
204
|
+
for m in HEREDOC.finditer(line):
|
|
205
|
+
body = []
|
|
206
|
+
while i < len(lines):
|
|
207
|
+
cand = lines[i].lstrip("\t") if m.group(1) else lines[i]
|
|
208
|
+
i += 1
|
|
209
|
+
if cand == m.group(3):
|
|
210
|
+
break
|
|
211
|
+
body.append(lines[i - 1])
|
|
212
|
+
if EXEC_CONSUMER.search(line[: m.start()]) and mentions_git("\n".join(body)):
|
|
213
|
+
raise Ambiguous("a heredoc feeds git commands to a shell or interpreter")
|
|
214
|
+
return "\n".join(out)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _close_paren(text, start):
|
|
218
|
+
depth, quote, i = 1, "", start
|
|
219
|
+
while i < len(text):
|
|
220
|
+
c = text[i]
|
|
221
|
+
if quote == "'":
|
|
222
|
+
quote = "" if c == "'" else quote
|
|
223
|
+
elif c == "\\":
|
|
224
|
+
i += 1
|
|
225
|
+
elif c == "'" and not quote:
|
|
226
|
+
quote = "'"
|
|
227
|
+
elif c == '"':
|
|
228
|
+
quote = "" if quote == '"' else '"'
|
|
229
|
+
elif not quote and c == "(":
|
|
230
|
+
depth += 1
|
|
231
|
+
elif not quote and c == ")":
|
|
232
|
+
depth -= 1
|
|
233
|
+
if depth == 0:
|
|
234
|
+
return i
|
|
235
|
+
i += 1
|
|
236
|
+
raise Ambiguous("unbalanced command substitution")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def extract_substitutions(text):
|
|
240
|
+
out, inner, quote, i = [], [], "", 0
|
|
241
|
+
while i < len(text):
|
|
242
|
+
c = text[i]
|
|
243
|
+
if quote == "'":
|
|
244
|
+
quote = "" if c == "'" else quote
|
|
245
|
+
out.append(c)
|
|
246
|
+
i += 1
|
|
247
|
+
continue
|
|
248
|
+
if c == "\\" and i + 1 < len(text):
|
|
249
|
+
out.append(text[i : i + 2])
|
|
250
|
+
i += 2
|
|
251
|
+
continue
|
|
252
|
+
if c == "'" and not quote:
|
|
253
|
+
quote = "'"
|
|
254
|
+
elif c == '"':
|
|
255
|
+
quote = "" if quote == '"' else '"'
|
|
256
|
+
elif c == "`":
|
|
257
|
+
j = i + 1
|
|
258
|
+
while j < len(text) and text[j] != "`":
|
|
259
|
+
j += 2 if text[j] == "\\" else 1
|
|
260
|
+
if j >= len(text):
|
|
261
|
+
raise Ambiguous("unclosed backtick")
|
|
262
|
+
inner.append(text[i + 1 : j])
|
|
263
|
+
out.append(SUBST)
|
|
264
|
+
i = j + 1
|
|
265
|
+
continue
|
|
266
|
+
elif text.startswith("$(", i) or (not quote and text[i : i + 2] in ("<(", ">(")):
|
|
267
|
+
j = _close_paren(text, i + 2)
|
|
268
|
+
inner.append(text[i + 2 : j])
|
|
269
|
+
out.append(SUBST)
|
|
270
|
+
i = j + 1
|
|
271
|
+
continue
|
|
272
|
+
out.append(c)
|
|
273
|
+
i += 1
|
|
274
|
+
if quote:
|
|
275
|
+
raise Ambiguous("unclosed quote")
|
|
276
|
+
return "".join(out), inner
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def tokenize(text):
|
|
280
|
+
try:
|
|
281
|
+
lexer = shlex.shlex(text.replace("\n", " ; "), posix=True, punctuation_chars=";&|<>()")
|
|
282
|
+
lexer.whitespace_split = True
|
|
283
|
+
lexer.commenters = ""
|
|
284
|
+
return list(lexer)
|
|
285
|
+
except ValueError as e:
|
|
286
|
+
raise Ambiguous(f"cannot parse the command ({e})") from None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def split_segments(tokens):
|
|
290
|
+
segs, cur, prev = [], [], None
|
|
291
|
+
for tok in tokens:
|
|
292
|
+
if tok in SEPARATORS or tok in ("(", ")"):
|
|
293
|
+
if cur:
|
|
294
|
+
segs.append([prev, cur, tok])
|
|
295
|
+
elif segs and segs[-1][2] is None:
|
|
296
|
+
segs[-1][2] = tok
|
|
297
|
+
if tok in ("(", ")"):
|
|
298
|
+
segs.append([tok, None, None])
|
|
299
|
+
prev, cur = tok, []
|
|
300
|
+
else:
|
|
301
|
+
cur.append(tok)
|
|
302
|
+
if cur:
|
|
303
|
+
segs.append([prev, cur, None])
|
|
304
|
+
for i, seg in enumerate(segs):
|
|
305
|
+
if seg[1] is not None and seg[2] is None and i + 1 < len(segs):
|
|
306
|
+
seg[2] = segs[i + 1][0]
|
|
307
|
+
return segs
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def strip_redirects(argv):
|
|
311
|
+
args, i = [], 0
|
|
312
|
+
while i < len(argv):
|
|
313
|
+
tok = argv[i]
|
|
314
|
+
if tok.isdigit() and i + 1 < len(argv) and argv[i + 1] in REDIRECTS:
|
|
315
|
+
i += 1
|
|
316
|
+
tok = argv[i]
|
|
317
|
+
if tok in REDIRECTS:
|
|
318
|
+
i += 2
|
|
319
|
+
continue
|
|
320
|
+
args.append(tok)
|
|
321
|
+
i += 1
|
|
322
|
+
return args
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def strip_keywords(argv):
|
|
326
|
+
i = 0
|
|
327
|
+
while i < len(argv) and argv[i] in KEYWORDS:
|
|
328
|
+
i += 1
|
|
329
|
+
return argv[i:]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def unwrap(argv):
|
|
333
|
+
env, i = {}, 0
|
|
334
|
+
while i < len(argv):
|
|
335
|
+
tok = argv[i]
|
|
336
|
+
base = os.path.basename(tok)
|
|
337
|
+
if ENV_ASSIGN.match(tok):
|
|
338
|
+
key, _, value = tok.partition("=")
|
|
339
|
+
env[key] = value
|
|
340
|
+
i += 1
|
|
341
|
+
elif base == "env":
|
|
342
|
+
i += 1
|
|
343
|
+
while i < len(argv) and argv[i].startswith("-"):
|
|
344
|
+
if argv[i] in ("-i", "--ignore-environment", "-"):
|
|
345
|
+
i += 1
|
|
346
|
+
elif argv[i] in ("-u", "--unset"):
|
|
347
|
+
i += 2
|
|
348
|
+
elif argv[i].startswith("--unset="):
|
|
349
|
+
i += 1
|
|
350
|
+
else:
|
|
351
|
+
raise Ambiguous(f"env {argv[i]} hides what runs")
|
|
352
|
+
elif base in SIMPLE_WRAPPERS:
|
|
353
|
+
i += 1
|
|
354
|
+
while i < len(argv) and argv[i].startswith("-"):
|
|
355
|
+
if argv[i] in ("-v", "-V"):
|
|
356
|
+
return env, []
|
|
357
|
+
if argv[i] not in ("-p", "--"):
|
|
358
|
+
raise Ambiguous(f"{base} {argv[i]} hides what runs")
|
|
359
|
+
i += 1
|
|
360
|
+
else:
|
|
361
|
+
break
|
|
362
|
+
return env, argv[i:]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_git(word):
|
|
366
|
+
base = os.path.basename(word)
|
|
367
|
+
return base == "git" or base.startswith("git-")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _raidlock_python(argv):
|
|
371
|
+
return len(argv) >= 3 and argv[1] == "-m" and argv[2] == "raidlock"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _cd_target(argv, cwds):
|
|
375
|
+
args = argv[1:]
|
|
376
|
+
if args and args[0] == "--":
|
|
377
|
+
args = args[1:]
|
|
378
|
+
if len(args) != 1 or args[0].startswith(("-", "+")):
|
|
379
|
+
return {None}
|
|
380
|
+
target = args[0]
|
|
381
|
+
if target == "-" or "$" in target or SUBST in target or GLOB.search(target):
|
|
382
|
+
return {None}
|
|
383
|
+
out = set()
|
|
384
|
+
for c in cwds:
|
|
385
|
+
out.add(None if c is None else os.path.normpath(os.path.join(c, os.path.expanduser(target))))
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def analyze(command, cwds, depth=0, exports=None, all_commands=False):
|
|
390
|
+
if depth > MAX_DEPTH:
|
|
391
|
+
raise Ambiguous("commands nested too deep")
|
|
392
|
+
text, inners = extract_substitutions(strip_heredocs(command))
|
|
393
|
+
cur = set(cwds)
|
|
394
|
+
seen = set(cur)
|
|
395
|
+
stack = []
|
|
396
|
+
carried = dict(exports or {})
|
|
397
|
+
ops = []
|
|
398
|
+
for prev, argv, nxt in split_segments(tokenize(text)):
|
|
399
|
+
if argv is None:
|
|
400
|
+
if prev == "(":
|
|
401
|
+
stack.append(set(cur))
|
|
402
|
+
elif stack:
|
|
403
|
+
cur = stack.pop()
|
|
404
|
+
else:
|
|
405
|
+
raise Ambiguous("unbalanced parentheses")
|
|
406
|
+
continue
|
|
407
|
+
raw_argv = argv
|
|
408
|
+
argv = strip_keywords(strip_redirects(argv))
|
|
409
|
+
prefix, argv = unwrap(argv)
|
|
410
|
+
if all_commands:
|
|
411
|
+
ops.append({"env": {**carried, **prefix}, "argv": argv, "raw": raw_argv, "cwds": frozenset(cur)})
|
|
412
|
+
if not argv:
|
|
413
|
+
carried.update(prefix)
|
|
414
|
+
continue
|
|
415
|
+
base = os.path.basename(argv[0])
|
|
416
|
+
if ("$" in argv[0] or SUBST in argv[0]) and mentions_git(command):
|
|
417
|
+
raise Ambiguous("the executable is computed at run time next to git")
|
|
418
|
+
env = {**carried, **prefix}
|
|
419
|
+
if base in ("cd", "pushd", "popd"):
|
|
420
|
+
new = {None} if base == "popd" else _cd_target(argv, cur)
|
|
421
|
+
if env.get("CDPATH") or os.environ.get("CDPATH"):
|
|
422
|
+
new = {None}
|
|
423
|
+
cur = new if prev in (None, ";", "(", ")") and nxt == "&&" else cur | new
|
|
424
|
+
seen |= cur
|
|
425
|
+
continue
|
|
426
|
+
if base == "export":
|
|
427
|
+
for item in argv[1:]:
|
|
428
|
+
if ENV_ASSIGN.match(item):
|
|
429
|
+
key, _, value = item.partition("=")
|
|
430
|
+
carried[key] = value
|
|
431
|
+
continue
|
|
432
|
+
if is_git(argv[0]):
|
|
433
|
+
if not all_commands:
|
|
434
|
+
ops.append({"env": env, "argv": argv, "cwds": frozenset(cur)})
|
|
435
|
+
continue
|
|
436
|
+
if base in SHELLS:
|
|
437
|
+
script = None
|
|
438
|
+
for i, a in enumerate(argv[1:], 1):
|
|
439
|
+
if a == "--":
|
|
440
|
+
break
|
|
441
|
+
if a.startswith("-") and not a.startswith("--") and "c" in a[1:]:
|
|
442
|
+
if i + 1 >= len(argv):
|
|
443
|
+
raise Ambiguous(f"{base} -c without a script")
|
|
444
|
+
script = argv[i + 1]
|
|
445
|
+
break
|
|
446
|
+
if script is not None:
|
|
447
|
+
ops += analyze(script, cur, depth + 1, env, all_commands)
|
|
448
|
+
elif len([a for a in argv[1:] if not a.startswith("-")]) == 0 and mentions_git(command):
|
|
449
|
+
raise Ambiguous(f"{base} reads commands from stdin next to git")
|
|
450
|
+
continue
|
|
451
|
+
if base == "eval":
|
|
452
|
+
ops += analyze(" ".join(argv[1:]), cur, depth + 1, env, all_commands)
|
|
453
|
+
cur = {None}
|
|
454
|
+
seen |= cur
|
|
455
|
+
continue
|
|
456
|
+
if base in ("source", "."):
|
|
457
|
+
cur = {None}
|
|
458
|
+
seen |= cur
|
|
459
|
+
continue
|
|
460
|
+
if INTERPRETER.match(base):
|
|
461
|
+
if not _raidlock_python(argv) and any(mentions_git(a) for a in argv[1:]):
|
|
462
|
+
raise Ambiguous(f"{base} runs code that mentions git")
|
|
463
|
+
continue
|
|
464
|
+
if base in LAUNCHERS:
|
|
465
|
+
if any(mentions_git(a) for a in argv[1:]):
|
|
466
|
+
raise Ambiguous(f"{base} launches git indirectly")
|
|
467
|
+
continue
|
|
468
|
+
if base not in NON_EXEC and any(is_git(a) and os.path.basename(a) == "git" for a in argv[1:]):
|
|
469
|
+
raise Ambiguous(f"{base} is passed git as an argument")
|
|
470
|
+
for inner in inners:
|
|
471
|
+
ops += analyze(inner, seen, depth + 1, carried, all_commands)
|
|
472
|
+
return ops
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def parse_git(argv):
|
|
476
|
+
base = os.path.basename(argv[0])
|
|
477
|
+
if base != "git":
|
|
478
|
+
return [], base[4:], argv[1:]
|
|
479
|
+
cdirs, i = [], 1
|
|
480
|
+
while i < len(argv) and argv[i].startswith("-"):
|
|
481
|
+
a = argv[i]
|
|
482
|
+
if a == "-C":
|
|
483
|
+
if i + 1 >= len(argv):
|
|
484
|
+
raise Ambiguous("git -C without a directory")
|
|
485
|
+
cdirs.append(argv[i + 1])
|
|
486
|
+
i += 2
|
|
487
|
+
continue
|
|
488
|
+
if a in ("--icase-pathspecs", "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs"):
|
|
489
|
+
raise Forbidden("pathspec mode overrides are not safely auditable")
|
|
490
|
+
if a in GLOBAL_FLAGS:
|
|
491
|
+
i += 1
|
|
492
|
+
continue
|
|
493
|
+
raise Forbidden(f"git option {a.split('=', 1)[0]} is not allowed")
|
|
494
|
+
if i >= len(argv):
|
|
495
|
+
return cdirs, "", []
|
|
496
|
+
return cdirs, argv[i], argv[i + 1 :]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def split_args(args, value_opts=(), short_values=""):
|
|
500
|
+
flags, values, pos, after = set(), {}, [], []
|
|
501
|
+
i, dashdash = 0, False
|
|
502
|
+
while i < len(args):
|
|
503
|
+
a = args[i]
|
|
504
|
+
if dashdash:
|
|
505
|
+
after.append(a)
|
|
506
|
+
elif a == "--":
|
|
507
|
+
dashdash = True
|
|
508
|
+
elif a.startswith("--"):
|
|
509
|
+
name, eq, value = a.partition("=")
|
|
510
|
+
flags.add(name)
|
|
511
|
+
if eq:
|
|
512
|
+
values.setdefault(name, []).append(value)
|
|
513
|
+
elif name in value_opts and i + 1 < len(args):
|
|
514
|
+
values.setdefault(name, []).append(args[i + 1])
|
|
515
|
+
i += 1
|
|
516
|
+
elif a.startswith("-") and len(a) > 1:
|
|
517
|
+
for j, ch in enumerate(a[1:], 1):
|
|
518
|
+
flags.add("-" + ch)
|
|
519
|
+
if ch in short_values:
|
|
520
|
+
rest = a[j + 1 :]
|
|
521
|
+
if rest:
|
|
522
|
+
values.setdefault("-" + ch, []).append(rest)
|
|
523
|
+
elif i + 1 < len(args):
|
|
524
|
+
values.setdefault("-" + ch, []).append(args[i + 1])
|
|
525
|
+
i += 1
|
|
526
|
+
break
|
|
527
|
+
else:
|
|
528
|
+
pos.append(a)
|
|
529
|
+
i += 1
|
|
530
|
+
return flags, values, pos, after, dashdash
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _paths(items):
|
|
534
|
+
if not items:
|
|
535
|
+
return WHOLE, []
|
|
536
|
+
for p in items:
|
|
537
|
+
if p.startswith(":") or "$" in p or SUBST in p:
|
|
538
|
+
return WHOLE, []
|
|
539
|
+
return PATHS, list(items)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _exec_check(sub, flags, args):
|
|
543
|
+
for f in flags:
|
|
544
|
+
if f.startswith("--") and any(option.startswith(f) and option != f for option in EXEC_OPTIONS + OUTPUT_OPTIONS):
|
|
545
|
+
raise Forbidden(f"git {sub} abbreviated option {f} is not safely auditable")
|
|
546
|
+
if f in EXEC_OPTIONS:
|
|
547
|
+
raise Forbidden(f"git {sub} {f} runs an external command")
|
|
548
|
+
if sub in ("rebase",) and ("-x" in flags or "--exec" in flags):
|
|
549
|
+
raise Forbidden("git rebase --exec runs an external command")
|
|
550
|
+
if sub in ("fetch", "pull", "clone", "ls-remote", "archive") and "-u" in flags:
|
|
551
|
+
raise Forbidden(f"git {sub} -u runs an external command")
|
|
552
|
+
if sub == "grep" and "-O" in flags:
|
|
553
|
+
raise Forbidden("git grep -O runs an external command")
|
|
554
|
+
if any("ext::" in a or "fd::" in a for a in args):
|
|
555
|
+
raise Forbidden("git ext:: and fd:: transports run external commands")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def classify(sub, args):
|
|
559
|
+
"""Return (kind, paths, written) for one git subcommand: kind is read, ref, paths or whole."""
|
|
560
|
+
flags, values, pos, after, dashdash = split_args(
|
|
561
|
+
args,
|
|
562
|
+
(
|
|
563
|
+
"--output",
|
|
564
|
+
"--source",
|
|
565
|
+
"--pathspec-from-file",
|
|
566
|
+
"--message",
|
|
567
|
+
"--file",
|
|
568
|
+
"--author",
|
|
569
|
+
"--date",
|
|
570
|
+
"--template",
|
|
571
|
+
"--reuse-message",
|
|
572
|
+
"--reedit-message",
|
|
573
|
+
"--fixup",
|
|
574
|
+
"--squash",
|
|
575
|
+
"--trailer",
|
|
576
|
+
"--cleanup",
|
|
577
|
+
"--exclude",
|
|
578
|
+
"--orphan",
|
|
579
|
+
"--conflict",
|
|
580
|
+
"--chmod",
|
|
581
|
+
"--upload-pack",
|
|
582
|
+
"--receive-pack",
|
|
583
|
+
"--exec",
|
|
584
|
+
"--extcmd",
|
|
585
|
+
"--tool",
|
|
586
|
+
"--open-files-in-pager",
|
|
587
|
+
"--format",
|
|
588
|
+
"--pretty",
|
|
589
|
+
"--sort",
|
|
590
|
+
"--contains",
|
|
591
|
+
"--points-at",
|
|
592
|
+
),
|
|
593
|
+
_short_values(sub),
|
|
594
|
+
)
|
|
595
|
+
if "$" in sub or SUBST in sub:
|
|
596
|
+
raise Ambiguous("the git subcommand is computed at run time")
|
|
597
|
+
_exec_check(sub, flags, args)
|
|
598
|
+
if sub in FORBIDDEN_SUBS:
|
|
599
|
+
raise Forbidden(f"git {sub} runs external commands or rewrites the repo")
|
|
600
|
+
if "--pathspec-from-file" in flags:
|
|
601
|
+
return WHOLE, [], []
|
|
602
|
+
written = [v for opt in OUTPUT_OPTIONS for v in values.get(opt, [])]
|
|
603
|
+
if sub in READ_ONLY or sub == "":
|
|
604
|
+
return READ, [], written
|
|
605
|
+
if any(a.startswith(":") for a in pos + after):
|
|
606
|
+
raise Forbidden("magic pathspecs are not safely auditable")
|
|
607
|
+
if sub == "branch":
|
|
608
|
+
listing = {
|
|
609
|
+
"--list",
|
|
610
|
+
"-l",
|
|
611
|
+
"-a",
|
|
612
|
+
"--all",
|
|
613
|
+
"-r",
|
|
614
|
+
"--remotes",
|
|
615
|
+
"-v",
|
|
616
|
+
"-vv",
|
|
617
|
+
"--verbose",
|
|
618
|
+
"--show-current",
|
|
619
|
+
"--contains",
|
|
620
|
+
"--no-contains",
|
|
621
|
+
"--merged",
|
|
622
|
+
"--no-merged",
|
|
623
|
+
"--sort",
|
|
624
|
+
"--format",
|
|
625
|
+
"--points-at",
|
|
626
|
+
"--column",
|
|
627
|
+
"--no-column",
|
|
628
|
+
"--color",
|
|
629
|
+
"--no-color",
|
|
630
|
+
"-i",
|
|
631
|
+
"--ignore-case",
|
|
632
|
+
"--abbrev",
|
|
633
|
+
"--no-abbrev",
|
|
634
|
+
"-q",
|
|
635
|
+
"--quiet",
|
|
636
|
+
}
|
|
637
|
+
if "--list" in flags or (not pos and flags <= listing):
|
|
638
|
+
return READ, [], written
|
|
639
|
+
return REF, [], written
|
|
640
|
+
if sub == "tag":
|
|
641
|
+
if (
|
|
642
|
+
"-l" in flags
|
|
643
|
+
or "--list" in flags
|
|
644
|
+
or (not pos and flags <= {"-n", "--sort", "--format", "--contains", "--points-at"})
|
|
645
|
+
):
|
|
646
|
+
return READ, [], written
|
|
647
|
+
return REF, [], written
|
|
648
|
+
if sub == "remote":
|
|
649
|
+
if not pos or pos[0] in ("show", "get-url") or flags <= {"-v", "--verbose"} and not pos:
|
|
650
|
+
return READ, [], written
|
|
651
|
+
return REF, [], written
|
|
652
|
+
if sub == "config":
|
|
653
|
+
if flags & {
|
|
654
|
+
"--add",
|
|
655
|
+
"--replace-all",
|
|
656
|
+
"--unset",
|
|
657
|
+
"--unset-all",
|
|
658
|
+
"--remove-section",
|
|
659
|
+
"--rename-section",
|
|
660
|
+
"--edit",
|
|
661
|
+
"-e",
|
|
662
|
+
}:
|
|
663
|
+
raise Forbidden("git config writes can install commands")
|
|
664
|
+
if flags & {"--get", "--get-all", "--get-regexp", "--list", "-l"} or (pos and pos[0] in ("get", "list")):
|
|
665
|
+
return READ, [], written
|
|
666
|
+
raise Forbidden("git config writes can install commands; edit config outside agents")
|
|
667
|
+
if sub == "stash":
|
|
668
|
+
action = pos[0] if pos else "push"
|
|
669
|
+
if action in ("list", "show"):
|
|
670
|
+
return READ, [], written
|
|
671
|
+
if action in ("drop", "clear", "create", "store"):
|
|
672
|
+
return REF, [], written
|
|
673
|
+
if action in ("push", "save") or not pos:
|
|
674
|
+
items = (pos[1:] if pos and action == "push" else []) + after
|
|
675
|
+
return _paths(items) + (written,)
|
|
676
|
+
return WHOLE, [], written
|
|
677
|
+
if sub in REF_ONLY:
|
|
678
|
+
return REF, [], written
|
|
679
|
+
if sub in WHOLE_TREE:
|
|
680
|
+
return WHOLE, [], written
|
|
681
|
+
if sub == "restore":
|
|
682
|
+
return _paths(pos + after) + (written,)
|
|
683
|
+
if sub == "checkout":
|
|
684
|
+
if dashdash:
|
|
685
|
+
return _paths(after) + (written,)
|
|
686
|
+
if not pos and not flags & {"--orphan", "-f", "--force", "-p", "--patch", "-m", "--merge"}:
|
|
687
|
+
return REF, [], written
|
|
688
|
+
return WHOLE, [], written
|
|
689
|
+
if sub == "switch":
|
|
690
|
+
if flags & {"-c", "-C", "--create", "--force-create"} and not pos:
|
|
691
|
+
return REF, [], written
|
|
692
|
+
return WHOLE, [], written
|
|
693
|
+
if sub == "reset":
|
|
694
|
+
if flags & {"--hard", "--merge", "--keep", "-p", "--patch"}:
|
|
695
|
+
return WHOLE, [], written
|
|
696
|
+
if "--soft" in flags:
|
|
697
|
+
return REF, [], written
|
|
698
|
+
if dashdash:
|
|
699
|
+
return _paths(after) + (written,)
|
|
700
|
+
if pos and REV.match(pos[0]) and pos[1:]:
|
|
701
|
+
return _paths(pos[1:]) + (written,)
|
|
702
|
+
return WHOLE, [], written
|
|
703
|
+
if sub == "clean":
|
|
704
|
+
if flags & {"-n", "--dry-run"}:
|
|
705
|
+
return READ, [], written
|
|
706
|
+
return _paths(pos + after) + (written,)
|
|
707
|
+
if sub == "apply":
|
|
708
|
+
if flags & {"--check", "--stat", "--numstat", "--summary"} and "--apply" not in flags:
|
|
709
|
+
return READ, [], written
|
|
710
|
+
return WHOLE, [], written
|
|
711
|
+
if sub in ("rm", "mv"):
|
|
712
|
+
if flags & {"-n", "--dry-run"}:
|
|
713
|
+
return READ, [], written
|
|
714
|
+
return _paths(pos + after) + (written,)
|
|
715
|
+
if sub == "add":
|
|
716
|
+
if flags & {"-n", "--dry-run"}:
|
|
717
|
+
return READ, [], written
|
|
718
|
+
if flags & {"-p", "--patch", "-i", "--interactive", "-e", "--edit"}:
|
|
719
|
+
return WHOLE, [], written
|
|
720
|
+
return _paths(pos + after) + (written,)
|
|
721
|
+
if sub == "commit":
|
|
722
|
+
if flags & {"-a", "--all", "-p", "--patch", "--interactive"}:
|
|
723
|
+
return WHOLE, [], written
|
|
724
|
+
items = pos + after
|
|
725
|
+
if items:
|
|
726
|
+
return _paths(items) + (written,)
|
|
727
|
+
return REF, [], written
|
|
728
|
+
raise Ambiguous(f"unsupported git subcommand {sub!r}; use a directly auditable command")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _short_values(sub):
|
|
732
|
+
return {
|
|
733
|
+
"commit": "mFCct",
|
|
734
|
+
"restore": "s",
|
|
735
|
+
"checkout": "bB",
|
|
736
|
+
"switch": "cC",
|
|
737
|
+
"clean": "e",
|
|
738
|
+
"stash": "m",
|
|
739
|
+
"diff": "",
|
|
740
|
+
"log": "n",
|
|
741
|
+
"show": "",
|
|
742
|
+
"tag": "mFu",
|
|
743
|
+
"branch": "",
|
|
744
|
+
"add": "",
|
|
745
|
+
"rm": "",
|
|
746
|
+
"mv": "",
|
|
747
|
+
"fetch": "uo",
|
|
748
|
+
"pull": "u",
|
|
749
|
+
"push": "o",
|
|
750
|
+
"rebase": "xs",
|
|
751
|
+
"merge": "msX",
|
|
752
|
+
"grep": "efO",
|
|
753
|
+
}.get(sub, "")
|