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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. 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, "")