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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|