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 ADDED
@@ -0,0 +1,3 @@
1
+ """restory package."""
2
+
3
+ __version__ = "0.1.0"
restory/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m restory`` to run the CLI."""
2
+
3
+ from .cli import app
4
+
5
+ app()
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)