restory 0.1.1__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.
- restory/__init__.py +3 -0
- restory/__main__.py +5 -0
- restory/classify.py +672 -0
- restory/cli.py +218 -0
- restory/config.py +40 -0
- restory/hook.py +89 -0
- restory/server.py +92 -0
- restory/snapshot.py +241 -0
- restory/store.py +161 -0
- restory/ui/app/globals.css +358 -0
- restory/ui/app/layout.tsx +26 -0
- restory/ui/app/page.tsx +152 -0
- restory/ui/next-env.d.ts +5 -0
- restory/ui/next.config.mjs +8 -0
- restory/ui/package-lock.json +1664 -0
- restory/ui/package.json +24 -0
- restory/ui/postcss.config.js +6 -0
- restory/ui/tailwind.config.ts +15 -0
- restory/ui/tsconfig.json +21 -0
- restory-0.1.1.dist-info/METADATA +61 -0
- restory-0.1.1.dist-info/RECORD +24 -0
- restory-0.1.1.dist-info/WHEEL +4 -0
- restory-0.1.1.dist-info/entry_points.txt +2 -0
- restory-0.1.1.dist-info/licenses/LICENSE +21 -0
restory/__init__.py
ADDED
restory/__main__.py
ADDED
restory/classify.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""Blast-radius classification of Claude Code PreToolUse payloads.
|
|
2
|
+
|
|
3
|
+
Given a tool call ``{"tool_name": str, "tool_input": {...}}`` this module
|
|
4
|
+
returns a :class:`ClassifyResult` describing which blast-radius tags apply,
|
|
5
|
+
whether the call is dangerous, and a one-line human-readable reason.
|
|
6
|
+
|
|
7
|
+
All path handling uses :class:`pathlib.Path` so behavior is correct on
|
|
8
|
+
Windows as well as POSIX platforms.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from fnmatch import fnmatch
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
from .config import find_repo_root
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
# Result type
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ClassifyResult:
|
|
29
|
+
tags: list[str] = field(default_factory=list)
|
|
30
|
+
danger: bool = False
|
|
31
|
+
reason: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
# Constants
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
|
|
38
|
+
_SECRET_GLOBS = (".env*", "*.pem", "*id_rsa*", "*.key", "*credentials*", "*.aws/*")
|
|
39
|
+
|
|
40
|
+
_NET_VERBS = {
|
|
41
|
+
"curl",
|
|
42
|
+
"wget",
|
|
43
|
+
"invoke-webrequest",
|
|
44
|
+
"iwr",
|
|
45
|
+
"nc",
|
|
46
|
+
"ncat",
|
|
47
|
+
"netcat",
|
|
48
|
+
"scp",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_ENCODE_CMDS = {"base64", "xxd"}
|
|
52
|
+
|
|
53
|
+
_DELETE_CMDS = {"rm", "remove-item", "ri", "del", "erase", "rmdir", "rd"}
|
|
54
|
+
|
|
55
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"}
|
|
56
|
+
|
|
57
|
+
# Redirect targets that discard output rather than writing a real file.
|
|
58
|
+
_NULL_SINKS = {"/dev/null", "nul"}
|
|
59
|
+
|
|
60
|
+
# Shells that, when fed on stdin, execute arbitrary constructed code.
|
|
61
|
+
_SHELL_CMDS = {"sh", "bash", "zsh", "dash", "ash"}
|
|
62
|
+
|
|
63
|
+
# Interpreters whose ``-c``/``-e`` one-liner body we cannot statically inspect.
|
|
64
|
+
_INTERP_CMDS = {"python", "python3", "node", "nodejs", "perl", "ruby"}
|
|
65
|
+
|
|
66
|
+
# Keywords that betray a delete or network verb inside an interpreter one-liner.
|
|
67
|
+
_CODE_DELETE_KW = (
|
|
68
|
+
"unlink",
|
|
69
|
+
"rmtree",
|
|
70
|
+
"os.remove",
|
|
71
|
+
"remove(",
|
|
72
|
+
"rmdir",
|
|
73
|
+
"shutil",
|
|
74
|
+
"fs.rm",
|
|
75
|
+
"rimraf",
|
|
76
|
+
"rm -",
|
|
77
|
+
" rm ",
|
|
78
|
+
"del ",
|
|
79
|
+
)
|
|
80
|
+
_CODE_NET_KW = (
|
|
81
|
+
"socket",
|
|
82
|
+
"urllib",
|
|
83
|
+
"urlopen",
|
|
84
|
+
"requests",
|
|
85
|
+
"http://",
|
|
86
|
+
"https://",
|
|
87
|
+
"fetch(",
|
|
88
|
+
"http.client",
|
|
89
|
+
"httplib",
|
|
90
|
+
"net::http",
|
|
91
|
+
"wget",
|
|
92
|
+
"curl",
|
|
93
|
+
"connect(",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Max recursion depth when unrolling nested command substitutions.
|
|
97
|
+
_MAX_SUBST_DEPTH = 4
|
|
98
|
+
|
|
99
|
+
# Tag → reason priority (most severe first).
|
|
100
|
+
_TAG_PRIORITY = (
|
|
101
|
+
"pipe-to-shell",
|
|
102
|
+
"uninspectable",
|
|
103
|
+
"mass-delete",
|
|
104
|
+
"git-hook-write",
|
|
105
|
+
"git-destructive",
|
|
106
|
+
"net-egress",
|
|
107
|
+
"read-secret",
|
|
108
|
+
"write-outside-repo",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
# Bash tokenizing helpers
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _split_segments(command: str) -> list[str]:
|
|
118
|
+
"""Split a shell command on ``;`` ``&&`` ``||`` ``|`` respecting quotes."""
|
|
119
|
+
segments: list[str] = []
|
|
120
|
+
buf = ""
|
|
121
|
+
in_single = in_double = False
|
|
122
|
+
i = 0
|
|
123
|
+
n = len(command)
|
|
124
|
+
while i < n:
|
|
125
|
+
c = command[i]
|
|
126
|
+
if c == "'" and not in_double:
|
|
127
|
+
in_single = not in_single
|
|
128
|
+
buf += c
|
|
129
|
+
i += 1
|
|
130
|
+
continue
|
|
131
|
+
if c == '"' and not in_single:
|
|
132
|
+
in_double = not in_double
|
|
133
|
+
buf += c
|
|
134
|
+
i += 1
|
|
135
|
+
continue
|
|
136
|
+
if not in_single and not in_double:
|
|
137
|
+
two = command[i : i + 2]
|
|
138
|
+
if two in ("&&", "||"):
|
|
139
|
+
segments.append(buf)
|
|
140
|
+
buf = ""
|
|
141
|
+
i += 2
|
|
142
|
+
continue
|
|
143
|
+
if c in ";|":
|
|
144
|
+
segments.append(buf)
|
|
145
|
+
buf = ""
|
|
146
|
+
i += 1
|
|
147
|
+
continue
|
|
148
|
+
buf += c
|
|
149
|
+
i += 1
|
|
150
|
+
segments.append(buf)
|
|
151
|
+
return [s.strip() for s in segments if s.strip()]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tokenize(segment: str) -> list[str]:
|
|
155
|
+
"""Tokenize a single command segment, tolerating malformed quoting."""
|
|
156
|
+
try:
|
|
157
|
+
return shlex.split(segment, posix=True)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return segment.split()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _split_on_pipe(command: str) -> list[str]:
|
|
163
|
+
"""Split a command into pipeline stages on single ``|`` (respecting quotes).
|
|
164
|
+
|
|
165
|
+
``||`` (logical OR) is not a pipe and is left intact within a stage.
|
|
166
|
+
"""
|
|
167
|
+
parts: list[str] = []
|
|
168
|
+
buf = ""
|
|
169
|
+
in_single = in_double = False
|
|
170
|
+
i = 0
|
|
171
|
+
n = len(command)
|
|
172
|
+
while i < n:
|
|
173
|
+
c = command[i]
|
|
174
|
+
if c == "'" and not in_double:
|
|
175
|
+
in_single = not in_single
|
|
176
|
+
buf += c
|
|
177
|
+
i += 1
|
|
178
|
+
continue
|
|
179
|
+
if c == '"' and not in_single:
|
|
180
|
+
in_double = not in_double
|
|
181
|
+
buf += c
|
|
182
|
+
i += 1
|
|
183
|
+
continue
|
|
184
|
+
if not in_single and not in_double:
|
|
185
|
+
if command[i : i + 2] == "||":
|
|
186
|
+
buf += "||"
|
|
187
|
+
i += 2
|
|
188
|
+
continue
|
|
189
|
+
if c == "|":
|
|
190
|
+
parts.append(buf)
|
|
191
|
+
buf = ""
|
|
192
|
+
i += 1
|
|
193
|
+
continue
|
|
194
|
+
buf += c
|
|
195
|
+
i += 1
|
|
196
|
+
parts.append(buf)
|
|
197
|
+
return parts
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _extract_substitutions(segment: str) -> tuple[list[str], bool]:
|
|
201
|
+
"""Return ``(inner_commands, unparseable)`` for ``$(...)`` and backticks.
|
|
202
|
+
|
|
203
|
+
``unparseable`` is True when a substitution is opened but never balanced
|
|
204
|
+
(an unbalanced ``$(`` or an odd number of backticks) — meaning we cannot
|
|
205
|
+
recover the inner command to inspect it.
|
|
206
|
+
"""
|
|
207
|
+
inners: list[str] = []
|
|
208
|
+
unparseable = False
|
|
209
|
+
|
|
210
|
+
# $( ... ) with balanced parens; single quotes suppress substitution.
|
|
211
|
+
i = 0
|
|
212
|
+
n = len(segment)
|
|
213
|
+
in_single = False
|
|
214
|
+
while i < n:
|
|
215
|
+
c = segment[i]
|
|
216
|
+
if c == "'":
|
|
217
|
+
in_single = not in_single
|
|
218
|
+
i += 1
|
|
219
|
+
continue
|
|
220
|
+
if not in_single and c == "$" and i + 1 < n and segment[i + 1] == "(":
|
|
221
|
+
depth = 1
|
|
222
|
+
j = i + 2
|
|
223
|
+
while j < n and depth > 0:
|
|
224
|
+
if segment[j] == "(":
|
|
225
|
+
depth += 1
|
|
226
|
+
elif segment[j] == ")":
|
|
227
|
+
depth -= 1
|
|
228
|
+
j += 1
|
|
229
|
+
if depth != 0:
|
|
230
|
+
unparseable = True
|
|
231
|
+
break
|
|
232
|
+
inners.append(segment[i + 2 : j - 1])
|
|
233
|
+
i = j
|
|
234
|
+
continue
|
|
235
|
+
i += 1
|
|
236
|
+
|
|
237
|
+
# Backticks: pair them up, ignoring those inside single quotes.
|
|
238
|
+
bt_positions: list[int] = []
|
|
239
|
+
in_single = False
|
|
240
|
+
for k, ch in enumerate(segment):
|
|
241
|
+
if ch == "'":
|
|
242
|
+
in_single = not in_single
|
|
243
|
+
elif ch == "`" and not in_single:
|
|
244
|
+
bt_positions.append(k)
|
|
245
|
+
if len(bt_positions) % 2 == 1:
|
|
246
|
+
unparseable = True
|
|
247
|
+
else:
|
|
248
|
+
for a in range(0, len(bt_positions), 2):
|
|
249
|
+
inners.append(segment[bt_positions[a] + 1 : bt_positions[a + 1]])
|
|
250
|
+
|
|
251
|
+
return inners, unparseable
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _extract_redirect_targets(segment: str) -> list[str]:
|
|
255
|
+
"""Return file targets of ``>``/``>>`` redirections in ``segment``.
|
|
256
|
+
|
|
257
|
+
File-descriptor duplications like ``>&2`` are ignored (no file target).
|
|
258
|
+
"""
|
|
259
|
+
targets: list[str] = []
|
|
260
|
+
i = 0
|
|
261
|
+
n = len(segment)
|
|
262
|
+
in_single = in_double = False
|
|
263
|
+
while i < n:
|
|
264
|
+
c = segment[i]
|
|
265
|
+
if c == "'" and not in_double:
|
|
266
|
+
in_single = not in_single
|
|
267
|
+
i += 1
|
|
268
|
+
continue
|
|
269
|
+
if c == '"' and not in_single:
|
|
270
|
+
in_double = not in_double
|
|
271
|
+
i += 1
|
|
272
|
+
continue
|
|
273
|
+
if c == ">" and not in_single and not in_double:
|
|
274
|
+
j = i + 1
|
|
275
|
+
if j < n and segment[j] == ">": # ">>"
|
|
276
|
+
j += 1
|
|
277
|
+
while j < n and segment[j] in " \t":
|
|
278
|
+
j += 1
|
|
279
|
+
if j < n and segment[j] == "&": # ">&2" fd dup, not a file
|
|
280
|
+
i = j + 1
|
|
281
|
+
continue
|
|
282
|
+
tok = ""
|
|
283
|
+
t_single = t_double = False
|
|
284
|
+
while j < n:
|
|
285
|
+
ch = segment[j]
|
|
286
|
+
if ch == "'" and not t_double:
|
|
287
|
+
t_single = not t_single
|
|
288
|
+
j += 1
|
|
289
|
+
continue
|
|
290
|
+
if ch == '"' and not t_single:
|
|
291
|
+
t_double = not t_double
|
|
292
|
+
j += 1
|
|
293
|
+
continue
|
|
294
|
+
if not t_single and not t_double and ch in " \t|;&<>":
|
|
295
|
+
break
|
|
296
|
+
tok += ch
|
|
297
|
+
j += 1
|
|
298
|
+
if tok:
|
|
299
|
+
targets.append(tok)
|
|
300
|
+
i = j
|
|
301
|
+
continue
|
|
302
|
+
i += 1
|
|
303
|
+
return targets
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _basename_cmd(token: str) -> str:
|
|
307
|
+
"""Return the lowercased command name, stripping any path prefix."""
|
|
308
|
+
norm = token.replace("\\", "/")
|
|
309
|
+
return norm.rsplit("/", 1)[-1].lower()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _has_unquoted_tilde_target(segment: str) -> bool:
|
|
313
|
+
"""True if the segment contains an unquoted ``~`` used as a path argument.
|
|
314
|
+
|
|
315
|
+
This is the CVE class: ``rm -rf ~`` expands to ``$HOME`` while ``rm -rf
|
|
316
|
+
"~"`` targets a literal directory named ``~``.
|
|
317
|
+
"""
|
|
318
|
+
in_single = in_double = False
|
|
319
|
+
prev = " "
|
|
320
|
+
i = 0
|
|
321
|
+
n = len(segment)
|
|
322
|
+
while i < n:
|
|
323
|
+
c = segment[i]
|
|
324
|
+
if c == "'" and not in_double:
|
|
325
|
+
in_single = not in_single
|
|
326
|
+
elif c == '"' and not in_single:
|
|
327
|
+
in_double = not in_double
|
|
328
|
+
elif c == "~" and not in_single and not in_double and prev.isspace():
|
|
329
|
+
nxt = segment[i + 1] if i + 1 < n else " "
|
|
330
|
+
if nxt in ("/", "\\") or nxt.isspace():
|
|
331
|
+
return True
|
|
332
|
+
prev = c
|
|
333
|
+
i += 1
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# --------------------------------------------------------------------------- #
|
|
338
|
+
# Path / secret helpers
|
|
339
|
+
# --------------------------------------------------------------------------- #
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _resolve_target(raw: str, repo_root: Path) -> Path:
|
|
343
|
+
"""Expand ``~`` and ``%VAR%``/``$VAR`` then resolve relative to repo root."""
|
|
344
|
+
expanded = os.path.expandvars(os.path.expanduser(raw))
|
|
345
|
+
p = Path(expanded)
|
|
346
|
+
if not p.is_absolute():
|
|
347
|
+
p = repo_root / p
|
|
348
|
+
try:
|
|
349
|
+
return p.resolve()
|
|
350
|
+
except (OSError, ValueError):
|
|
351
|
+
return p
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _is_inside(path: Path, root: Path) -> bool:
|
|
355
|
+
try:
|
|
356
|
+
return path.resolve().is_relative_to(root.resolve())
|
|
357
|
+
except (OSError, ValueError):
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _matches_secret(token: str) -> bool:
|
|
362
|
+
"""True if ``token`` looks like a reference to a secret-bearing file."""
|
|
363
|
+
cand = token.lstrip("@").replace("\\", "/")
|
|
364
|
+
if not cand:
|
|
365
|
+
return False
|
|
366
|
+
base = cand.rsplit("/", 1)[-1]
|
|
367
|
+
for pat in _SECRET_GLOBS:
|
|
368
|
+
if fnmatch(base, pat) or fnmatch(cand, pat):
|
|
369
|
+
return True
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _extract_host(token: str) -> str | None:
|
|
374
|
+
"""Extract a hostname from a URL or scp-style ``user@host:path`` token."""
|
|
375
|
+
if "://" in token:
|
|
376
|
+
host = urlparse(token).hostname
|
|
377
|
+
return host
|
|
378
|
+
# scp / rsync style: user@host:path or host:path
|
|
379
|
+
if ":" in token and "/" not in token.split(":", 1)[0]:
|
|
380
|
+
left = token.split(":", 1)[0]
|
|
381
|
+
host = left.rsplit("@", 1)[-1]
|
|
382
|
+
if host and "." in host:
|
|
383
|
+
return host
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _is_local(host: str) -> bool:
|
|
388
|
+
h = host.lower()
|
|
389
|
+
if h in _LOCAL_HOSTS:
|
|
390
|
+
return True
|
|
391
|
+
return h.startswith(("127.", "10.", "192.168.", "169.254.")) or h == "::1"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# --------------------------------------------------------------------------- #
|
|
395
|
+
# Per-command detectors
|
|
396
|
+
# --------------------------------------------------------------------------- #
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _detect_find_delete(cmd: str, tokens: list[str]) -> str | None:
|
|
400
|
+
if cmd != "find":
|
|
401
|
+
return None
|
|
402
|
+
lowered = [t.lower() for t in tokens[1:]]
|
|
403
|
+
if "-delete" in lowered:
|
|
404
|
+
return "mass-delete: find -delete removes every matched file"
|
|
405
|
+
if "-exec" in lowered or "-execdir" in lowered:
|
|
406
|
+
# any rm in the -exec body
|
|
407
|
+
if any(_basename_cmd(t) == "rm" for t in tokens[1:]):
|
|
408
|
+
return "mass-delete: find -exec rm deletes every matched file"
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _detect_mass_delete(cmd: str, tokens: list[str], segment: str, repo_root: Path):
|
|
413
|
+
r = _detect_find_delete(cmd, tokens)
|
|
414
|
+
if r:
|
|
415
|
+
return r
|
|
416
|
+
if cmd not in _DELETE_CMDS:
|
|
417
|
+
return None
|
|
418
|
+
recursive = False
|
|
419
|
+
dangerous_target = None
|
|
420
|
+
for tok in tokens[1:]:
|
|
421
|
+
low = tok.lower()
|
|
422
|
+
if tok.startswith("-") and not tok.startswith("--"):
|
|
423
|
+
if "r" in low: # -r, -rf, -fr, -R ...
|
|
424
|
+
recursive = True
|
|
425
|
+
if low in ("-recurse", "--recursive"):
|
|
426
|
+
recursive = True
|
|
427
|
+
if not tok.startswith("-"):
|
|
428
|
+
target = _classify_delete_target(tok, repo_root)
|
|
429
|
+
if target:
|
|
430
|
+
dangerous_target = target
|
|
431
|
+
if _has_unquoted_tilde_target(segment):
|
|
432
|
+
dangerous_target = dangerous_target or "home directory (~)"
|
|
433
|
+
if recursive or dangerous_target:
|
|
434
|
+
target = dangerous_target or "recursively"
|
|
435
|
+
return f"mass-delete: '{cmd}' deletes {target}"
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _classify_delete_target(token: str, repo_root: Path) -> str | None:
|
|
440
|
+
if token in ("~", "~/", "$HOME", "%USERPROFILE%"):
|
|
441
|
+
return "home directory"
|
|
442
|
+
resolved = _resolve_target(token, repo_root)
|
|
443
|
+
try:
|
|
444
|
+
anchor = Path(resolved.anchor)
|
|
445
|
+
except (OSError, ValueError):
|
|
446
|
+
anchor = None
|
|
447
|
+
if anchor is not None and resolved == anchor:
|
|
448
|
+
return "filesystem root"
|
|
449
|
+
if resolved == repo_root.resolve():
|
|
450
|
+
return "repository root"
|
|
451
|
+
try:
|
|
452
|
+
if resolved == Path.home().resolve():
|
|
453
|
+
return "home directory"
|
|
454
|
+
except (OSError, ValueError):
|
|
455
|
+
pass
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _detect_git_destructive(cmd: str, tokens: list[str]) -> str | None:
|
|
460
|
+
if cmd != "git":
|
|
461
|
+
return None
|
|
462
|
+
args = [t.lower() for t in tokens[1:]]
|
|
463
|
+
argset = set(args)
|
|
464
|
+
if "reset" in args and "--hard" in argset:
|
|
465
|
+
return "git-destructive: git reset --hard discards working-tree changes"
|
|
466
|
+
if "clean" in args and any(
|
|
467
|
+
t.startswith("-") and "f" in t for t in args
|
|
468
|
+
):
|
|
469
|
+
return "git-destructive: git clean removes untracked files"
|
|
470
|
+
if "push" in args and ("--force" in argset or "-f" in argset or "--force-with-lease" in argset):
|
|
471
|
+
return "git-destructive: git push --force rewrites remote history"
|
|
472
|
+
if "branch" in args and ("-d" in argset or "--delete" in argset):
|
|
473
|
+
return "git-destructive: git branch deletion"
|
|
474
|
+
if "push" in args and any(a.startswith(":") for a in args):
|
|
475
|
+
return "git-destructive: git push deletes a remote branch"
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _detect_net_egress(cmd: str, tokens: list[str]) -> str | None:
|
|
480
|
+
if cmd not in _NET_VERBS:
|
|
481
|
+
return None
|
|
482
|
+
for tok in tokens[1:]:
|
|
483
|
+
host = _extract_host(tok)
|
|
484
|
+
if host and not _is_local(host):
|
|
485
|
+
return f"net-egress: {cmd} sends data to external host {host}"
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _detect_pipe_to_shell(command: str) -> str | None:
|
|
490
|
+
"""True if output is piped into a shell, or code is fed to eval/iex.
|
|
491
|
+
|
|
492
|
+
Covers ``curl ... | sh``, ``base64 -d | bash``, and PowerShell
|
|
493
|
+
``Invoke-Expression`` / ``iex``.
|
|
494
|
+
"""
|
|
495
|
+
stages = _split_on_pipe(command)
|
|
496
|
+
for idx, stage in enumerate(stages):
|
|
497
|
+
tokens = _tokenize(stage)
|
|
498
|
+
for tok in tokens:
|
|
499
|
+
if tok.lower() in ("iex", "invoke-expression"):
|
|
500
|
+
return "pipe-to-shell: Invoke-Expression runs constructed code"
|
|
501
|
+
if idx > 0 and tokens:
|
|
502
|
+
name = _basename_cmd(tokens[0])
|
|
503
|
+
if name in _SHELL_CMDS:
|
|
504
|
+
return f"pipe-to-shell: output piped into '{name}'"
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _detect_interpreter_oneliner(cmd: str, tokens: list[str]) -> str | None:
|
|
509
|
+
"""Flag ``python -c`` / ``node -e`` / ``perl -e`` one-liners whose inline
|
|
510
|
+
body performs a delete or network operation (statically uninspectable)."""
|
|
511
|
+
if cmd not in _INTERP_CMDS:
|
|
512
|
+
return None
|
|
513
|
+
body = None
|
|
514
|
+
for idx in range(1, len(tokens) - 1):
|
|
515
|
+
if tokens[idx] in ("-c", "-e"):
|
|
516
|
+
body = tokens[idx + 1]
|
|
517
|
+
break
|
|
518
|
+
if body is None:
|
|
519
|
+
return None
|
|
520
|
+
low = body.lower()
|
|
521
|
+
if any(kw in low for kw in _CODE_DELETE_KW):
|
|
522
|
+
return f"uninspectable: {cmd} inline code performs a delete"
|
|
523
|
+
if any(kw in low for kw in _CODE_NET_KW):
|
|
524
|
+
return f"uninspectable: {cmd} inline code performs network I/O"
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _detect_redirect_writes(segment: str, repo_root: Path):
|
|
529
|
+
"""Yield ``(tag, reason)`` for ``>``/``>>`` redirections to sensitive targets."""
|
|
530
|
+
results: list[tuple[str, str]] = []
|
|
531
|
+
for tgt in _extract_redirect_targets(segment):
|
|
532
|
+
norm = tgt.replace("\\", "/")
|
|
533
|
+
# Null sinks (`2>/dev/null`, `>NUL`) discard output; not a real write.
|
|
534
|
+
if norm.lower() in _NULL_SINKS:
|
|
535
|
+
continue
|
|
536
|
+
if ".git/hooks/" in norm or norm.endswith("/.git/hooks"):
|
|
537
|
+
results.append(
|
|
538
|
+
("git-hook-write", f"git-hook-write: redirect writes to git hook {tgt}")
|
|
539
|
+
)
|
|
540
|
+
continue
|
|
541
|
+
if _matches_secret(tgt):
|
|
542
|
+
results.append(
|
|
543
|
+
("read-secret", f"read-secret: redirect writes to secret file {tgt}")
|
|
544
|
+
)
|
|
545
|
+
resolved = _resolve_target(tgt, repo_root)
|
|
546
|
+
if not _is_inside(resolved, repo_root):
|
|
547
|
+
results.append(
|
|
548
|
+
(
|
|
549
|
+
"write-outside-repo",
|
|
550
|
+
f"write-outside-repo: redirect writes to {resolved} outside repo root {repo_root}",
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
return results
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# --------------------------------------------------------------------------- #
|
|
557
|
+
# Tool handlers
|
|
558
|
+
# --------------------------------------------------------------------------- #
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _classify_bash(command: str, repo_root: Path):
|
|
562
|
+
tags: list[str] = []
|
|
563
|
+
reasons: dict[str, str] = {}
|
|
564
|
+
|
|
565
|
+
def add(tag: str, reason: str) -> None:
|
|
566
|
+
if tag not in tags:
|
|
567
|
+
tags.append(tag)
|
|
568
|
+
reasons[tag] = reason
|
|
569
|
+
|
|
570
|
+
_process_command(command, repo_root, add, depth=0)
|
|
571
|
+
return tags, reasons
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _process_command(command: str, repo_root: Path, add, depth: int) -> None:
|
|
575
|
+
"""Classify one command string (recursing into command substitutions)."""
|
|
576
|
+
r = _detect_pipe_to_shell(command)
|
|
577
|
+
if r:
|
|
578
|
+
add("pipe-to-shell", r)
|
|
579
|
+
|
|
580
|
+
for segment in _split_segments(command):
|
|
581
|
+
tokens = _tokenize(segment)
|
|
582
|
+
if not tokens:
|
|
583
|
+
continue
|
|
584
|
+
cmd = _basename_cmd(tokens[0])
|
|
585
|
+
|
|
586
|
+
# Command substitutions: recursively classify the inner command; if it
|
|
587
|
+
# cannot be parsed (unbalanced), it is uninspectable.
|
|
588
|
+
if depth < _MAX_SUBST_DEPTH:
|
|
589
|
+
inners, unparseable = _extract_substitutions(segment)
|
|
590
|
+
if unparseable:
|
|
591
|
+
add(
|
|
592
|
+
"uninspectable",
|
|
593
|
+
"uninspectable: unbalanced command substitution could not be parsed",
|
|
594
|
+
)
|
|
595
|
+
for inner in inners:
|
|
596
|
+
if inner.strip():
|
|
597
|
+
_process_command(inner, repo_root, add, depth + 1)
|
|
598
|
+
|
|
599
|
+
r = _detect_mass_delete(cmd, tokens, segment, repo_root)
|
|
600
|
+
if r:
|
|
601
|
+
add("mass-delete", r)
|
|
602
|
+
|
|
603
|
+
r = _detect_git_destructive(cmd, tokens)
|
|
604
|
+
if r:
|
|
605
|
+
add("git-destructive", r)
|
|
606
|
+
|
|
607
|
+
r = _detect_net_egress(cmd, tokens)
|
|
608
|
+
if r:
|
|
609
|
+
add("net-egress", r)
|
|
610
|
+
|
|
611
|
+
r = _detect_interpreter_oneliner(cmd, tokens)
|
|
612
|
+
if r:
|
|
613
|
+
add("uninspectable", r)
|
|
614
|
+
|
|
615
|
+
for tag, reason in _detect_redirect_writes(segment, repo_root):
|
|
616
|
+
add(tag, reason)
|
|
617
|
+
|
|
618
|
+
for tok in tokens:
|
|
619
|
+
if _matches_secret(tok):
|
|
620
|
+
add("read-secret", f"read-secret: references secret file {tok.lstrip('@')}")
|
|
621
|
+
break
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _classify_fileop(tool_name: str, tool_input: dict, repo_root: Path):
|
|
625
|
+
tags: list[str] = []
|
|
626
|
+
reasons: dict[str, str] = {}
|
|
627
|
+
raw = tool_input.get("file_path") or tool_input.get("path")
|
|
628
|
+
if not raw:
|
|
629
|
+
return tags, reasons
|
|
630
|
+
|
|
631
|
+
if _matches_secret(str(raw)):
|
|
632
|
+
tags.append("read-secret")
|
|
633
|
+
reasons["read-secret"] = f"read-secret: {tool_name} targets secret file {raw}"
|
|
634
|
+
|
|
635
|
+
resolved = _resolve_target(str(raw), repo_root)
|
|
636
|
+
if not _is_inside(resolved, repo_root):
|
|
637
|
+
tags.append("write-outside-repo")
|
|
638
|
+
reasons["write-outside-repo"] = (
|
|
639
|
+
f"write-outside-repo: {tool_name} writes to {resolved} outside repo root {repo_root}"
|
|
640
|
+
)
|
|
641
|
+
return tags, reasons
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# --------------------------------------------------------------------------- #
|
|
645
|
+
# Public entry point
|
|
646
|
+
# --------------------------------------------------------------------------- #
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def classify(tool_call: dict, repo_root: Path | None = None) -> ClassifyResult:
|
|
650
|
+
"""Classify a Claude Code PreToolUse payload.
|
|
651
|
+
|
|
652
|
+
``repo_root`` may be supplied for testing; it defaults to the resolved
|
|
653
|
+
repository root of the current working directory.
|
|
654
|
+
"""
|
|
655
|
+
root = Path(repo_root).resolve() if repo_root is not None else find_repo_root()
|
|
656
|
+
tool_name = tool_call.get("tool_name", "")
|
|
657
|
+
tool_input = tool_call.get("tool_input") or {}
|
|
658
|
+
|
|
659
|
+
if tool_name == "Bash":
|
|
660
|
+
tags, reasons = _classify_bash(str(tool_input.get("command", "")), root)
|
|
661
|
+
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
|
662
|
+
tags, reasons = _classify_fileop(tool_name, tool_input, root)
|
|
663
|
+
else:
|
|
664
|
+
tags, reasons = [], {}
|
|
665
|
+
|
|
666
|
+
danger = bool(tags)
|
|
667
|
+
reason = "no blast-radius indicators"
|
|
668
|
+
for tag in _TAG_PRIORITY:
|
|
669
|
+
if tag in reasons:
|
|
670
|
+
reason = reasons[tag]
|
|
671
|
+
break
|
|
672
|
+
return ClassifyResult(tags=tags, danger=danger, reason=reason)
|