obey-cli 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 (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2005 @@
1
+ """obey rule engine: one implementation of every rule check, shared by the checkers and the generated hooks.
2
+
3
+ Standard library only, Python 3.9+. Vendored into a repository's .obey/hooks/ by `obey enforce`, so that what a
4
+ hook blocks is exactly what `obey run` measures. https://github.com/B0yko/obey
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import posixpath
11
+ import re
12
+ import shlex
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ WORKDIR = "/srv/obey/work" # where `obey run` puts the repository inside the sandbox
16
+ DENY_MARK = "obey: this breaks the rule" # how every refusal starts; the stop hook finds its own refusals by it
17
+ STOP_TYPES = {"command_required", "path_required_with", "final_message", "content_required_added"}
18
+ DEFAULT_MANIFESTS = [
19
+ "**/package.json",
20
+ "**/pyproject.toml",
21
+ "**/requirements*.txt",
22
+ "**/requirements*.in",
23
+ "**/go.mod",
24
+ "**/Cargo.toml",
25
+ "**/setup.cfg",
26
+ "**/Pipfile",
27
+ ]
28
+
29
+ # --------------------------------------------------------------------------------------------- globs
30
+
31
+ _GLOB_CACHE: Dict[str, "re.Pattern[str]"] = {}
32
+
33
+
34
+ def _glob_regex(glob: str) -> "re.Pattern[str]":
35
+ cached = _GLOB_CACHE.get(glob)
36
+ if cached is not None:
37
+ return cached
38
+ g = glob.strip().lstrip("/")
39
+ if g.startswith("./"):
40
+ g = g[2:]
41
+ if "/" not in g.rstrip("/"):
42
+ g = "**/" + g # a bare pattern matches the basename anywhere
43
+ if g.endswith("/"):
44
+ g += "**"
45
+ out = ""
46
+ i = 0
47
+ while i < len(g):
48
+ if g.startswith("**/", i):
49
+ out += "(?:[^/]+/)*"
50
+ i += 3
51
+ elif g.startswith("**", i):
52
+ out += ".*"
53
+ i += 2
54
+ elif g[i] == "*":
55
+ out += "[^/]*"
56
+ i += 1
57
+ elif g[i] == "?":
58
+ out += "[^/]"
59
+ i += 1
60
+ elif g[i] == "[":
61
+ j = g.find("]", i + 1)
62
+ if j == -1:
63
+ out += re.escape(g[i])
64
+ i += 1
65
+ else:
66
+ out += "[" + g[i + 1 : j].replace("!", "^", 1) + "]"
67
+ i = j + 1
68
+ else:
69
+ out += re.escape(g[i])
70
+ i += 1
71
+ pattern = re.compile("^" + out + "$")
72
+ _GLOB_CACHE[glob] = pattern
73
+ return pattern
74
+
75
+
76
+ def glob_match(globs: List[str], path: str) -> bool:
77
+ p = path.strip().lstrip("/")
78
+ if p.startswith("./"):
79
+ p = p[2:]
80
+ return any(_glob_regex(g).match(p) or (g.rstrip("/").endswith("/**") and p == g.rstrip("/")[:-3]) for g in globs)
81
+
82
+
83
+ # --------------------------------------------------------------------------------------------- shell
84
+
85
+ # Not followed (a command the engine cannot see is not blocked): `ssh host '...'`, `docker run image cmd`, code
86
+ # that spawns processes inside `python -c` / `node -e` (only its file writes are read), `watch`, `flock`, `su -c`
87
+ # and `script -c` wrappers, `bash <(...)` / `source <(...)`, `curl ... | sh`, aliases, shell functions, and a
88
+ # command word that comes from a variable (`$CMD`).
89
+
90
+ _ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
91
+ _SIMPLE_WRAPPERS = {
92
+ "sudo",
93
+ "time",
94
+ "nohup",
95
+ "exec",
96
+ "command",
97
+ "builtin",
98
+ "nice",
99
+ "doas",
100
+ "stdbuf",
101
+ "caffeinate",
102
+ "corepack",
103
+ "setsid",
104
+ }
105
+ _SHELL_NAMES = {"bash", "sh", "zsh", "dash", "ksh", "ash", "fish"}
106
+ # `<<EOF`, `<<-'EOF'`, `<<"EOF"`, `<<\EOF`, but not the here-string `<<<`. Groups: single-quoted, double-quoted,
107
+ # backslash, bare delimiter.
108
+ _HEREDOC_RE = re.compile(r"""(?<!<)<<(?!<)-?[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|(\\?)([^\s;&|<>()'"\\]+))""")
109
+ _REDIR_RE = re.compile(r"^(\d+|&)?(>>|>\||>&|>|<<<|<<-|<<|<&|<>|<)(.*)$", re.S)
110
+ _RESERVED_RE = re.compile(r"^(?:(?:!|do|then|else|elif|if|while|until|\{)(?:\s+|$))+")
111
+ _CLOSERS = {"}", "fi", "done", "esac", "then", "else", "do"}
112
+ _CD_NAMES = {"cd", "pushd", "popd"}
113
+ _PLAIN_RUN = re.compile(r"""[^\\'"`$<>()#;&|\n]+""")
114
+ _WORD_RUN = re.compile(r"""[^\s\\'"`$<>]+""")
115
+ _DQ_SPECIAL = re.compile(r"""["\\$`]""")
116
+ _PAREN_SPECIAL = re.compile(r"""[()'"\\`<#]""")
117
+ _SUBST_SPECIAL = re.compile(r"""['"\\`$<>]""")
118
+ _ANSI_SPECIAL = re.compile(r"""[\\']""")
119
+
120
+
121
+ class _Redir(str):
122
+ """A word that starts with an unquoted redirection operator: `>f`, `2>>f`, `&>`, `<<<`, `>|`."""
123
+
124
+ __slots__ = ()
125
+
126
+
127
+ def _hd(m: "re.Match[str]") -> Tuple[str, bool]:
128
+ """(delimiter, quoted) of a heredoc operator matched by _HEREDOC_RE."""
129
+ if m.group(1) is not None:
130
+ return m.group(1), True
131
+ if m.group(2) is not None:
132
+ return m.group(2), True
133
+ return m.group(4), bool(m.group(3))
134
+
135
+
136
+ def _terminator(delim: str) -> "re.Pattern[str]":
137
+ return re.compile(r"(?m)^[ \t]*" + re.escape(delim) + r"[ \t]*$")
138
+
139
+
140
+ def _read_dq(s: str, i: int, depth: int = 0) -> Tuple[str, int, bool]:
141
+ """The double-quoted string at s[i]: (its text with escapes resolved, index after it, whether it was closed)."""
142
+ out: List[str] = []
143
+ n = len(s)
144
+ j = i + 1
145
+ while j < n:
146
+ m = _DQ_SPECIAL.search(s, j)
147
+ if m is None:
148
+ out.append(s[j:])
149
+ return "".join(out), n, False
150
+ k = m.start()
151
+ out.append(s[j:k])
152
+ c = s[k]
153
+ if c == '"':
154
+ return "".join(out), k + 1, True
155
+ if c == "\\":
156
+ nxt = s[k + 1 : k + 2]
157
+ if nxt and nxt in '"\\$`':
158
+ out.append(nxt)
159
+ j = k + 2
160
+ elif nxt == "\n":
161
+ j = k + 2
162
+ else:
163
+ out.append("\\")
164
+ j = k + 1
165
+ elif c == "$" and s.startswith("(", k + 1) and depth < 20:
166
+ e = _skip_paren(s, k + 1, depth + 1)
167
+ out.append(s[k:e]) # a command substitution stays as written
168
+ j = e
169
+ elif c == "`":
170
+ e = s.find("`", k + 1)
171
+ e = n if e == -1 else e + 1
172
+ out.append(s[k:e])
173
+ j = e
174
+ else:
175
+ out.append(c)
176
+ j = k + 1
177
+ return "".join(out), n, False
178
+
179
+
180
+ def _skip_ansi(s: str, i: int) -> int:
181
+ """Index after the `$'...'` string at s[i]."""
182
+ n = len(s)
183
+ j = i + 2
184
+ while j < n:
185
+ m = _ANSI_SPECIAL.search(s, j)
186
+ if m is None:
187
+ return n
188
+ if s[m.start()] == "'":
189
+ return m.start() + 1
190
+ j = m.start() + 2
191
+ return n
192
+
193
+
194
+ def _ansi_text(raw: str) -> str:
195
+ return re.sub(r"\\(.)", lambda m: {"n": "\n", "t": "\t", "r": "\r"}.get(m.group(1), m.group(1)), raw, flags=re.S)
196
+
197
+
198
+ def _skip_paren(s: str, i: int, depth: int = 0) -> int:
199
+ """Index after the `)` that closes the `(` at s[i] (len(s) when it is never closed).
200
+
201
+ Quotes, backslashes, nested parentheses and heredoc bodies inside are skipped, so `)` in them does not count.
202
+ """
203
+ n = len(s)
204
+ level = 0
205
+ j = i
206
+ while j < n:
207
+ m = _PAREN_SPECIAL.search(s, j)
208
+ if m is None:
209
+ return n
210
+ j = m.start()
211
+ c = s[j]
212
+ if c == "(":
213
+ level += 1
214
+ elif c == ")":
215
+ level -= 1
216
+ if level == 0:
217
+ return j + 1
218
+ elif c == "'":
219
+ if j > 0 and s[j - 1] == "$":
220
+ j = _skip_ansi(s, j - 1)
221
+ continue
222
+ k = s.find("'", j + 1)
223
+ j = n if k == -1 else k
224
+ elif c == '"':
225
+ if depth >= 20:
226
+ return n
227
+ j = _read_dq(s, j, depth + 1)[1]
228
+ continue
229
+ elif c == "\\":
230
+ j += 1
231
+ elif c == "`":
232
+ k = s.find("`", j + 1)
233
+ j = n if k == -1 else k
234
+ elif c == "<":
235
+ hm = _HEREDOC_RE.match(s, j)
236
+ if hm:
237
+ nl = s.find("\n", hm.end())
238
+ term = _terminator(_hd(hm)[0]).search(s, nl + 1) if nl != -1 else None
239
+ j = term.end() if term else hm.end()
240
+ continue
241
+ elif c == "#" and (j == 0 or s[j - 1] in " \t\n"):
242
+ k = s.find("\n", j)
243
+ j = n if k == -1 else k
244
+ continue
245
+ j += 1
246
+ return n
247
+
248
+
249
+ def _split_top(cmd: str) -> List[Tuple[str, str]]:
250
+ """Split a command line on unquoted separators: [(separator before the part, part)].
251
+
252
+ A heredoc body stays attached to its command, also when more commands follow on the line of the operator.
253
+ The parentheses of a subshell come back as the parts ("(", "") and (")", ""), so a caller can scope a `cd`;
254
+ command and process substitutions are not split, and a quoted string or `$(...)` is read as a unit in linear
255
+ time.
256
+ """
257
+ parts: List[Tuple[str, str]] = []
258
+ buf: List[str] = []
259
+ last = "" # last character in buf ("" when it is empty)
260
+ sep_before = ""
261
+ pending: List[Tuple[int, str]] = [] # heredocs waiting for their body: (index of their part, delimiter)
262
+ i = 0
263
+ n = len(cmd)
264
+
265
+ def add(text: str) -> None:
266
+ nonlocal last
267
+ buf.append(text)
268
+ last = text[-1]
269
+
270
+ def flush() -> None:
271
+ nonlocal last
272
+ text = "".join(buf).strip()
273
+ if text:
274
+ parts.append((sep_before, text))
275
+ buf.clear()
276
+ last = ""
277
+
278
+ while i < n:
279
+ c = cmd[i]
280
+ m = _PLAIN_RUN.match(cmd, i)
281
+ if m:
282
+ add(m.group())
283
+ i = m.end()
284
+ elif c == "\\":
285
+ add(cmd[i : i + 2])
286
+ i += 2
287
+ elif c == "'":
288
+ j = cmd.find("'", i + 1)
289
+ j = n if j == -1 else j + 1
290
+ add(cmd[i:j])
291
+ i = j
292
+ elif c == '"':
293
+ j = _read_dq(cmd, i)[1]
294
+ add(cmd[i:j])
295
+ i = j
296
+ elif c == "`":
297
+ j = cmd.find("`", i + 1)
298
+ j = n if j == -1 else j + 1
299
+ add(cmd[i:j])
300
+ i = j
301
+ elif c == "$":
302
+ if cmd.startswith("$(", i):
303
+ j = _skip_paren(cmd, i + 1)
304
+ elif cmd.startswith("$'", i):
305
+ j = _skip_ansi(cmd, i)
306
+ else:
307
+ j = i + 1
308
+ add(cmd[i:j])
309
+ i = j
310
+ elif c == "#":
311
+ if last == "" or last in " \t\n":
312
+ j = cmd.find("\n", i)
313
+ i = n if j == -1 else j
314
+ else:
315
+ add(c)
316
+ i += 1
317
+ elif c in "<>":
318
+ if cmd.startswith("(", i + 1):
319
+ j = _skip_paren(cmd, i + 1) # process substitution
320
+ add(cmd[i:j])
321
+ i = j
322
+ continue
323
+ hm = _HEREDOC_RE.match(cmd, i) if c == "<" else None
324
+ if hm:
325
+ add(cmd[i : hm.end()])
326
+ pending.append((len(parts), _hd(hm)[0]))
327
+ i = hm.end()
328
+ else:
329
+ add(c)
330
+ i += 1
331
+ elif c == "(" or c == ")":
332
+ flush()
333
+ parts.append((c, ""))
334
+ sep_before = ""
335
+ i += 1
336
+ else: # ; & | newline
337
+ two = cmd[i : i + 2]
338
+ if c == "\n" and pending:
339
+ pos = i + 1
340
+ for idx, delim in pending:
341
+ term = _terminator(delim).search(cmd, pos)
342
+ if term:
343
+ attach = "\n" + cmd[pos : term.start()] + delim
344
+ pos = term.end() + 1
345
+ else:
346
+ attach = "\n" + cmd[pos:]
347
+ pos = n + 1
348
+ if idx < len(parts):
349
+ sep, text = parts[idx]
350
+ parts[idx] = (sep, text + attach)
351
+ else:
352
+ add(attach)
353
+ pending.clear()
354
+ flush()
355
+ sep_before = "\n"
356
+ i = min(pos, n)
357
+ continue
358
+ if two in ("&&", "||"):
359
+ sep = two
360
+ elif c == "&" and (two == "&>" or (i > 0 and cmd[i - 1] in "<>")):
361
+ add(c) # &> file, >&2, <&3
362
+ i += 1
363
+ continue
364
+ elif c == "|" and i > 0 and cmd[i - 1] == ">":
365
+ add(c) # >| file
366
+ i += 1
367
+ continue
368
+ elif two == "|&":
369
+ sep = two
370
+ else:
371
+ sep = c
372
+ flush()
373
+ sep_before = sep
374
+ i += len(sep)
375
+ flush()
376
+ return parts
377
+
378
+
379
+ def _operator(s: str, i: int) -> str:
380
+ for op in (">>", ">|", ">&", "<<<", "<<-", "<<", "<&", "<>"):
381
+ if s.startswith(op, i):
382
+ return op
383
+ return s[i]
384
+
385
+
386
+ def _lex(seg: str) -> Tuple[List[str], int, Optional[Tuple[str, bool]]]:
387
+ """Words of one simple command with quotes resolved as a POSIX shell does, in linear time.
388
+
389
+ Returns (words, where a heredoc body starts or -1, (delimiter, quoted) of that heredoc). A word that starts with
390
+ an unquoted redirection operator is a _Redir (also when the operator is glued to the word before it, as in
391
+ `echo hi>f`); `$(...)`, backticks and `<(...)` stay inside their word as written. Raises ValueError on an
392
+ unterminated quote.
393
+ """
394
+ toks: List[str] = []
395
+ pieces: List[str] = []
396
+ n = len(seg)
397
+ i = 0
398
+ in_word = redir = atoms = False
399
+ plain = "" # the unquoted plain text of the word so far (an fd number or "&" in front of an operator)
400
+ body = -1
401
+ heredoc: Optional[Tuple[str, bool]] = None
402
+
403
+ def flush() -> None:
404
+ nonlocal in_word, redir, atoms, plain
405
+ if in_word:
406
+ word = "".join(pieces)
407
+ toks.append(_Redir(word) if redir else word)
408
+ pieces.clear()
409
+ in_word = redir = atoms = False
410
+ plain = ""
411
+
412
+ while i < n:
413
+ c = seg[i]
414
+ if c == "\n":
415
+ flush()
416
+ if heredoc is not None:
417
+ body = i + 1
418
+ break
419
+ i += 1
420
+ elif c.isspace():
421
+ flush()
422
+ i += 1
423
+ elif c == "\\":
424
+ if i + 1 >= n:
425
+ pieces.append("\\")
426
+ i += 1
427
+ elif seg[i + 1] == "\n": # line continuation
428
+ i += 2
429
+ continue
430
+ else:
431
+ pieces.append(seg[i + 1])
432
+ i += 2
433
+ in_word = atoms = True
434
+ elif c == "'":
435
+ j = seg.find("'", i + 1)
436
+ if j == -1:
437
+ raise ValueError("unterminated quote")
438
+ pieces.append(seg[i + 1 : j])
439
+ in_word = atoms = True
440
+ i = j + 1
441
+ elif c == '"':
442
+ text, j, closed = _read_dq(seg, i)
443
+ if not closed:
444
+ raise ValueError("unterminated quote")
445
+ pieces.append(text)
446
+ in_word = atoms = True
447
+ i = j
448
+ elif c == "$" and seg.startswith(("$(", "$'"), i):
449
+ if seg[i + 1] == "(":
450
+ j = _skip_paren(seg, i + 1)
451
+ pieces.append(seg[i:j])
452
+ else:
453
+ j = _skip_ansi(seg, i)
454
+ pieces.append(_ansi_text(seg[i + 2 : j - 1]))
455
+ in_word = atoms = True
456
+ i = j
457
+ elif c == "`" and seg.find("`", i + 1) != -1:
458
+ j = seg.find("`", i + 1) + 1
459
+ pieces.append(seg[i:j])
460
+ in_word = atoms = True
461
+ i = j
462
+ elif c in "<>":
463
+ if seg.startswith("(", i + 1):
464
+ j = _skip_paren(seg, i + 1)
465
+ pieces.append(seg[i:j])
466
+ in_word = atoms = True
467
+ i = j
468
+ continue
469
+ if in_word and (redir or atoms or not (plain.isdigit() or plain == "&")):
470
+ flush()
471
+ hm = _HEREDOC_RE.match(seg, i) if c == "<" else None
472
+ if hm:
473
+ heredoc = _hd(hm)
474
+ op = _operator(seg, i)
475
+ pieces.append(op)
476
+ in_word = redir = True
477
+ i += len(op)
478
+ else:
479
+ m = _WORD_RUN.match(seg, i)
480
+ run = m.group() if m else c
481
+ pieces.append(run)
482
+ in_word = True
483
+ plain += run
484
+ i += len(run)
485
+ flush()
486
+ return toks, body, heredoc
487
+
488
+
489
+ def _tokens(segment: str) -> Tuple[List[str], int, Optional[Tuple[str, bool]]]:
490
+ try:
491
+ return _lex(segment)
492
+ except ValueError:
493
+ return segment.split(), -1, None
494
+
495
+
496
+ def _split_redirs(tokens: List[str]) -> Tuple[List[str], List[Tuple[str, str, str]], List[str]]:
497
+ """(words without redirections, [(fd or "&", operator, target)], the redirection words as written)."""
498
+ clean: List[str] = []
499
+ redirs: List[Tuple[str, str, str]] = []
500
+ words: List[str] = []
501
+ i = 0
502
+ while i < len(tokens):
503
+ tok = tokens[i]
504
+ m = _REDIR_RE.match(tok) if isinstance(tok, _Redir) else None
505
+ if m is None:
506
+ clean.append(tok)
507
+ else:
508
+ target = m.group(3)
509
+ words.append(tok)
510
+ if not target and i + 1 < len(tokens) and not isinstance(tokens[i + 1], _Redir):
511
+ i += 1
512
+ target = tokens[i]
513
+ words.append(target)
514
+ redirs.append((m.group(1) or "", m.group(2), target))
515
+ i += 1
516
+ return clean, redirs, words
517
+
518
+
519
+ def _substitutions(text: str, literal: bool = False) -> List[str]:
520
+ """Command text inside $(...), `...`, <(...) and >(...), except in single quotes (`literal`: a heredoc body)."""
521
+ out: List[str] = []
522
+ i = 0
523
+ n = len(text)
524
+ in_dq = False
525
+ while i < n:
526
+ m = _SUBST_SPECIAL.search(text, i)
527
+ if m is None:
528
+ break
529
+ k = m.start()
530
+ c = text[k]
531
+ if c == "\\":
532
+ i = k + 2
533
+ elif c == "'":
534
+ if in_dq or literal:
535
+ i = k + 1
536
+ else:
537
+ e = text.find("'", k + 1)
538
+ i = n if e == -1 else e + 1
539
+ elif c == '"':
540
+ if not literal:
541
+ in_dq = not in_dq
542
+ i = k + 1
543
+ elif c == "`":
544
+ e = text.find("`", k + 1)
545
+ if e == -1:
546
+ break
547
+ out.append(text[k + 1 : e])
548
+ i = e + 1
549
+ elif c == "$" and text.startswith("'", k + 1) and not in_dq and not literal:
550
+ i = _skip_ansi(text, k)
551
+ elif text.startswith("(", k + 1) and (c == "$" or (c in "<>" and not in_dq)):
552
+ e = _skip_paren(text, k + 1)
553
+ out.append(text[k + 2 : e - 1 if text[e - 1 : e] == ")" else e])
554
+ i = e
555
+ else:
556
+ i = k + 1
557
+ return out
558
+
559
+
560
+ _VALUE_OPTS = {
561
+ "sudo": {"-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U"},
562
+ "env": {"-u", "-C", "-S"},
563
+ "nice": {"-n"},
564
+ "timeout": {"-s", "-k", "--signal", "--kill-after"},
565
+ "uv": {
566
+ "--with",
567
+ "--python",
568
+ "-p",
569
+ "--project",
570
+ "--directory",
571
+ "--package",
572
+ "--extra",
573
+ "--group",
574
+ "--env-file",
575
+ "--index",
576
+ "--from",
577
+ },
578
+ "npx": {"-p", "--package", "-c", "--call"},
579
+ "xargs": {"-I", "-n", "-L", "-P", "-d", "-s", "-E"},
580
+ }
581
+
582
+
583
+ def _drop_opts(tokens: List[str], tool: str) -> List[str]:
584
+ takes = _VALUE_OPTS.get(tool, set())
585
+ i = 0
586
+ while i < len(tokens) and tokens[i].startswith("-"):
587
+ i += 2 if tokens[i] in takes else 1
588
+ return tokens[i:]
589
+
590
+
591
+ def _env_split(rest: List[str]) -> Optional[List[str]]:
592
+ """The command of `env -S 'cmd args'` (also -S'cmd' and --split-string=...); None without such an option."""
593
+ k = 0
594
+ while k < len(rest):
595
+ tok = rest[k]
596
+ if tok in ("-S", "--split-string") and k + 1 < len(rest):
597
+ value, after = rest[k + 1], rest[k + 2 :]
598
+ elif tok.startswith("--split-string="):
599
+ value, after = tok.split("=", 1)[1], rest[k + 1 :]
600
+ elif tok.startswith("-S") and len(tok) > 2:
601
+ value, after = tok[2:], rest[k + 1 :]
602
+ elif tok in ("-u", "-C"):
603
+ k += 2
604
+ continue
605
+ elif tok.startswith("-") or _ASSIGN_RE.match(tok):
606
+ k += 1
607
+ continue
608
+ else:
609
+ return None
610
+ try:
611
+ return [*shlex.split(value), *after]
612
+ except ValueError:
613
+ return None
614
+ return None
615
+
616
+
617
+ def _unwrap(tokens: List[str]) -> List[List[str]]:
618
+ """Forms of one simple command with wrappers removed: [[as written], [unwrapped], ...]."""
619
+ forms = [tokens]
620
+ t = list(tokens)
621
+ for _ in range(8):
622
+ if not t:
623
+ break
624
+ head = t[0]
625
+ if "/" in head and not _ASSIGN_RE.match(head):
626
+ bare = head.rsplit("/", 1)[-1] # /usr/bin/npm, ./node_modules/.bin/eslint
627
+ if not bare or bare in (".", ".."):
628
+ break
629
+ t = [bare, *t[1:]]
630
+ forms.append(t)
631
+ continue
632
+ base = head
633
+ if _ASSIGN_RE.match(head):
634
+ t = t[1:]
635
+ elif base == "env":
636
+ split = _env_split(t[1:])
637
+ if split is not None:
638
+ t = split
639
+ else:
640
+ t = [x for x in _drop_opts(t[1:], "env") if not _ASSIGN_RE.match(x)] if len(t) > 1 else []
641
+ elif base == "command" and len(t) > 1 and re.match(r"^-[a-zA-Z]*[vV]", t[1]):
642
+ break # `command -v x` looks x up, it does not run it
643
+ elif base in _SIMPLE_WRAPPERS:
644
+ t = _drop_opts(t[1:], base)
645
+ elif base == "timeout":
646
+ rest = _drop_opts(t[1:], "timeout")
647
+ t = rest[1:]
648
+ elif base == "xargs":
649
+ t = _drop_opts(t[1:], "xargs")
650
+ elif base in ("npx", "bunx", "uvx") or (base == "pipx" and len(t) > 1 and t[1] == "run"):
651
+ rest = t[2:] if base == "pipx" else t[1:]
652
+ rest = [x for x in rest if x not in ("--yes", "-y", "--no-install", "--quiet", "-q")]
653
+ t = _drop_opts(rest, "npx")
654
+ if t:
655
+ versioned = re.match(r"^(@?[^@\s]+)@[^/\s]+$", t[0]) # npx npm@10 i -> npm i
656
+ if versioned:
657
+ t = [versioned.group(1), *t[1:]]
658
+ elif base in ("pnpm", "yarn", "bun") and len(t) > 1 and t[1] in ("dlx", "exec", "x"):
659
+ t = t[2:]
660
+ elif base in ("uv", "poetry", "pipenv", "pdm", "hatch", "rye") and len(t) > 1 and t[1] == "run":
661
+ t = _drop_opts(t[2:], "uv")
662
+ elif base == "conda" and len(t) > 1 and t[1] == "run":
663
+ rest = t[2:]
664
+ while rest and rest[0].startswith("-"):
665
+ rest = rest[2:] if rest[0] in ("-n", "--name", "-p", "--prefix") else rest[1:]
666
+ t = rest
667
+ elif re.match(r"^python[0-9.]*$", base) and len(t) > 2 and t[1] == "-m":
668
+ t = t[2:]
669
+ elif base == "git":
670
+ rest = t[1:]
671
+ i = 0
672
+ while i < len(rest) and rest[i].startswith("-"):
673
+ i += 2 if rest[i] in ("-C", "-c", "--git-dir", "--work-tree", "--namespace") else 1
674
+ if i:
675
+ t = ["git", *rest[i:]]
676
+ forms.append(t)
677
+ break
678
+ else:
679
+ break
680
+ if t:
681
+ forms.append(t)
682
+ return forms
683
+
684
+
685
+ def _script_of(tokens: List[str]) -> Optional[str]:
686
+ """The script of `bash -c '...'`, `sh -lc "..."` etc. (`bash <<EOF` and `sh <<< '...'` are handled by the caller)."""
687
+ if not tokens:
688
+ return None
689
+ if tokens[0].rsplit("/", 1)[-1] not in _SHELL_NAMES:
690
+ return None
691
+ for i, tok in enumerate(tokens[1:], 1):
692
+ if tok.startswith("-") and "c" in tok[1:] and not tok.startswith("--"):
693
+ rest = [t for t in tokens[i + 1 :] if t != "--"]
694
+ return rest[0] if rest else None
695
+ return None
696
+
697
+
698
+ def _stdin_shell(form: List[str]) -> bool:
699
+ """A shell that reads its script from stdin: `sh`, `bash -s`, but not `bash x.sh` or `bash -c ...`."""
700
+ clean = _split_redirs(form)[0]
701
+ if not clean or clean[0].rsplit("/", 1)[-1] not in _SHELL_NAMES or _script_of(clean) is not None:
702
+ return False
703
+ return all(a.startswith("-") for a in clean[1:])
704
+
705
+
706
+ def _exec_commands(form: List[str]) -> List[List[str]]:
707
+ """Commands that `find ... -exec cmd ... ;` runs (also -execdir, -ok, -okdir)."""
708
+ if not form or form[0].rsplit("/", 1)[-1] != "find":
709
+ return []
710
+ out = []
711
+ i = 0
712
+ while i < len(form):
713
+ if form[i] in ("-exec", "-execdir", "-ok", "-okdir"):
714
+ j = i + 1
715
+ while j < len(form) and form[j] not in (";", "+"):
716
+ j += 1
717
+ if j > i + 1:
718
+ out.append(list(form[i + 1 : j]))
719
+ i = j
720
+ i += 1
721
+ return out
722
+
723
+
724
+ def _git_dirs(form: List[str]) -> List[str]:
725
+ """The directories that `git -C <dir>` options move into, in order."""
726
+ if not form or form[0].rsplit("/", 1)[-1] != "git":
727
+ return []
728
+ out = []
729
+ i = 1
730
+ while i < len(form) and form[i].startswith("-"):
731
+ if form[i] == "-C" and i + 1 < len(form):
732
+ out.append(form[i + 1])
733
+ i += 2
734
+ elif form[i] in ("-c", "--git-dir", "--work-tree", "--namespace"):
735
+ i += 2
736
+ else:
737
+ i += 1
738
+ return out
739
+
740
+
741
+ def _pipe_text(prev: Tuple[List[str], Optional[str]]) -> Optional[str]:
742
+ """The text a command writes into a pipe when it is only echo, printf or cat of literal text (for `... | sh`)."""
743
+ words, body = prev
744
+ clean, redirs, _ = _split_redirs(words)
745
+ if not clean:
746
+ return None
747
+ base = clean[0].rsplit("/", 1)[-1]
748
+ args = [a for a in clean[1:] if not a.startswith("-")]
749
+ if base == "echo":
750
+ return " ".join(args)
751
+ if base == "printf":
752
+ return " ".join(args[1:] if args and "%" in args[0] else args)
753
+ if base == "cat":
754
+ here = [t for _, op, t in redirs if op == "<<<"]
755
+ return here[0] if here else body
756
+ return None
757
+
758
+
759
+ def _cd_join(cwd: str, target: str) -> str:
760
+ """The directory reached by `cd target` from `cwd`; "" is the directory the command line started in."""
761
+ if target.startswith("/"):
762
+ new = posixpath.normpath(target)
763
+ else:
764
+ new = posixpath.normpath(posixpath.join(cwd, target) if cwd else target)
765
+ if new == WORKDIR or new.startswith(WORKDIR + "/"): # the sandbox's repository is where a run starts
766
+ new = new[len(WORKDIR) + 1 :]
767
+ return "" if new == "." else new
768
+
769
+
770
+ def _next_cwd(cmd: str, args: List[str], cwd: str, dirs: List[str]) -> str:
771
+ """The directory after `cd`, `pushd` or `popd`; a target that is not a plain literal path resets to the start."""
772
+ if cmd == "popd":
773
+ return dirs.pop() if dirs else ""
774
+ i = 0
775
+ while i < len(args) and args[i].startswith("-") and args[i] != "-":
776
+ i += 1
777
+ if args[i - 1] == "--":
778
+ break
779
+ if i >= len(args):
780
+ return "" # a bare `cd` goes home
781
+ target = args[i]
782
+ if target == "-" or target.startswith(("~", "+")) or any(ch in target for ch in "$`*?[{"):
783
+ return ""
784
+ if cmd == "pushd":
785
+ dirs.append(cwd)
786
+ return _cd_join(cwd, target)
787
+
788
+
789
+ def _is_query(tokens: List[str]) -> bool:
790
+ """`which x`, `type x`, `command -v x`: they look a name up, they do not run it."""
791
+ if tokens[0] in ("type", "which", "whereis", "hash"):
792
+ return True
793
+ return tokens[0] == "command" and len(tokens) > 1 and bool(re.match(r"^-[a-zA-Z]*[vV]", tokens[1]))
794
+
795
+
796
+ _SEG_CACHE: Dict[str, List[Dict[str, Any]]] = {}
797
+
798
+
799
+ def segments(command: str, _depth: int = 0, _cwd: str = "") -> List[Dict[str, Any]]:
800
+ """Simple commands in a shell line, each with its argv and unwrapped views (normalised strings).
801
+
802
+ A segment also holds "cwd": the directory (relative to where the line started) it runs in after the `cd` and
803
+ `pushd` commands before it; a `cd` to something that is not a literal path counts as the start directory.
804
+ """
805
+ if _depth == 0 and not _cwd:
806
+ cached = _SEG_CACHE.get(command)
807
+ if cached is None:
808
+ cached = _segments(command, 0, "")
809
+ if len(_SEG_CACHE) >= 16:
810
+ _SEG_CACHE.clear()
811
+ _SEG_CACHE[command] = cached
812
+ return list(cached)
813
+ return _segments(command, _depth, _cwd)
814
+
815
+
816
+ def _segments(command: str, depth: int, start: str) -> List[Dict[str, Any]]:
817
+ out: List[Dict[str, Any]] = []
818
+ if depth > 4:
819
+ return out
820
+ cwd = start
821
+ scopes: List[str] = [] # directories to return to at the `)` of a subshell
822
+ dirs: List[str] = [] # the pushd stack
823
+ prev: Optional[Tuple[List[str], Optional[str]]] = None
824
+ for sep, part in _split_top(command):
825
+ if sep == "(":
826
+ scopes.append(cwd)
827
+ continue
828
+ if sep == ")":
829
+ if scopes:
830
+ cwd = scopes.pop()
831
+ continue
832
+ part = _RESERVED_RE.sub("", part, count=1).strip() # `then git push`, `! git push`, `do cmd`
833
+ if not part or part in _CLOSERS:
834
+ continue
835
+ tokens, body_start, heredoc = _tokens(part)
836
+ clean, redirs, redir_words = _split_redirs(tokens)
837
+ if tokens and isinstance(tokens[0], _Redir):
838
+ tokens = [*clean, *redir_words] # `>f cmd` runs like `cmd >f`
839
+ here = cwd
840
+ if clean and clean[0] in _CD_NAMES:
841
+ cwd = _next_cwd(clean[0], clean[1:], cwd, dirs)
842
+ is_cd = bool(clean) and clean[0] == "cd"
843
+ body: Optional[str] = None
844
+ if body_start >= 0:
845
+ body = part[body_start:]
846
+ if heredoc and heredoc[0] and body.endswith(heredoc[0]):
847
+ body = body[: -len(heredoc[0])]
848
+ forms = _unwrap(tokens) if tokens else []
849
+ views: List[str] = []
850
+ for form in forms:
851
+ text = " ".join(form)
852
+ if text and text not in views:
853
+ views.append(text)
854
+ if tokens and _is_query(tokens):
855
+ views = [tokens[0]]
856
+ seg_cwd = here
857
+ for form in forms:
858
+ moved = _git_dirs(form)
859
+ if moved:
860
+ for d in moved:
861
+ seg_cwd = _cd_join(seg_cwd, d)
862
+ break
863
+ if not is_cd: # a plain `cd` is not reported as a command, but its substitutions still run
864
+ out.append(
865
+ {
866
+ "raw": part,
867
+ "argv": forms[-1] if forms else tokens,
868
+ "argv_raw": tokens,
869
+ "views": views,
870
+ "heredoc": body,
871
+ "cwd": seg_cwd,
872
+ }
873
+ )
874
+ scripts: List[str] = []
875
+ for form in forms:
876
+ script = _script_of(form)
877
+ if script:
878
+ scripts.append(script)
879
+ elif form and form[0] == "eval" and len(form) > 1:
880
+ scripts.append(" ".join(form[1:]))
881
+ scripts += [shlex.join(inner) for inner in _exec_commands(form)]
882
+ if any(_stdin_shell(form) for form in forms):
883
+ scripts += [target for _, op, target in redirs if op == "<<<"] # sh <<< 'git push'
884
+ if sep in ("|", "|&") and prev is not None: # echo 'git push' | sh
885
+ piped = _pipe_text(prev)
886
+ if piped:
887
+ scripts.append(piped)
888
+ if body is not None and any(f and f[0].rsplit("/", 1)[-1] in _SHELL_NAMES for f in forms):
889
+ scripts.append(body)
890
+ for script in dict.fromkeys(scripts):
891
+ out += _segments(script, depth + 1, seg_cwd)
892
+ subs = _substitutions(part if body_start < 0 else part[: body_start - 1])
893
+ if body is not None and heredoc and not heredoc[1]:
894
+ subs += _substitutions(body, literal=True) # an unquoted heredoc delimiter lets the body expand
895
+ for sub in subs:
896
+ out += _segments(sub, depth + 1, seg_cwd)
897
+ prev = (tokens, body)
898
+ return out
899
+
900
+
901
+ def matches_command(patterns: List[str], segment: Dict[str, Any]) -> bool:
902
+ return any(re.search(p, v) for p in patterns for v in segment["views"])
903
+
904
+
905
+ def _git_sub(segment: Dict[str, Any]) -> Tuple[str, List[str]]:
906
+ argv = segment["argv"] # the most unwrapped form has git's global options removed
907
+ if argv and argv[0].rsplit("/", 1)[-1] == "git":
908
+ return (argv[1] if len(argv) > 1 else "", list(argv[2:]))
909
+ for v in reversed(segment["views"]):
910
+ if v.startswith("git "):
911
+ parts = v.split()
912
+ return (parts[1] if len(parts) > 1 else "", parts[2:])
913
+ return ("", [])
914
+
915
+
916
+ def _prefix_of(arg: str, option: str, shortest: int = 4) -> bool:
917
+ """True when `arg` is `option` or an abbreviation of it git still accepts (`--mess`, `--message`)."""
918
+ return len(arg) >= shortest and option.startswith(arg)
919
+
920
+
921
+ def is_git(segment: Dict[str, Any], sub: str) -> bool:
922
+ """True when the segment runs `git <sub>` with any options: an executed attempt counts, even `--dry-run` or
923
+ `--help` (the frozen checker contract: "never push" means never run `git push`)."""
924
+ return _git_sub(segment)[0] == sub
925
+
926
+
927
+ # --------------------------------------------------------------------------------------------- writes through the shell
928
+
929
+ _PY_WRITE_RE = re.compile(
930
+ r"""open\(\s*(?:[rfbu]*['"])([^'"]+)['"]\s*,\s*['"][^'"]*[wax+][^'"]*['"]|"""
931
+ r"""Path\(\s*['"]([^'"]+)['"]\s*\)\s*\.\s*(?:write_text|write_bytes|unlink|touch|rename|replace|mkdir)|"""
932
+ r"""(?:os\.remove|os\.unlink|shutil\.rmtree|os\.rename|os\.replace|shutil\.move)\(\s*['"]([^'"]+)['"]|"""
933
+ r"""shutil\.(?:copy2?|copyfile|move)\(\s*['"][^'"]+['"]\s*,\s*['"]([^'"]+)['"]|"""
934
+ r"""(?:writeFileSync|appendFileSync|writeFile|appendFile|rmSync|unlinkSync|renameSync)\(\s*['"]([^'"]+)['"]"""
935
+ )
936
+
937
+
938
+ def _code_targets(code: str) -> List[str]:
939
+ return [next(g for g in m.groups() if g) for m in _PY_WRITE_RE.finditer(code)]
940
+
941
+
942
+ def _dest_args(args: List[str]) -> List[str]:
943
+ return [a for a in args if not a.startswith("-")]
944
+
945
+
946
+ def _join_cwd(cwd: str, p: str) -> str:
947
+ """A path as written in a command that ran in `cwd`, relative to the directory the command line started in."""
948
+ if p == "*":
949
+ return p
950
+ while p.startswith("./"):
951
+ p = p[2:]
952
+ if cwd and not p.startswith("/"):
953
+ p = cwd + "/" + p
954
+ if cwd or "//" in p or "/./" in p or ".." in p.split("/"):
955
+ p = posixpath.normpath(p)
956
+ return p
957
+
958
+
959
+ def shell_write_targets(command: str) -> List[Dict[str, str]]:
960
+ """Files a command line writes, creates, deletes or renames: [{"path", "op"}], op in write|delete|rename|unknown.
961
+
962
+ Redirections are read from the command text (`echo hi>f`, `cat a >> b`, `&> f`, `2> f`, `>| f`; not `grep '>' f`,
963
+ `2>&1` or `/dev/null`). Paths are relative to the directory the line started in: a literal `cd dir` or `pushd dir`
964
+ before the command is followed.
965
+ """
966
+ found: List[Dict[str, str]] = []
967
+ cwd = ""
968
+
969
+ def add(path: str, op: str = "write") -> None:
970
+ p = path.strip().strip("'\"")
971
+ if p and p not in ("-", "{}") and not p.startswith(("&", "/dev/")):
972
+ found.append({"path": _join_cwd(cwd, p), "op": op})
973
+
974
+ for seg in segments(command):
975
+ cwd = seg.get("cwd", "")
976
+ clean, redirs, _ = _split_redirs(seg["argv"])
977
+ for _fd, op, target in redirs:
978
+ if op in (">", ">>", ">|") or (op == ">&" and not re.match(r"^(\d+|-)$", target)):
979
+ add(target)
980
+ if not clean:
981
+ continue
982
+ tool = clean[0].rsplit("/", 1)[-1]
983
+ args = clean[1:]
984
+ if tool == "tee":
985
+ for a in _dest_args(args):
986
+ add(a)
987
+ elif tool in ("sed", "gsed"):
988
+ if any(a == "-i" or a.startswith("-i") or a == "--in-place" or a.startswith("--in-place=") for a in args):
989
+ rest = []
990
+ skip_next = False
991
+ script_given = any(a in ("-e", "--expression", "-f", "--file") for a in args)
992
+ for j, a in enumerate(args):
993
+ if skip_next:
994
+ skip_next = False
995
+ continue
996
+ if a in ("-e", "--expression", "-f", "--file"):
997
+ skip_next = True
998
+ continue
999
+ if a == "-i" and j + 1 < len(args) and args[j + 1] == "":
1000
+ skip_next = True # BSD sed -i ''
1001
+ continue
1002
+ if a.startswith("-"):
1003
+ continue
1004
+ rest.append(a)
1005
+ files = rest if script_given else rest[1:]
1006
+ for f in files:
1007
+ add(f)
1008
+ elif tool == "perl" and any(re.match(r"^-[a-zA-Z]*i", a) for a in args):
1009
+ rest = [a for a in args if not a.startswith("-")]
1010
+ has_e = any(re.match(r"^-[a-zA-Z]*e", a) for a in args)
1011
+ for f in rest[1:] if has_e else rest:
1012
+ add(f)
1013
+ elif tool in ("mv", "git") and (tool == "mv" or (args[:1] == ["mv"])):
1014
+ files = _dest_args(args[1:] if tool == "git" else args)
1015
+ if len(files) >= 2:
1016
+ for src in files[:-1]:
1017
+ add(src, "rename")
1018
+ add(files[-1])
1019
+ elif tool in ("cp", "install", "rsync", "ln"):
1020
+ files = _dest_args(args)
1021
+ if len(files) >= 2:
1022
+ add(files[-1])
1023
+ elif tool in ("rm", "unlink", "shred"):
1024
+ for f in _dest_args(args):
1025
+ add(f, "delete")
1026
+ elif tool == "git" and args[:1] == ["rm"]:
1027
+ for f in _dest_args(args[1:]):
1028
+ add(f, "delete")
1029
+ elif tool == "git" and args[:1] in (["checkout"], ["restore"]):
1030
+ if "--" in args:
1031
+ for f in args[args.index("--") + 1 :]:
1032
+ add(f)
1033
+ elif args[:1] == ["restore"]:
1034
+ for f in _dest_args(args[1:]):
1035
+ add(f)
1036
+ elif (tool == "git" and args[:1] == ["apply"]) or tool == "patch":
1037
+ add("*", "unknown")
1038
+ elif tool in ("touch", "truncate", "mkdir"):
1039
+ files = _dest_args(args)
1040
+ if tool == "truncate":
1041
+ files = [f for f in files if not re.match(r"^[+-]?\d+[KMGkmg]?$", f)]
1042
+ for f in files:
1043
+ add(f)
1044
+ elif tool == "dd":
1045
+ for a in args:
1046
+ if a.startswith("of="):
1047
+ add(a[3:])
1048
+ elif re.match(r"^python[0-9.]*$", tool) or tool in ("node", "ruby", "deno", "bun"):
1049
+ if "-c" in args or "-e" in args:
1050
+ flag = "-c" if "-c" in args else "-e"
1051
+ idx = args.index(flag)
1052
+ if idx + 1 < len(args):
1053
+ for p in _code_targets(args[idx + 1]):
1054
+ add(p)
1055
+ if seg.get("heredoc"):
1056
+ for p in _code_targets(seg["heredoc"]):
1057
+ add(p)
1058
+ return found
1059
+
1060
+
1061
+ # --------------------------------------------------------------------------------------------- diffs
1062
+
1063
+
1064
+ def parse_diff(diff: str) -> List[Dict[str, Any]]:
1065
+ """Files in a git-format diff: path, old_path, status (added|deleted|modified|renamed), added, removed lines."""
1066
+ files: List[Dict[str, Any]] = []
1067
+ cur: Optional[Dict[str, Any]] = None
1068
+ new_line = 0
1069
+ for line in diff.split("\n"):
1070
+ if line.startswith("diff --git "):
1071
+ m = re.match(r'^diff --git "?a/(.+?)"? "?b/(.+?)"?$', line)
1072
+ path = m.group(2) if m else line.split(" b/")[-1]
1073
+ cur = {
1074
+ "path": path,
1075
+ "old_path": m.group(1) if m else path,
1076
+ "status": "modified",
1077
+ "added": [],
1078
+ "removed": [],
1079
+ "binary": False,
1080
+ "lines": [],
1081
+ }
1082
+ files.append(cur)
1083
+ continue
1084
+ if cur is None:
1085
+ continue
1086
+ if line.startswith("new file mode"):
1087
+ cur["status"] = "added"
1088
+ elif line.startswith("deleted file mode"):
1089
+ cur["status"] = "deleted"
1090
+ elif line.startswith("rename from "):
1091
+ cur["old_path"] = line[len("rename from ") :]
1092
+ cur["status"] = "renamed"
1093
+ elif line.startswith("rename to "):
1094
+ cur["path"] = line[len("rename to ") :]
1095
+ elif line.startswith("Binary files") or line.startswith("GIT binary patch"):
1096
+ cur["binary"] = True
1097
+ elif line.startswith("--- "):
1098
+ if line.strip() == "--- /dev/null":
1099
+ cur["status"] = "added"
1100
+ elif line.startswith("+++ "):
1101
+ if line.strip() == "+++ /dev/null":
1102
+ cur["status"] = "deleted"
1103
+ elif line.startswith("@@"):
1104
+ m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)", line)
1105
+ new_line = int(m.group(1)) if m else 0
1106
+ cur["lines"].append(("@", line))
1107
+ elif line.startswith("+"):
1108
+ cur["added"].append((new_line, line[1:]))
1109
+ cur["lines"].append(("+", line[1:]))
1110
+ new_line += 1
1111
+ elif line.startswith("-"):
1112
+ cur["removed"].append(line[1:])
1113
+ cur["lines"].append(("-", line[1:]))
1114
+ elif line.startswith(" "):
1115
+ cur["lines"].append((" ", line[1:]))
1116
+ new_line += 1
1117
+ return files
1118
+
1119
+
1120
+ def new_file_diff(path: str, text: str) -> str:
1121
+ lines = text.split("\n")
1122
+ if lines and lines[-1] == "":
1123
+ lines = lines[:-1]
1124
+ body = "".join("+" + ln + "\n" for ln in lines)
1125
+ return (
1126
+ f"diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n"
1127
+ f"@@ -0,0 +1,{len(lines)} @@\n{body}"
1128
+ )
1129
+
1130
+
1131
+ def _changed(run: Dict[str, Any]) -> List[Dict[str, Any]]:
1132
+ return parse_diff(str(run.get("diff") or ""))
1133
+
1134
+
1135
+ # --------------------------------------------------------------------------------------------- dependencies
1136
+
1137
+ _PY_REQ_RE = re.compile(r"^\s*['\"]?([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@ ].*)?['\"]?,?\s*$")
1138
+ _JSON_DEP_RE = re.compile(
1139
+ r'^\s*"(@?[A-Za-z0-9._/-]+)"\s*:\s*"(\^|~|>=?|<=?|=|\*|\d|workspace:|npm:|github:|git|file:|'
1140
+ r"link:|https?:|latest|next|catalog:)"
1141
+ )
1142
+ _TOML_KEY_DEP_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*(\"[\^~<>=!*\d]|\{|\[)")
1143
+ _NOT_DEPS = {
1144
+ "python",
1145
+ "name",
1146
+ "version",
1147
+ "edition",
1148
+ "authors",
1149
+ "description",
1150
+ "license",
1151
+ "readme",
1152
+ "repository",
1153
+ "homepage",
1154
+ "documentation",
1155
+ "keywords",
1156
+ "categories",
1157
+ "build",
1158
+ "workspace",
1159
+ "resolver",
1160
+ "members",
1161
+ "publish",
1162
+ "rust-version",
1163
+ "requires-python",
1164
+ "packages",
1165
+ "include",
1166
+ "exclude",
1167
+ "default-run",
1168
+ "license-file",
1169
+ "classifiers",
1170
+ "urls",
1171
+ "scripts",
1172
+ "dynamic",
1173
+ "requires",
1174
+ "build-backend",
1175
+ }
1176
+
1177
+
1178
+ def _norm_pkg(name: str) -> str:
1179
+ return re.sub(r"[-_.]+", "-", name.lower())
1180
+
1181
+
1182
+ _PEP508_RE = re.compile(
1183
+ r"""^\s*['"]([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*([<>=!~;@ ][^'"]*)?['"]\s*,?\s*(#.*)?$"""
1184
+ )
1185
+ _QUOTED_RE = re.compile(r"""['"]([^'"]+)['"]""")
1186
+ _JS_DEP_SECTIONS = {"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"}
1187
+
1188
+
1189
+ def _py_dep_location(section: str, array_key: str) -> Optional[bool]:
1190
+ """True/False when the location is known to hold (or not hold) requirement strings; None when unknown."""
1191
+ if not section:
1192
+ return None
1193
+ if section == "project":
1194
+ return array_key == "dependencies"
1195
+ if section in ("project.optional-dependencies", "dependency-groups"):
1196
+ return True
1197
+ if section == "tool.uv":
1198
+ return array_key in ("dev-dependencies", "constraint-dependencies", "override-dependencies")
1199
+ if section == "tool.pdm.dev-dependencies":
1200
+ return True
1201
+ if section == "options": # setup.cfg
1202
+ return array_key in ("install_requires", "tests_require", "setup_requires")
1203
+ if section == "options.extras_require":
1204
+ return True
1205
+ return False
1206
+
1207
+
1208
+ def _toml_table_dep(section: str, base: str) -> Optional[bool]:
1209
+ if not section:
1210
+ return None
1211
+ if base == "Cargo.toml":
1212
+ return bool(re.match(r"^((workspace|target\.[^ ]+)\.)?(dev-|build-)?dependencies$", section))
1213
+ if base == "Pipfile":
1214
+ return section in ("packages", "dev-packages")
1215
+ if base == "pyproject.toml":
1216
+ return bool(re.match(r"^tool\.poetry(\.group\.[^.]+)?\.(dev-)?dependencies$", section))
1217
+ return False
1218
+
1219
+
1220
+ def _req_name(text: str) -> Optional[str]:
1221
+ m = _PEP508_RE.match(text)
1222
+ return m.group(1) if m else None
1223
+
1224
+
1225
+ def _looks_like_package(name: str) -> bool:
1226
+ return bool(re.match(r"^[a-z0-9][a-z0-9._-]*$", name)) and len(name) > 1 and not name.isupper()
1227
+
1228
+
1229
+ def manifest_deps(path: str, lines: List[Tuple[str, str]], side: str) -> List[str]:
1230
+ """Dependency names on one side of a manifest's diff ("+" new side, "-" old side), using visible context."""
1231
+ base = path.rsplit("/", 1)[-1]
1232
+ keep = {" ", side}
1233
+ names: List[str] = []
1234
+ section = ""
1235
+ array_key = ""
1236
+ js_stack: List[str] = []
1237
+ js_known = False
1238
+ for kind, raw in lines:
1239
+ if kind == "@":
1240
+ section, array_key, js_stack, js_known = "", "", [], False
1241
+ continue
1242
+ if kind not in keep:
1243
+ continue
1244
+ text = raw.rstrip()
1245
+ stripped = text.split("#", 1)[0].rstrip() if base != "package.json" else text
1246
+ if base == "package.json":
1247
+ opened = re.match(r'^\s*"([^"]+)"\s*:\s*\{', text)
1248
+ if opened:
1249
+ js_stack.append(opened.group(1))
1250
+ js_known = True
1251
+ continue
1252
+ if re.match(r"^\s*\},?\s*$", text):
1253
+ if js_stack:
1254
+ js_stack.pop()
1255
+ continue
1256
+ m = re.match(r'^\s*"(@?[^"]+)"\s*:\s*"([^"]*)"', text)
1257
+ if not m or kind != side:
1258
+ continue
1259
+ if js_known and js_stack:
1260
+ if js_stack[-1] in _JS_DEP_SECTIONS:
1261
+ names.append(m.group(1))
1262
+ elif _JSON_DEP_RE.match(text):
1263
+ names.append(m.group(1))
1264
+ continue
1265
+ if base.startswith("requirements") and base.endswith((".txt", ".in")):
1266
+ if kind == side and stripped.strip() and not stripped.strip().startswith(("-", "git+", "http")):
1267
+ m = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", stripped)
1268
+ if m:
1269
+ names.append(m.group(1))
1270
+ continue
1271
+ if base == "go.mod":
1272
+ if kind == side:
1273
+ m = re.match(r"^\s*(?:require\s+)?([a-z0-9.-]+\.[a-z]{2,}/[^\s]+)\s+v\d", stripped)
1274
+ if m:
1275
+ names.append(m.group(1))
1276
+ continue
1277
+ header = re.match(r"^\s*\[+\s*([^\]]+?)\s*\]+\s*$", stripped)
1278
+ if header:
1279
+ section, array_key = header.group(1).strip().strip('"'), ""
1280
+ table = re.match(r"^((?:workspace\.)?(?:dev-|build-)?dependencies)\.([A-Za-z0-9_-]+)$", section)
1281
+ if table and base == "Cargo.toml" and kind == side:
1282
+ names.append(table.group(2))
1283
+ continue
1284
+ key_line = re.match(r"^\s*([A-Za-z0-9_.\"-]+)\s*=\s*(.*)$", stripped)
1285
+ if key_line:
1286
+ key, value = key_line.group(1).strip('"'), key_line.group(2)
1287
+ if value.startswith("["):
1288
+ array_key = key
1289
+ inline = value.endswith("]")
1290
+ if kind == side and (
1291
+ _py_dep_location(section, key)
1292
+ or (
1293
+ section == ""
1294
+ and key in ("dependencies", "dev-dependencies", "requires")
1295
+ and base != "Cargo.toml"
1296
+ )
1297
+ ):
1298
+ for q in _QUOTED_RE.findall(value):
1299
+ n = _req_name(f'"{q}"')
1300
+ if n:
1301
+ names.append(n)
1302
+ if inline:
1303
+ array_key = ""
1304
+ continue
1305
+ array_key = ""
1306
+ if kind == side and key.lower() not in _NOT_DEPS:
1307
+ table_dep = _toml_table_dep(section, base)
1308
+ if table_dep or (
1309
+ table_dep is None and base in ("Cargo.toml", "Pipfile") and re.match(r"^(\"[\^~<>=!*\d]|\{)", value)
1310
+ ):
1311
+ names.append(key)
1312
+ continue
1313
+ if re.match(r"^\s*\]", stripped):
1314
+ array_key = ""
1315
+ continue
1316
+ if kind == side and base in ("pyproject.toml", "setup.cfg"):
1317
+ location = _py_dep_location(section, array_key)
1318
+ name = (
1319
+ _req_name(stripped)
1320
+ if base == "pyproject.toml"
1321
+ else (
1322
+ re.match(r"^\s+([A-Za-z0-9][A-Za-z0-9._-]*)", stripped).group(1) # type: ignore[union-attr]
1323
+ if re.match(r"^\s+[A-Za-z0-9]", stripped)
1324
+ else None
1325
+ )
1326
+ )
1327
+ if not name:
1328
+ continue
1329
+ if location or (
1330
+ location is None
1331
+ and array_key in ("", "dependencies")
1332
+ and _looks_like_package(name)
1333
+ and (re.search(r"[<>=!~]", stripped) or array_key == "dependencies")
1334
+ ):
1335
+ names.append(name)
1336
+ return names
1337
+
1338
+
1339
+ def new_dependencies_from_diff(run: Dict[str, Any], manifests: List[str]) -> List[Tuple[str, str]]:
1340
+ out: List[Tuple[str, str]] = []
1341
+ for f in _changed(run):
1342
+ if not glob_match(manifests, f["path"]) or f["status"] == "deleted":
1343
+ continue
1344
+ added = manifest_deps(f["path"], f["lines"], "+")
1345
+ removed = {_norm_pkg(n) for n in manifest_deps(f["path"], f["lines"], "-")}
1346
+ for name in added:
1347
+ if _norm_pkg(name) not in removed:
1348
+ out.append((name, f["path"]))
1349
+ return out
1350
+
1351
+
1352
+ _INSTALL = [
1353
+ (re.compile(r"^(npm)\s+(install|i|add|isntall|ci|clean-install)\b(.*)$"), "npm"),
1354
+ (re.compile(r"^(pnpm|yarn|bun)\s+(add|install|i|ci)\b(.*)$"), "js"),
1355
+ (re.compile(r"^(pip3?)\s+(install)\b(.*)$"), "pip"),
1356
+ (re.compile(r"^(uv)\s+(add)\b(.*)$"), "uv"),
1357
+ (re.compile(r"^(uv)\s+pip\s+(install)\b(.*)$"), "pip"),
1358
+ (re.compile(r"^(poetry|pdm|pipenv|rye)\s+(add|install)\b(.*)$"), "py"),
1359
+ (re.compile(r"^(go)\s+(get)\b(.*)$"), "go"),
1360
+ (re.compile(r"^(cargo)\s+(add)\b(.*)$"), "cargo"),
1361
+ (re.compile(r"^(gem)\s+(install)\b(.*)$"), "gem"),
1362
+ (re.compile(r"^(composer)\s+(require)\b(.*)$"), "php"),
1363
+ (re.compile(r"^(conda|mamba)\s+(install)\b(.*)$"), "conda"),
1364
+ ]
1365
+ _OPT_WITH_VALUE = {
1366
+ "-r",
1367
+ "--requirement",
1368
+ "-c",
1369
+ "--constraint",
1370
+ "-e",
1371
+ "--editable",
1372
+ "-t",
1373
+ "--target",
1374
+ "--prefix",
1375
+ "-i",
1376
+ "--index-url",
1377
+ "--extra-index-url",
1378
+ "--python",
1379
+ "-p",
1380
+ "--group",
1381
+ "--optional",
1382
+ "--extra",
1383
+ "-w",
1384
+ "--workspace",
1385
+ "--filter",
1386
+ "-C",
1387
+ "--dir",
1388
+ "--save-prefix",
1389
+ "--registry",
1390
+ "--cwd",
1391
+ }
1392
+
1393
+
1394
+ def install_packages(segment: Dict[str, Any]) -> Tuple[bool, List[str]]:
1395
+ """(is an install/add command, package names it adds)."""
1396
+ for view in segment["views"]:
1397
+ for rx, kind in _INSTALL:
1398
+ m = rx.match(view)
1399
+ if not m:
1400
+ continue
1401
+ args = m.group(3).split()
1402
+ names: List[str] = []
1403
+ skip = False
1404
+ for a in args:
1405
+ if skip:
1406
+ skip = False
1407
+ continue
1408
+ if a in _OPT_WITH_VALUE:
1409
+ skip = True
1410
+ continue
1411
+ redirect = _REDIR_RE.match(a)
1412
+ if redirect: # `2>&1`, `>/dev/null`, `> install.log`: output handling, never a package
1413
+ skip = not redirect.group(3)
1414
+ continue
1415
+ if (
1416
+ a.startswith("-")
1417
+ or a in (".", "./")
1418
+ or a.startswith((".", "/", "git+", "http://", "https://", "file:"))
1419
+ ):
1420
+ continue
1421
+ if kind == "pip" and a.endswith((".txt", ".whl")):
1422
+ continue
1423
+ name = (
1424
+ re.split(r"(?<=.)[@=<>~!\[;]", a, maxsplit=1)[0]
1425
+ if not a.startswith("@")
1426
+ else ("@" + re.split(r"[@=<>~!]", a[1:], maxsplit=1)[0])
1427
+ )
1428
+ if kind == "go":
1429
+ name = name.split("@", 1)[0]
1430
+ if name:
1431
+ names.append(name)
1432
+ return True, names
1433
+ return False, []
1434
+
1435
+
1436
+ # --------------------------------------------------------------------------------------------- run helpers
1437
+
1438
+
1439
+ def _executed_shell(run: Dict[str, Any]) -> List[Tuple[int, Dict[str, Any], Dict[str, Any]]]:
1440
+ """(seq, event, segment) for every simple command that ran (hook-denied commands excluded)."""
1441
+ out = []
1442
+ for i, ev in enumerate(run.get("events") or []):
1443
+ if ev.get("type") != "shell" or ev.get("denied"):
1444
+ continue
1445
+ seq = ev.get("seq", i)
1446
+ for seg in segments(str(ev.get("command") or "")):
1447
+ out.append((seq if isinstance(seq, int) else i, ev, seg))
1448
+ return out
1449
+
1450
+
1451
+ def _file_events(run: Dict[str, Any]) -> List[Tuple[int, str, str]]:
1452
+ """(seq, path, op) for files written by edit/write/delete events and by shell commands that ran."""
1453
+ out: List[Tuple[int, str, str]] = []
1454
+ for i, ev in enumerate(run.get("events") or []):
1455
+ seq = ev.get("seq", i)
1456
+ seq = seq if isinstance(seq, int) else i
1457
+ if ev.get("denied"):
1458
+ continue
1459
+ kind = ev.get("type")
1460
+ if kind in ("edit", "write", "delete") and ev.get("path"):
1461
+ out.append((seq, str(ev["path"]), {"edit": "write", "write": "write", "delete": "delete"}[kind]))
1462
+ elif kind == "shell":
1463
+ for t in shell_write_targets(str(ev.get("command") or "")):
1464
+ out.append((seq, t["path"], t["op"]))
1465
+ return out
1466
+
1467
+
1468
+ def _ev(kind: str, text: str, seq: Optional[int] = None, path: Optional[str] = None) -> Dict[str, Any]:
1469
+ return {"kind": kind, "text": text[:400], "seq": seq, "path": path}
1470
+
1471
+
1472
+ def _readable(regex: str) -> str:
1473
+ """A regex as people wrote it (escapes removed), for messages."""
1474
+ return re.sub(r"\\(.)", r"\1", regex)
1475
+
1476
+
1477
+ def _verdict(verdict: str, evidence: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
1478
+ return {"verdict": verdict, "evidence": evidence or []}
1479
+
1480
+
1481
+ def _norm_path(p: str) -> str:
1482
+ p = p.strip().strip("'\"")
1483
+ while p.startswith("./"):
1484
+ p = p[2:]
1485
+ if p.startswith(WORKDIR + "/"):
1486
+ p = p[len(WORKDIR) + 1 :]
1487
+ return p
1488
+
1489
+
1490
+ # --------------------------------------------------------------------------------------------- checkers
1491
+
1492
+
1493
+ def _check_command_forbidden(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1494
+ patterns = list(params.get("patterns") or [])
1495
+ preferred = list(params.get("preferred_patterns") or [])
1496
+ exercised = False
1497
+ for seq, ev, seg in _executed_shell(run):
1498
+ if matches_command(patterns, seg):
1499
+ return _verdict("violated", [_ev("command", seg["raw"], seq)])
1500
+ if preferred and matches_command(preferred, seg):
1501
+ exercised = True
1502
+ manual = list(params.get("manual_edit_paths") or [])
1503
+ if manual:
1504
+ touched = [f for f in _changed(run) if glob_match(manual, f["path"])]
1505
+ if touched:
1506
+ ok = (
1507
+ any(matches_command(preferred, seg) and ev.get("exit_code") == 0 for _, ev, seg in _executed_shell(run))
1508
+ if preferred
1509
+ else False
1510
+ )
1511
+ if not ok:
1512
+ return _verdict(
1513
+ "violated",
1514
+ [
1515
+ _ev(
1516
+ "diff",
1517
+ f"{touched[0]['path']} changed without {params.get('preferred') or 'the preferred tool'}",
1518
+ path=touched[0]["path"],
1519
+ )
1520
+ ],
1521
+ )
1522
+ exercised = True
1523
+ return _verdict("obeyed" if exercised else "not_exercised")
1524
+
1525
+
1526
+ def _check_command_required(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1527
+ pattern = str(params["pattern"])
1528
+ globs = list(params.get("edit_globs") or ["**"])
1529
+ need_ok = params.get("require_success", True)
1530
+ edits = [seq for seq, path, _ in _file_events(run) if path != "*" and glob_match(globs, _norm_path(path))]
1531
+ changed = [f for f in _changed(run) if glob_match(globs, f["path"]) or glob_match(globs, f["old_path"])]
1532
+ if not edits and not changed:
1533
+ return _verdict("not_exercised")
1534
+ last = max(edits) if edits else -1
1535
+ for seq, ev, seg in _executed_shell(run):
1536
+ if seq <= last:
1537
+ continue
1538
+ if any(re.search(pattern, v) for v in seg["views"]):
1539
+ if not need_ok or ev.get("exit_code") == 0:
1540
+ return _verdict("obeyed", [_ev("command", seg["raw"], seq)])
1541
+ where = f" after the last edit (event {last})" if last >= 0 else ""
1542
+ return _verdict("violated", [_ev("command", f"no successful command matching {pattern}{where}", last)])
1543
+
1544
+
1545
+ def _check_path_forbidden(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1546
+ globs = list(params["globs"])
1547
+ existing_only = bool(params.get("existing_only"))
1548
+ base = set(run.get("base_files") or [])
1549
+ exercised = False
1550
+ # Changes made by an allowed command (a generator) are fine; direct edits still violate. The generator only has
1551
+ # to have run (not been blocked): a command line's exit code belongs to its last command, not to the generator
1552
+ # (`pnpm gen:types && pnpm test; pnpm lint` fails when lint fails).
1553
+ allow_via = list(params.get("allow_via") or [])
1554
+ via = allow_via and any(matches_command(allow_via, seg) for _, ev, seg in _executed_shell(run))
1555
+ for seq, raw_path, op in _file_events(run):
1556
+ path = _norm_path(raw_path)
1557
+ if path == "*" or not glob_match(globs, path):
1558
+ continue
1559
+ exercised = True
1560
+ if existing_only and path not in base:
1561
+ continue
1562
+ return _verdict("violated", [_ev("event", f"{op} {path}", seq, path)])
1563
+ for f in _changed(run):
1564
+ hits = [p for p in {f["path"], f["old_path"]} if glob_match(globs, p)]
1565
+ if not hits:
1566
+ continue
1567
+ exercised = True
1568
+ if via:
1569
+ continue
1570
+ if existing_only:
1571
+ if f["status"] == "added":
1572
+ continue
1573
+ target = f["old_path"] if f["status"] == "renamed" else f["path"]
1574
+ if base and target not in base:
1575
+ continue
1576
+ return _verdict("violated", [_ev("diff", f"{f['status']} {hits[0]}", path=hits[0])])
1577
+ return _verdict("obeyed" if exercised else "not_exercised")
1578
+
1579
+
1580
+ def _check_path_required_with(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1581
+ changed = [f for f in _changed(run)]
1582
+ paths = {p for f in changed for p in (f["path"], f["old_path"])}
1583
+ when = [p for p in paths if glob_match(list(params["when"]), p)]
1584
+ if not when:
1585
+ return _verdict("not_exercised")
1586
+ if any(glob_match(list(params["require"]), p) for p in paths):
1587
+ return _verdict("obeyed")
1588
+ return _verdict(
1589
+ "violated",
1590
+ [
1591
+ _ev(
1592
+ "diff",
1593
+ f"{sorted(when)[0]} changed with no change under {', '.join(params['require'])}",
1594
+ path=sorted(when)[0],
1595
+ )
1596
+ ],
1597
+ )
1598
+
1599
+
1600
+ def _check_dependency(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1601
+ manifests = list(params.get("manifests") or DEFAULT_MANIFESTS)
1602
+ allow = {_norm_pkg(a) for a in params.get("allow") or []}
1603
+ # Any change counts as exercising the rule: a run that did the task without adding a package obeyed it.
1604
+ exercised = bool(_changed(run)) or any(glob_match(manifests, f["path"]) for f in _changed(run))
1605
+ for seq, ev, seg in _executed_shell(run):
1606
+ is_install, names = install_packages(seg)
1607
+ if not is_install:
1608
+ continue
1609
+ exercised = True
1610
+ new = [n for n in names if _norm_pkg(n) not in allow]
1611
+ if new:
1612
+ return _verdict("violated", [_ev("command", seg["raw"], seq)])
1613
+ for name, path in new_dependencies_from_diff(run, manifests):
1614
+ if _norm_pkg(name) not in allow:
1615
+ return _verdict("violated", [_ev("diff", f"{path}: +{name}", path=path)])
1616
+ return _verdict("obeyed" if exercised else "not_exercised")
1617
+
1618
+
1619
+ def _check_content_forbidden(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1620
+ rx = re.compile(str(params["regex"]))
1621
+ globs = list(params.get("globs") or ["**"])
1622
+ exercised = False
1623
+ for f in _changed(run):
1624
+ if f["binary"] or f["status"] == "deleted" or not glob_match(globs, f["path"]):
1625
+ continue
1626
+ for line_no, text in f["added"]:
1627
+ exercised = True
1628
+ if rx.search(text):
1629
+ return _verdict("violated", [_ev("diff", f"{f['path']}:{line_no}: {text.strip()}", path=f["path"])])
1630
+ return _verdict("obeyed" if exercised else "not_exercised")
1631
+
1632
+
1633
+ def _check_content_required(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1634
+ rx = re.compile(str(params["regex"]))
1635
+ globs = list(params.get("globs") or ["**"])
1636
+ first = int(params.get("first_lines") or 10)
1637
+ exercised = False
1638
+ for f in _changed(run):
1639
+ if f["status"] != "added" or f["binary"] or not glob_match(globs, f["path"]):
1640
+ continue
1641
+ exercised = True
1642
+ # The required pattern may be on any of the first lines (each line is searched, as for added lines).
1643
+ if not any(rx.search(t) for _, t in f["added"][:first]):
1644
+ return _verdict(
1645
+ "violated",
1646
+ [_ev("diff", f"new file {f['path']} lacks {_readable(str(params['regex']))}", path=f["path"])],
1647
+ )
1648
+ return _verdict("obeyed" if exercised else "not_exercised")
1649
+
1650
+
1651
+ def _check_commit_message(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1652
+ commits = list(run.get("commits") or [])
1653
+ if not commits:
1654
+ return _verdict("not_exercised")
1655
+ for c in commits:
1656
+ message = str(c.get("message", ""))
1657
+ if not re.match(str(params["regex"]), message, re.S):
1658
+ return _verdict("violated", [_ev("commit", message.split("\n", 1)[0])])
1659
+ return _verdict("obeyed")
1660
+
1661
+
1662
+ def _git_activity(run: Dict[str, Any]) -> bool:
1663
+ return bool(
1664
+ str(run.get("diff") or "").strip()
1665
+ or run.get("commits")
1666
+ or any(any(v.startswith("git ") for v in seg["views"]) for _, _, seg in _executed_shell(run))
1667
+ )
1668
+
1669
+
1670
+ def _check_no_commit(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1671
+ for seq, _, seg in _executed_shell(run):
1672
+ if is_git(seg, "commit"):
1673
+ return _verdict("violated", [_ev("command", seg["raw"], seq)])
1674
+ commits = list(run.get("commits") or [])
1675
+ if commits:
1676
+ return _verdict("violated", [_ev("commit", str(commits[0].get("message", "")).split("\n", 1)[0])])
1677
+ return _verdict("obeyed" if _git_activity(run) else "not_exercised")
1678
+
1679
+
1680
+ def _check_no_push(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1681
+ for seq, _, seg in _executed_shell(run):
1682
+ if is_git(seg, "push"):
1683
+ return _verdict("violated", [_ev("command", seg["raw"], seq)])
1684
+ if run.get("pushed_refs"):
1685
+ return _verdict("violated", [_ev("event", "pushed " + ", ".join(run["pushed_refs"]))])
1686
+ return _verdict("obeyed" if _git_activity(run) else "not_exercised")
1687
+
1688
+
1689
+ def _check_file_naming(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1690
+ globs = list(params["globs"])
1691
+ pattern = re.compile(str(params["pattern"]))
1692
+ exercised = False
1693
+ for f in _changed(run):
1694
+ if f["status"] != "added" or not glob_match(globs, f["path"]):
1695
+ continue
1696
+ exercised = True
1697
+ name = f["path"].rsplit("/", 1)[-1]
1698
+ if not pattern.fullmatch(name):
1699
+ return _verdict("violated", [_ev("diff", f"new file {f['path']}", path=f["path"])])
1700
+ return _verdict("obeyed" if exercised else "not_exercised")
1701
+
1702
+
1703
+ def _check_final_message(params: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1704
+ msg = str(run.get("final_message") or "").strip()
1705
+ if not msg:
1706
+ return _verdict("not_exercised")
1707
+ if "required" in params and not re.search(str(params["required"]), msg):
1708
+ return _verdict("violated", [_ev("message", msg.splitlines()[-1] if msg else "")])
1709
+ if "forbidden" in params:
1710
+ m = re.search(str(params["forbidden"]), msg)
1711
+ if m:
1712
+ return _verdict("violated", [_ev("message", m.group(0))])
1713
+ return _verdict("obeyed")
1714
+
1715
+
1716
+ CHECKS = {
1717
+ "command_forbidden": _check_command_forbidden,
1718
+ "command_required": _check_command_required,
1719
+ "path_forbidden": _check_path_forbidden,
1720
+ "path_required_with": _check_path_required_with,
1721
+ "dependency_unchanged": _check_dependency,
1722
+ "content_forbidden_added": _check_content_forbidden,
1723
+ "content_required_added": _check_content_required,
1724
+ "commit_message": _check_commit_message,
1725
+ "no_commit": _check_no_commit,
1726
+ "no_push": _check_no_push,
1727
+ "file_naming": _check_file_naming,
1728
+ "final_message": _check_final_message,
1729
+ }
1730
+
1731
+
1732
+ def check(rule: Dict[str, Any], run: Dict[str, Any]) -> Dict[str, Any]:
1733
+ """Verdict of one rule on one run. `rule` is a checker ({"type", "params"}) or a rule with a "checker" key."""
1734
+ nested = rule.get("checker")
1735
+ checker: Dict[str, Any] = nested if isinstance(nested, dict) else rule
1736
+ return CHECKS[str(checker["type"])](dict(checker.get("params") or {}), run)
1737
+
1738
+
1739
+ def reason(rule: Dict[str, Any], verdict: Dict[str, Any]) -> str:
1740
+ src = rule.get("source") or {}
1741
+ where = f" ({src.get('file')}:{(src.get('lines') or [''])[0]})" if src.get("file") else ""
1742
+ ev = (verdict.get("evidence") or [{}])[0]
1743
+ detail = f" Found: {ev.get('text')}." if ev.get("text") else ""
1744
+ return f'{DENY_MARK} "{rule.get("text", "")}"{where}.{detail}'
1745
+
1746
+
1747
+ # --------------------------------------------------------------------------------------------- actions (hooks)
1748
+
1749
+
1750
+ def _added_lines(old: str, new: str) -> List[str]:
1751
+ before: Dict[str, int] = {}
1752
+ for line in old.split("\n"):
1753
+ before[line] = before.get(line, 0) + 1
1754
+ out = []
1755
+ for line in new.split("\n"):
1756
+ if before.get(line, 0) > 0:
1757
+ before[line] -= 1
1758
+ else:
1759
+ out.append(line)
1760
+ return out
1761
+
1762
+
1763
+ def _action_run(action: Dict[str, Any]) -> Dict[str, Any]:
1764
+ """A one-event run record for a proposed action, so the same checks decide."""
1765
+ kind = action.get("kind")
1766
+ path = str(action.get("path") or "")
1767
+ if kind == "shell":
1768
+ return {
1769
+ "events": [{"seq": 0, "type": "shell", "command": action.get("command", ""), "exit_code": None}],
1770
+ "diff": "",
1771
+ "base_files": [],
1772
+ "commits": [],
1773
+ "pushed_refs": [],
1774
+ "final_message": "",
1775
+ }
1776
+ exists = bool(action.get("exists"))
1777
+ if kind == "delete":
1778
+ diff = f"diff --git a/{path} b/{path}\ndeleted file mode 100644\n--- a/{path}\n+++ /dev/null\n"
1779
+ return {
1780
+ "events": [{"seq": 0, "type": "delete", "path": path}],
1781
+ "diff": diff,
1782
+ "base_files": [path],
1783
+ "commits": [],
1784
+ "pushed_refs": [],
1785
+ "final_message": "",
1786
+ }
1787
+ if kind == "write":
1788
+ old = action.get("current") or ""
1789
+ new = str(action.get("content") or "")
1790
+ else:
1791
+ old = str(action.get("old") or "")
1792
+ new = str(action.get("new") or "")
1793
+ if action.get("new_content") is not None and action.get("current") is not None:
1794
+ old, new = str(action["current"]), str(action["new_content"])
1795
+ if not exists:
1796
+ diff = new_file_diff(path, new)
1797
+ else:
1798
+ added = _added_lines(old, new)
1799
+ removed = _added_lines(new, old)
1800
+ diff = (
1801
+ f"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1,{len(removed)} +1,{len(added)} @@\n"
1802
+ + "".join("-" + r + "\n" for r in removed)
1803
+ + "".join("+" + a + "\n" for a in added)
1804
+ )
1805
+ return {
1806
+ "events": [{"seq": 0, "type": "write" if kind == "write" else "edit", "path": path}],
1807
+ "diff": diff,
1808
+ "base_files": [path] if exists else [],
1809
+ "commits": [],
1810
+ "pushed_refs": [],
1811
+ "final_message": "",
1812
+ }
1813
+
1814
+
1815
+ _CAT_HEREDOC_RE = re.compile(r"\$\(\s*cat\s+(?=<<)")
1816
+
1817
+
1818
+ def _resolve_message(text: str) -> str:
1819
+ """`$(cat <<'EOF' ... EOF)` inside a commit message becomes the heredoc body, as the shell would run it."""
1820
+ out: List[str] = []
1821
+ pos = 0
1822
+ while True:
1823
+ m = _CAT_HEREDOC_RE.search(text, pos)
1824
+ if m is None:
1825
+ break
1826
+ hm = _HEREDOC_RE.match(text, m.end())
1827
+ nl = text.find("\n", hm.end()) if hm else -1
1828
+ term = _terminator(_hd(hm)[0]).search(text, nl + 1) if hm and nl != -1 else None
1829
+ close = text.find(")", term.end()) if term else -1
1830
+ if term is None or close == -1:
1831
+ out.append(text[pos : m.end()])
1832
+ pos = m.end()
1833
+ continue
1834
+ out.append(text[pos : m.start()])
1835
+ out.append(text[nl + 1 : term.start()].rstrip("\n"))
1836
+ pos = close + 1
1837
+ out.append(text[pos:])
1838
+ return "".join(out)
1839
+
1840
+
1841
+ def _commit_messages(command: str) -> List[str]:
1842
+ """The messages `git commit` commands in a command line would record (-m, -am, -sm, -mMSG, --message=MSG, -F -)."""
1843
+ out = []
1844
+ for seg in segments(command):
1845
+ if not is_git(seg, "commit"):
1846
+ continue
1847
+ args = _git_sub(seg)[1]
1848
+ msgs: List[str] = []
1849
+ i = 0
1850
+ while i < len(args):
1851
+ a = args[i]
1852
+ i += 1
1853
+ if a == "--":
1854
+ break
1855
+ if isinstance(a, _Redir):
1856
+ continue
1857
+ if a.startswith("--"):
1858
+ name, eq, value = a[2:].partition("=")
1859
+ if not (_prefix_of("--" + name, "--message", 5) or _prefix_of("--" + name, "--file", 5)):
1860
+ continue
1861
+ given = bool(eq)
1862
+ if not given and i < len(args):
1863
+ value = args[i]
1864
+ given = True
1865
+ i += 1
1866
+ if _prefix_of("--" + name, "--message", 5):
1867
+ if given:
1868
+ msgs.append(value)
1869
+ elif value == "-" and seg.get("heredoc") is not None:
1870
+ msgs.append(str(seg["heredoc"]).rstrip("\n"))
1871
+ elif a.startswith("-") and len(a) > 1:
1872
+ # A cluster of short options: the first that takes a value ends it (-am MSG, -sm MSG, -mMSG).
1873
+ for j, ch in enumerate(a[1:], 1):
1874
+ if ch in "mFcCt":
1875
+ value = a[j + 1 :]
1876
+ given = bool(value)
1877
+ if not given and i < len(args):
1878
+ value = args[i]
1879
+ given = True
1880
+ i += 1
1881
+ if ch == "m" and given:
1882
+ msgs.append(value)
1883
+ elif ch == "F" and value == "-" and seg.get("heredoc") is not None:
1884
+ msgs.append(str(seg["heredoc"]).rstrip("\n"))
1885
+ break
1886
+ if ch in "Su": # optional value glued to the option
1887
+ break
1888
+ if msgs:
1889
+ out.append(_resolve_message("\n\n".join(msgs)))
1890
+ return out
1891
+
1892
+
1893
+ def evaluate_action(rules: List[Dict[str, Any]], action: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1894
+ """The first rule a proposed action would break, as {"rule_id", "reason"}; None to allow it."""
1895
+ run = _action_run(action)
1896
+ for rule in rules:
1897
+ checker = rule["checker"]
1898
+ kind = checker["type"]
1899
+ params = checker.get("params") or {}
1900
+ verdict: Optional[Dict[str, Any]] = None
1901
+ if action.get("kind") == "shell":
1902
+ if kind in ("command_forbidden", "no_commit", "no_push", "dependency_unchanged"):
1903
+ if kind == "command_forbidden":
1904
+ params = {k: v for k, v in params.items() if k != "manual_edit_paths"}
1905
+ verdict = _check_command_forbidden(params, run)
1906
+ else:
1907
+ verdict = check(checker, run)
1908
+ elif kind == "path_forbidden":
1909
+ given = action.get("targets") # a hook resolves them against the working directory and symlinks
1910
+ if given is None:
1911
+ given = shell_write_targets(str(action.get("command") or ""))
1912
+ targets = [t for t in given if t["path"] != "*"]
1913
+ if targets:
1914
+ existing = action.get("existing_paths")
1915
+ shell_run = {
1916
+ "events": [{"seq": 0, "type": "write", "path": t["path"]} for t in targets],
1917
+ "diff": "",
1918
+ "base_files": list(existing) if existing is not None else [t["path"] for t in targets],
1919
+ "commits": [],
1920
+ "pushed_refs": [],
1921
+ }
1922
+ verdict = check(checker, shell_run)
1923
+ elif kind == "commit_message":
1924
+ for message in _commit_messages(str(action.get("command") or "")):
1925
+ verdict = check(checker, {"commits": [{"message": message}]})
1926
+ if verdict["verdict"] == "violated":
1927
+ break
1928
+ else:
1929
+ if kind in ("path_forbidden", "content_forbidden_added", "file_naming", "dependency_unchanged") or (
1930
+ kind == "content_required_added" and not action.get("exists") and action.get("kind") == "write"
1931
+ ):
1932
+ verdict = check(checker, run)
1933
+ elif kind == "command_forbidden" and params.get("manual_edit_paths"):
1934
+ if glob_match(list(params["manual_edit_paths"]), str(action.get("path") or "")):
1935
+ verdict = _verdict("violated", [_ev("event", f"edit {action.get('path')} by hand")])
1936
+ if verdict and verdict["verdict"] == "violated":
1937
+ return {"rule_id": rule.get("id"), "reason": reason(rule, verdict)}
1938
+ return None
1939
+
1940
+
1941
+ # --------------------------------------------------------------------------------------------- transcripts
1942
+
1943
+
1944
+ def transcript_recognized(text: str) -> bool:
1945
+ """True when the text is a Claude Code transcript (JSON lines that hold `message.content`)."""
1946
+ for line in text.splitlines():
1947
+ try:
1948
+ obj = json.loads(line)
1949
+ except ValueError:
1950
+ continue
1951
+ msg = obj.get("message") if isinstance(obj, dict) else None
1952
+ if isinstance(msg, dict) and isinstance(msg.get("content"), (list, str)):
1953
+ return True
1954
+ return False
1955
+
1956
+
1957
+ def events_from_transcript(text: str, root: str = "") -> List[Dict[str, Any]]:
1958
+ """Shell and file events from a Claude Code transcript (JSON lines), for stop-time checks."""
1959
+ events: List[Dict[str, Any]] = []
1960
+ pending: Dict[str, Dict[str, Any]] = {}
1961
+ prefix = root.rstrip("/") + "/" if root else ""
1962
+ for line in text.splitlines():
1963
+ try:
1964
+ obj = json.loads(line)
1965
+ except ValueError:
1966
+ continue
1967
+ msg = obj.get("message") if isinstance(obj, dict) else None
1968
+ if not isinstance(msg, dict) or not isinstance(msg.get("content"), list):
1969
+ continue
1970
+ for block in msg["content"]:
1971
+ if not isinstance(block, dict):
1972
+ continue
1973
+ if block.get("type") == "tool_use":
1974
+ name = block.get("name")
1975
+ data = block.get("input") or {}
1976
+ path = str(data.get("file_path") or "")
1977
+ if prefix and path.startswith(prefix):
1978
+ path = path[len(prefix) :]
1979
+ if name == "Bash":
1980
+ ev: Dict[str, Any] = {"type": "shell", "command": str(data.get("command") or ""), "exit_code": None}
1981
+ elif name in ("Edit", "MultiEdit", "NotebookEdit"):
1982
+ ev = {"type": "edit", "path": path}
1983
+ elif name == "Write":
1984
+ ev = {"type": "write", "path": path}
1985
+ else:
1986
+ continue
1987
+ ev["seq"] = len(events)
1988
+ events.append(ev)
1989
+ pending[str(block.get("id"))] = ev
1990
+ elif block.get("type") == "tool_result":
1991
+ ev2 = pending.pop(str(block.get("tool_use_id")), None)
1992
+ if ev2 is not None and ev2["type"] == "shell":
1993
+ content = block.get("content")
1994
+ body = (
1995
+ content
1996
+ if isinstance(content, str)
1997
+ else " ".join(str(c.get("text", "")) for c in content or [] if isinstance(c, dict))
1998
+ )
1999
+ # Only our own hook's refusal counts as one (the agent shows it as an error result); a command whose
2000
+ # output merely mentions a hook being denied or blocked ran.
2001
+ if block.get("is_error") and DENY_MARK in body[:600]:
2002
+ ev2["denied"] = True
2003
+ m = re.search(r"Exit code (\d+)", body)
2004
+ ev2["exit_code"] = (int(m.group(1)) if m else 1) if block.get("is_error") else 0
2005
+ return events