keywright 0.0.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.
keywright/hazards.py ADDED
@@ -0,0 +1,948 @@
1
+ """Destructive-operation tripwire.
2
+
3
+ Classification says WHAT a command is (`rm`, `docker/compose/down`); this says whether a human
4
+ must look at it fresh. It runs on every SSH operation before policy is consulted, so a standing
5
+ rule or a one-hour grant never lets `rm -rf /home` through unseen.
6
+
7
+ Severities:
8
+ critical refused. Keywright never runs it; the human can do it by hand over real SSH.
9
+ high always a fresh prompt, approved once and exactly, whatever grants or rules exist.
10
+ medium runs under existing access; the prompt, the result and the audit row carry the flag.
11
+
12
+ Two passes over a command line. The structured pass tokenizes it, strips `sudo`/`env`/`timeout`
13
+ wrappers, follows `bash -c`, `ssh host ...`, `xargs` and `find -delete`, and tracks `cd` so
14
+ `cd /home/x && rm -rf .` is seen for what it is. The lexical pass scans the raw text for shapes
15
+ the structured pass cannot reach: `python -c "shutil.rmtree('/')"`, heredocs, quoted scripts.
16
+
17
+ This is a tripwire, not a sandbox. It reads the command line, not what runs. Code executed by a
18
+ test runner or interpreter is invisible to it, which is why Keywright keeps credentials off the
19
+ agent's machine in the first place.
20
+
21
+ Stdlib only, no package imports: the file runs standalone as a Claude Code PreToolUse hook
22
+ (`python3 hazards.py --hook`) on a machine that has no Keywright installed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import fnmatch
28
+ import json
29
+ import posixpath
30
+ import re
31
+ import shlex
32
+ import sys
33
+ from collections.abc import Iterable
34
+ from dataclasses import dataclass
35
+ from typing import Any
36
+
37
+ SEVERITIES = ("medium", "high", "critical")
38
+ _RANK = {s: i for i, s in enumerate(SEVERITIES)}
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Hazard:
43
+ severity: str
44
+ reason: str
45
+
46
+ def as_dict(self) -> dict[str, str]:
47
+ return {"severity": self.severity, "reason": self.reason}
48
+
49
+
50
+ def worst(hazards: Iterable[Hazard | dict[str, Any]] | None) -> str | None:
51
+ """The highest severity present, or None."""
52
+ top: str | None = None
53
+ for h in hazards or ():
54
+ s = h.severity if isinstance(h, Hazard) else str(h.get("severity", ""))
55
+ if s in _RANK and (top is None or _RANK[s] > _RANK[top]):
56
+ top = s
57
+ return top
58
+
59
+
60
+ def blocks(level: str | None) -> bool:
61
+ """Whether this level overrides standing access (needs a fresh once-only approval or is refused)."""
62
+ return level in ("high", "critical")
63
+
64
+
65
+ def describe(hazards: Iterable[Hazard | dict[str, Any]] | None) -> str:
66
+ parts = []
67
+ for h in hazards or ():
68
+ d = h.as_dict() if isinstance(h, Hazard) else h
69
+ parts.append(f"{d.get('severity')}: {d.get('reason')}")
70
+ return "; ".join(parts)
71
+
72
+
73
+ # ---- operation entry point ----------------------------------------------------------------------
74
+ def scan(operation: dict[str, Any]) -> list[Hazard]:
75
+ """Hazards for one SSH operation dict as Keywright builds it (run, put, get, capture)."""
76
+ out = _Sink()
77
+ if operation.get("command"):
78
+ for h in scan_command(str(operation["command"])):
79
+ out.add(h.severity, h.reason)
80
+ if operation.get("to_path"):
81
+ for h in scan_path(str(operation["to_path"]), "write"):
82
+ out.add(h.severity, h.reason)
83
+ elif "sha256" in operation and operation.get("path"):
84
+ for h in scan_path(str(operation["path"]), "write"):
85
+ out.add(h.severity, h.reason)
86
+ elif operation.get("path"):
87
+ for h in scan_path(str(operation["path"]), "read"):
88
+ out.add(h.severity, h.reason)
89
+ return out.items
90
+
91
+
92
+ # ---- brokered MCP tools -------------------------------------------------------------------------
93
+ # A tool's name is the only thing Keywright can read about what it does; the owner's catalog is the
94
+ # rest. Names say medium at most: only the owner knows whether `reset` means a counter or a device.
95
+ _DESTRUCTIVE_WORDS = frozenset((
96
+ "delete", "remove", "destroy", "purge", "wipe", "erase", "reset", "restart", "reboot", "shutdown",
97
+ "poweroff", "format", "uninstall", "revoke", "drop", "kill", "terminate", "factory",
98
+ ))
99
+ _WORD_SPLIT = re.compile(r"[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])")
100
+
101
+
102
+ def scan_mcp(tool: str, paths: Iterable[str], catalog: dict[str, list[str]] | None = None) -> list[Hazard]:
103
+ """Hazards for one brokered MCP call. `paths` are the call's classified paths relative to the
104
+ resource (`tools/<tool>[/<key>=<value>]`, so qualifiers appear only for configured argument
105
+ keys); `catalog` is the owner's {severity: [entry, ...]} in bundle syntax, an entry matching a
106
+ path when its segments glob-match the path's leading segments."""
107
+ out = _Sink()
108
+ hits = sorted({w.lower() for w in _WORD_SPLIT.split(tool) if w} & _DESTRUCTIVE_WORDS)
109
+ if hits:
110
+ out.add("medium", f"tool name suggests a destructive action ({', '.join(hits)})")
111
+ paths = [p.strip("/") for p in paths]
112
+ for severity, entries in (catalog or {}).items():
113
+ if severity not in _RANK:
114
+ continue
115
+ for entry in entries:
116
+ if any(_entry_matches(entry, p) for p in paths):
117
+ out.add(severity, f"listed as {severity} for this server ({entry})")
118
+ return out.items
119
+
120
+
121
+ def _entry_matches(entry: str, path: str) -> bool:
122
+ e, p = entry.strip("/").split("/"), path.split("/")
123
+ return len(e) <= len(p) and all(fnmatch.fnmatchcase(pp, ee) for ee, pp in zip(e, p))
124
+
125
+
126
+ def as_dicts(hazards: Iterable[Hazard]) -> list[dict[str, str]]:
127
+ return [h.as_dict() for h in hazards]
128
+
129
+
130
+ class _Sink:
131
+ def __init__(self, cap: str | None = None):
132
+ self.items: list[Hazard] = []
133
+ self.cap = cap
134
+
135
+ def add(self, severity: str, reason: str) -> None:
136
+ if self.cap and _RANK[severity] > _RANK[self.cap]:
137
+ severity = self.cap
138
+ h = Hazard(severity, reason)
139
+ if h not in self.items:
140
+ self.items.append(h)
141
+
142
+ def add_weak(self, severity: str, reason: str) -> None:
143
+ """A lexical finding: dropped when the structured pass already reported the same thing,
144
+ possibly with more context ("(inside ssh) ...", "... (from .)")."""
145
+ if any(reason in h.reason for h in self.items):
146
+ return
147
+ self.add(severity, reason)
148
+
149
+
150
+ # ---- paths --------------------------------------------------------------------------------------
151
+ # (globs, reason) per group. fnmatch's `*` crosses `/`, so `*/.ssh/*` matches any depth.
152
+ _KEY_MATERIAL = (["*/.ssh", "*/.ssh/*", "*/.gnupg", "*/.gnupg/*", "/etc/ssh/ssh_host_*"],
153
+ "SSH/GPG key material")
154
+ _ACCOUNTS = (["/etc/passwd", "/etc/shadow", "/etc/group", "/etc/gshadow", "/etc/subuid", "/etc/subgid"],
155
+ "the account database")
156
+ _PRIVILEGE = (["/etc/sudoers", "/etc/sudoers.d/*", "/etc/pam.d/*", "/etc/nsswitch.conf", "/etc/ld.so.preload"],
157
+ "login and privilege configuration")
158
+ _BOOT = (["/etc/fstab", "/etc/crypttab", "/boot/*", "/etc/default/grub", "/etc/grub.d/*"], "boot configuration")
159
+ _SSHD = (["/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/*"], "the SSH server configuration")
160
+ _STARTUP = (["/etc/profile", "/etc/profile.d/*", "/etc/bash.bashrc", "/etc/zsh/*", "*/.bashrc", "*/.bash_profile",
161
+ "*/.profile", "*/.zshrc", "*/.zprofile", "*/.config/fish/config.fish", "/etc/crontab", "/etc/cron.d/*",
162
+ "/etc/cron.hourly/*", "/etc/cron.daily/*", "/var/spool/cron/*", "/etc/rc.local", "/etc/environment"],
163
+ "code that runs at login or on a schedule")
164
+ _SECRETS_READ = (["/etc/shadow", "/etc/gshadow", "*/.ssh/id_*", "*/.gnupg/*", "/etc/ssh/ssh_host_*_key",
165
+ "*/.aws/credentials", "*/.netrc", "*/.docker/config.json", "*/.kube/config"],
166
+ "private key material or password hashes")
167
+ _PUBLIC_KEY = re.compile(r"\.pub$")
168
+
169
+ _TOP_DIRS = {"bin", "boot", "dev", "etc", "home", "lib", "lib32", "lib64", "libx32", "opt", "proc", "root",
170
+ "sbin", "srv", "sys", "usr", "var", "snap", "nix"}
171
+ _CRITICAL_GLOB_PARENTS = {"etc", "usr", "var", "boot", "bin", "sbin", "lib", "lib32", "lib64", "root", "dev", "proc",
172
+ "sys"}
173
+ _HIGH_GLOB_PARENTS = {"opt", "srv", "tmp", "mnt", "media", "run", "snap", "var/lib", "var/log", "usr/local",
174
+ "var/backups", "var/mail", "var/www"}
175
+ _HOME = re.compile(r"^(~[A-Za-z0-9_.-]*|\$HOME|\$\{HOME\}|%h)(?=/|$)")
176
+ _GLOB = re.compile(r"[*?\[]")
177
+ _DEVICE = re.compile(r"^/dev/(sd|nvme|vd|hd|xvd|md|mapper/|dm-|zvol/|disk/|mmcblk|loop\d|nbd|rbd|drbd|sr\d)")
178
+
179
+
180
+ def _norm(raw: str, cwd: str | None) -> str | None:
181
+ """Normalize a path token. `~`, `$HOME` and friends become `~`. Relative paths resolve against
182
+ `cwd` when a `cd` earlier in the line set it; otherwise None (unknown)."""
183
+ p = raw.strip()
184
+ if not p:
185
+ return None
186
+ p = _HOME.sub("~", p)
187
+ if p.startswith("~"):
188
+ rest = posixpath.normpath(p[1:] or "/")
189
+ return "~" if rest in ("/", ".") else "~" + rest
190
+ if p.startswith("/"):
191
+ return re.sub(r"^/+", "/", posixpath.normpath(p))
192
+ if cwd is None:
193
+ return None
194
+ if cwd.startswith("~"):
195
+ joined = posixpath.normpath(posixpath.join(cwd[1:] or "/", p))
196
+ if joined.startswith("/.."):
197
+ return "/home" # above the home directory: the parent of all homes
198
+ return "~" if joined in ("/", ".") else "~" + joined
199
+ return re.sub(r"^/+", "/", posixpath.normpath(posixpath.join(cwd, p)))
200
+
201
+
202
+ def _matches(path: str, globs: list[str]) -> bool:
203
+ return any(fnmatch.fnmatchcase(path, g) for g in globs)
204
+
205
+
206
+ def _system_scope(p: str) -> str | None:
207
+ """'critical' for the filesystem root, any top-level directory, a home directory or their
208
+ wildcard contents; 'high' for the contents of shared directories like /opt or /var/lib."""
209
+ if p in ("/", "/*", "/.*", "/.", "/..", "~", "~/*", "~/.*", "~/."):
210
+ return "critical"
211
+ if p.startswith("~"):
212
+ parts = p[2:].split("/") if len(p) > 2 else []
213
+ return "critical" if len(parts) == 1 and _GLOB.search(parts[0]) else None
214
+ parts = p.strip("/").split("/")
215
+ if len(parts) == 1:
216
+ # /etc, /ho*, /*; a top-level directory the distribution did not create is high
217
+ return "critical" if parts[0] in _TOP_DIRS or _GLOB.search(parts[0]) else "high"
218
+ if parts[0] == "home":
219
+ if len(parts) == 2 or (len(parts) == 3 and _GLOB.search(parts[2])):
220
+ return "critical"
221
+ return None
222
+ if len(parts) == 2 and _GLOB.search(parts[1]):
223
+ if parts[0] in _CRITICAL_GLOB_PARENTS:
224
+ return "critical"
225
+ if parts[0] in _HIGH_GLOB_PARENTS:
226
+ return "high"
227
+ if len(parts) == 2 and "/".join(parts) in _HIGH_GLOB_PARENTS:
228
+ return "high"
229
+ # wiping the contents of a shared directory a few levels down (/var/lib/docker/*, /opt/app/*)
230
+ if 3 <= len(parts) <= 4 and _GLOB.search(parts[-1]) and (
231
+ "/".join(parts[:2]) in _HIGH_GLOB_PARENTS or parts[0] in _HIGH_GLOB_PARENTS - {"tmp", "run"}):
232
+ return "high"
233
+ return None
234
+
235
+
236
+ def _perm_scope(p: str) -> str | None:
237
+ """Recursive chmod/chown: critical on the root or a system directory, high on a home directory
238
+ or shared contents (chown -R on a home is a routine fix that still deserves a fresh look)."""
239
+ scope = _system_scope(p)
240
+ if scope != "critical":
241
+ return scope
242
+ parts = p.strip("/").split("/")
243
+ if p.startswith("~") or parts[0] == "home" and len(parts) > 1 or parts[0] == "root":
244
+ return "high"
245
+ return "critical"
246
+
247
+
248
+ def scan_path(path: str, mode: str) -> list[Hazard]:
249
+ """Hazards of writing to, reading or deleting one path. mode: write | read | delete."""
250
+ out = _Sink()
251
+ p = _norm(path, None) or path
252
+ if mode == "read":
253
+ if _matches(p, _SECRETS_READ[0]) and not _PUBLIC_KEY.search(p):
254
+ out.add("high", f"reads {_SECRETS_READ[1]} ({p})")
255
+ return out.items
256
+ verb = "deletes" if mode == "delete" else "overwrites"
257
+ for globs, what in (_KEY_MATERIAL, _ACCOUNTS, _PRIVILEGE, _BOOT, _SSHD):
258
+ if _matches(p, globs):
259
+ out.add("critical" if mode == "delete" else "high", f"{verb} {what} ({p})")
260
+ if _matches(p, _STARTUP[0]):
261
+ out.add("high", f"{verb} {_STARTUP[1]} ({p})")
262
+ if mode == "write" and _DEVICE.match(p):
263
+ out.add("high", f"writes raw bytes to block device {p}")
264
+ return out.items
265
+
266
+
267
+ def _delete_target(raw: str, cwd: str | None, recursive: bool, how: str, out: _Sink, weak: bool = False) -> None:
268
+ """`how` reads like "recursive delete of" or "find -delete under"."""
269
+ add = out.add_weak if weak else out.add
270
+ if not raw or raw in ("+", ";", "{}", "$", "\\;"):
271
+ return
272
+ p = _norm(raw, cwd)
273
+ if p is not None:
274
+ specific = scan_path(p, "delete")
275
+ for h in specific:
276
+ add(h.severity, h.reason)
277
+ if specific:
278
+ return
279
+ if not recursive:
280
+ return
281
+ if p is None:
282
+ if raw in (".", "..", "*", "./", "./*", "../*", ".*", "./.*"):
283
+ add("high", f"{how} the working directory ({raw}), location unknown")
284
+ else:
285
+ add("medium", f"{how} {raw}")
286
+ return
287
+ scope = _system_scope(p)
288
+ if scope:
289
+ add(scope, f"{how} {p}" + (f" (from {raw})" if p != raw else ""))
290
+ else:
291
+ add("medium", f"{how} {p}")
292
+
293
+
294
+ # ---- commands -----------------------------------------------------------------------------------
295
+ _SEPARATORS = {";", "|", "||", "&&", "&", "(", ")", "{", "}", "|&"}
296
+ _PUNCT = ";|&()<>"
297
+ _KEYWORDS = {"{", "}", "(", ")", "!", "if", "then", "else", "elif", "fi", "while", "until", "do", "done", "case",
298
+ "esac", "in", "function", "time"}
299
+ _SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "fish"}
300
+ _WRAPPERS = {"sudo", "doas", "busybox", "env", "nice", "ionice", "nohup", "time", "command", "builtin", "stdbuf", "timeout",
301
+ "exec", "nocorrect", "noglob"}
302
+ _SUDO_VALUE_FLAGS = {"-u", "-g", "-h", "-p", "-r", "-t", "-U", "-C", "-D", "-R", "-T", "--user", "--group", "--host",
303
+ "--prompt", "--role", "--type", "--other-user", "--close-from", "--chdir", "--chroot"}
304
+ _WRAPPER_VALUE_FLAGS = {"-n", "-c", "-k", "-s", "-u", "-o", "-e", "-i", "-p", "-C", "-S", "--kill-after", "--signal",
305
+ "--adjustment", "--class", "--classdata", "--output", "--error", "--input", "--chdir",
306
+ "--split-string"}
307
+ _REMOTE = {"ssh", "pct", "docker", "podman", "qm", "kubectl", "lxc", "machinectl", "incus", "nsenter", "chroot"}
308
+ _FIND_DELETE = {"-delete"}
309
+ _FIND_EXEC = {"-exec", "-execdir", "-ok", "-okdir"}
310
+ _DELETERS = {"rm", "rmdir", "unlink", "shred", "srm"}
311
+ _FORMATTERS = re.compile(r"^(mkfs(\.\w+)?|mke2fs|mkswap|mkfs_\w+|wipefs|blkdiscard|sgdisk|sfdisk|fdisk|cfdisk|parted|"
312
+ r"gdisk|zerofree|nvme|hdparm|badblocks)$")
313
+ _POWER = {"reboot", "poweroff", "halt", "shutdown", "telinit"}
314
+ _FLUSHERS = {"flushall", "flushdb"}
315
+
316
+
317
+ def scan_command(command: str) -> list[Hazard]:
318
+ out = _Sink()
319
+ _structured(command, out, depth=0)
320
+ _lexical(command, out)
321
+ return out.items
322
+
323
+
324
+ def _tokens(command: str) -> list[str] | None:
325
+ try:
326
+ lex = shlex.shlex(command, posix=True, punctuation_chars=_PUNCT)
327
+ lex.whitespace_split = True
328
+ return list(lex)
329
+ except ValueError:
330
+ return None
331
+
332
+
333
+ def _segments(tokens: list[str]) -> list[list[str]]:
334
+ segs: list[list[str]] = [[]]
335
+ for tok in tokens:
336
+ if tok in _SEPARATORS:
337
+ if segs[-1]:
338
+ segs.append([])
339
+ continue
340
+ if tok.startswith(("<", ">")) and set(tok) <= set("<>&|"):
341
+ # a redirection operator; the target that follows is data for the lexical pass
342
+ segs[-1].append(tok)
343
+ continue
344
+ segs[-1].append(tok)
345
+ return [s for s in segs if s]
346
+
347
+
348
+ def _structured(command: str, out: _Sink, depth: int, cwd: str | None = None) -> None:
349
+ if depth > 4:
350
+ return
351
+ tokens = _tokens(command)
352
+ if not tokens:
353
+ return
354
+ for seg in _segments(tokens):
355
+ words = _strip_redirections(seg)
356
+ while words and words[0] in _KEYWORDS:
357
+ words = words[1:]
358
+ words = _unwrap(words)
359
+ if not words:
360
+ continue
361
+ bin_ = words[0].rsplit("/", 1)[-1]
362
+ args = words[1:]
363
+ if bin_ in ("cd", "pushd"):
364
+ cwd = _norm(args[0], cwd) if args and not args[0].startswith("-") else "~"
365
+ continue
366
+ _check(bin_, args, cwd, out, depth)
367
+
368
+
369
+ def _strip_redirections(words: list[str]) -> list[str]:
370
+ kept: list[str] = []
371
+ skip = False
372
+ for w in words:
373
+ if skip:
374
+ skip = False
375
+ continue
376
+ if w and set(w) <= set("<>&|") and ("<" in w or ">" in w):
377
+ skip = True # drop the operator and its target (tokenized as separate words)
378
+ continue
379
+ kept.append(w)
380
+ return kept
381
+
382
+
383
+ def _unwrap(words: list[str]) -> list[str]:
384
+ """Strip privilege and environment wrappers so `sudo -u root rm -rf /` is seen as `rm`."""
385
+ while words:
386
+ head = words[0].rsplit("/", 1)[-1]
387
+ if "=" in words[0] and not words[0].startswith("-"):
388
+ words = words[1:]
389
+ continue
390
+ if head not in _WRAPPERS:
391
+ return words
392
+ rest = words[1:]
393
+ value_flags = _SUDO_VALUE_FLAGS if head in ("sudo", "doas") else _WRAPPER_VALUE_FLAGS
394
+ while rest and rest[0].startswith("-") and rest[0] != "--":
395
+ flag = rest[0]
396
+ rest = rest[1:]
397
+ if flag in value_flags and rest:
398
+ rest = rest[1:]
399
+ if rest and rest[0] == "--":
400
+ rest = rest[1:]
401
+ if head == "timeout" and rest:
402
+ rest = rest[1:] # the duration
403
+ if head == "env":
404
+ while rest and "=" in rest[0]:
405
+ rest = rest[1:]
406
+ words = rest
407
+ return words
408
+
409
+
410
+ def _flags_and_targets(args: list[str]) -> tuple[list[str], list[str]]:
411
+ flags, targets = [], []
412
+ end_of_flags = False
413
+ for a in args:
414
+ if end_of_flags:
415
+ targets.append(a)
416
+ elif a == "--":
417
+ end_of_flags = True
418
+ elif a.startswith("-") and len(a) > 1:
419
+ flags.append(a)
420
+ else:
421
+ targets.append(a)
422
+ return flags, targets
423
+
424
+
425
+ def _short_has(flags: list[str], letters: str, long: tuple[str, ...] = ()) -> bool:
426
+ for f in flags:
427
+ if f in long:
428
+ return True
429
+ if f.startswith("-") and not f.startswith("--") and any(c in letters for c in f[1:]):
430
+ return True
431
+ return False
432
+
433
+
434
+ def _check(bin_: str, args: list[str], cwd: str | None, out: _Sink, depth: int) -> None:
435
+ # --- things that run another command line ---
436
+ if bin_ in _SHELLS:
437
+ for i, a in enumerate(args):
438
+ if a in ("-c", "-lc", "-ec", "-xc") and i + 1 < len(args):
439
+ _structured(args[i + 1], out, depth + 1, cwd)
440
+ return
441
+ if a.startswith("-") and "c" in a and not a.startswith("--") and i + 1 < len(args):
442
+ _structured(args[i + 1], out, depth + 1, cwd)
443
+ return
444
+ return
445
+ if bin_ == "su":
446
+ for i, a in enumerate(args):
447
+ if a in ("-c", "--command") and i + 1 < len(args):
448
+ _structured(args[i + 1], out, depth + 1, None)
449
+ return
450
+ if bin_ == "eval":
451
+ _structured(" ".join(args), out, depth + 1, cwd)
452
+ return
453
+ if bin_ == "xargs":
454
+ rest = list(args)
455
+ while rest and rest[0].startswith("-"):
456
+ flag = rest[0]
457
+ rest = rest[1:]
458
+ if flag in ("-n", "-P", "-d", "-I", "-L", "-s", "-a", "-E", "--max-args", "--max-procs", "--delimiter",
459
+ "--replace", "--max-lines", "--max-chars", "--arg-file", "--eof") and rest:
460
+ rest = rest[1:]
461
+ cmd = _unwrap(rest)
462
+ if cmd:
463
+ inner = _Sink()
464
+ _check(cmd[0].rsplit("/", 1)[-1], cmd[1:], None, inner, depth + 1)
465
+ for h in inner.items:
466
+ out.add(h.severity, h.reason)
467
+ head = cmd[0].rsplit("/", 1)[-1]
468
+ if head in _DELETERS:
469
+ recursive = _short_has([a for a in cmd[1:] if a.startswith("-")], "rR", ("--recursive",))
470
+ out.add("high" if recursive else "medium",
471
+ f"xargs {head}{' -r' if recursive else ''}: deletes whatever the input names")
472
+ return
473
+ if bin_ in _REMOTE:
474
+ _remote(bin_, args, out, depth)
475
+ # fall through: the outer command itself may also be hazardous (docker rm, pct destroy)
476
+ # --- deletion ---
477
+ if bin_ in ("rm", "srm"):
478
+ flags, targets = _flags_and_targets(args)
479
+ recursive = _short_has(flags, "rR", ("--recursive",))
480
+ if "--no-preserve-root" in flags:
481
+ out.add("critical", "rm --no-preserve-root")
482
+ for t in targets:
483
+ _delete_target(t, cwd, recursive, "recursive delete of" if recursive else "delete of", out)
484
+ return
485
+ if bin_ in ("unlink", "rmdir"):
486
+ for t in _flags_and_targets(args)[1]:
487
+ _delete_target(t, cwd, False, "delete of", out)
488
+ return
489
+ if bin_ == "shred":
490
+ for t in _flags_and_targets(args)[1]:
491
+ p = _norm(t, cwd) or t
492
+ out.add("high" if _DEVICE.match(p) else "medium", f"shreds {p}")
493
+ _delete_target(t, cwd, False, "shreds", out)
494
+ return
495
+ if bin_ == "find":
496
+ _find(args, cwd, out)
497
+ return
498
+ if bin_ in ("chmod", "chown", "chgrp"):
499
+ flags, targets = _flags_and_targets(args)
500
+ if _short_has(flags, "R", ("--recursive",)):
501
+ for t in targets[1:]:
502
+ p = _norm(t, cwd)
503
+ scope = _perm_scope(p) if p else None
504
+ if scope:
505
+ out.add(scope, f"recursive {bin_} of {p}")
506
+ return
507
+ if bin_ == "mv":
508
+ flags, targets = _flags_and_targets(args)
509
+ for t in targets[:-1]:
510
+ p = _norm(t, cwd)
511
+ scope = _system_scope(p) if p else None
512
+ if scope:
513
+ out.add(scope, f"moves {p} away")
514
+ if p:
515
+ for h in scan_path(p, "delete"):
516
+ out.add(h.severity, h.reason.replace("deletes", "moves"))
517
+ if targets:
518
+ _write_target(targets[-1], cwd, out)
519
+ return
520
+ if bin_ in ("tee", "install", "truncate", "ln"):
521
+ flags, targets = _flags_and_targets(args)
522
+ if bin_ == "tee" or bin_ == "truncate":
523
+ for t in targets:
524
+ _write_target(t, cwd, out)
525
+ elif targets:
526
+ _write_target(targets[-1], cwd, out)
527
+ return
528
+ if bin_ == "dd":
529
+ for a in args:
530
+ if a.startswith("of="):
531
+ _write_target(a[3:], cwd, out)
532
+ elif a.startswith("if="):
533
+ _read_target(a[3:], cwd, out)
534
+ return
535
+ if bin_ in ("cat", "head", "tail", "less", "more", "base64", "xxd", "od", "strings", "grep", "cp", "scp", "rsync",
536
+ "tar", "zip", "gzip", "curl", "wget", "nc", "ncat"):
537
+ for t in _flags_and_targets(args)[1]:
538
+ _read_target(t.split(":", 1)[-1] if bin_ in ("scp", "rsync") and ":" in t else t, cwd, out)
539
+ flags, targets = _flags_and_targets(args)
540
+ if bin_ in ("cp", "scp", "rsync") and targets:
541
+ _write_target(targets[-1], cwd, out)
542
+ if bin_ == "rsync" and any(f.startswith("--delete") for f in flags) and targets:
543
+ p = _norm(targets[-1], cwd)
544
+ scope = _system_scope(p) if p else None
545
+ out.add(scope or "medium", f"rsync --delete into {p or targets[-1]}")
546
+ return
547
+ if _FORMATTERS.match(bin_):
548
+ flags, targets = _flags_and_targets(args)
549
+ listing = any(f in ("-l", "--list", "-p", "--print", "-s", "-i") for f in flags) or "print" in targets
550
+ if bin_ in ("nvme", "hdparm"):
551
+ if any(t in ("format", "sanitize") for t in targets) or "--security-erase" in flags:
552
+ out.add("high", f"{bin_} erases {targets[-1] if targets else 'a device'}")
553
+ return
554
+ if bin_ == "badblocks" and not _short_has(flags, "w"):
555
+ return
556
+ if listing and bin_ in ("fdisk", "sfdisk", "sgdisk", "parted", "gdisk", "cfdisk", "wipefs"):
557
+ return
558
+ # A formatter's device is the last positional, or whichever positional names a device or a
559
+ # variable; label values like `-L ncdata` must not be reported as victims.
560
+ victims = [t for t in targets if _DEVICE.match(_norm(t, cwd) or t) or t.startswith("$")]
561
+ if not victims and targets and bin_.startswith("mk"):
562
+ victims = [targets[-1]]
563
+ for t in victims:
564
+ out.add("high", f"{bin_} destroys the contents of {t}")
565
+ return
566
+ if bin_ == "cryptsetup":
567
+ if any(a in ("luksFormat", "erase", "luksErase", "reencrypt") for a in args):
568
+ out.add("high", f"cryptsetup {next(a for a in args if not a.startswith('-'))} on {args[-1]}")
569
+ return
570
+ # --- virtualization, storage, containers ---
571
+ sub = next((a for a in args if not a.startswith("-")), None)
572
+ sub2 = next((a for a in args[args.index(sub) + 1:] if not a.startswith("-")), None) if sub else None
573
+ if bin_ in ("qm", "pct"):
574
+ if sub == "destroy":
575
+ out.add("high", f"{bin_} destroy {sub2 or ''}: deletes a guest and its disks".strip())
576
+ elif sub in ("delsnapshot", "disk") and (sub != "disk" or sub2 == "unlink"):
577
+ out.add("medium", f"{bin_} {sub} {' '.join(args[1:3])}".strip())
578
+ elif sub in ("stop", "reset") and bin_ == "qm":
579
+ out.add("medium", f"{bin_} {sub} {sub2 or ''}: hard power-off".strip())
580
+ return
581
+ if bin_ == "pvesm" and sub in ("free", "remove"):
582
+ out.add("high", f"pvesm {sub} {sub2 or ''}".strip())
583
+ return
584
+ if bin_ == "vzdump":
585
+ return
586
+ if bin_ == "zpool" and sub in ("destroy", "labelclear"):
587
+ out.add("high", f"zpool {sub} {sub2 or ''}: destroys a whole pool".strip())
588
+ return
589
+ if bin_ == "zfs" and sub in ("destroy",):
590
+ flags, targets = _flags_and_targets(args[1:])
591
+ what = targets[0] if targets else ""
592
+ children = " and every child dataset/snapshot" if _short_has(flags, "rR") else ""
593
+ out.add("high", f"zfs destroy {what}{children}".strip())
594
+ return
595
+ if bin_ in ("lvremove", "vgremove", "pvremove") or (bin_ == "lvm" and sub in ("lvremove", "vgremove", "pvremove")):
596
+ out.add("high", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}".strip())
597
+ return
598
+ if bin_ == "rbd" and sub in ("rm", "remove", "trash") or bin_ == "rados" and sub in ("rmpool", "purge"):
599
+ out.add("high", f"{bin_} {sub} {sub2 or ''}".strip())
600
+ return
601
+ if bin_ == "ceph" and sub in ("osd", "fs", "mon", "mgr") and sub2 in ("purge", "destroy", "rm", "remove", "pool"):
602
+ if sub2 == "pool" and not any(a in ("delete", "rm") for a in args):
603
+ return
604
+ out.add("high", f"ceph {sub} {sub2} {' '.join(args[2:5])}".strip())
605
+ return
606
+ if bin_ == "virsh" and sub == "undefine":
607
+ out.add("high" if any("storage" in a for a in args) else "medium", f"virsh undefine {sub2 or ''}".strip())
608
+ return
609
+ if bin_ in ("docker", "podman", "nerdctl"):
610
+ _docker(bin_, args, out)
611
+ return
612
+ if bin_ == "kubectl" and sub == "delete":
613
+ out.add("high" if any(a in ("--all", "-A", "--all-namespaces") or a.startswith("ns") or a == "namespace"
614
+ for a in args) else "medium", f"kubectl delete {' '.join(args[1:4])}".strip())
615
+ return
616
+ if bin_ == "git":
617
+ _git(args, out)
618
+ return
619
+ # --- databases ---
620
+ if bin_ in ("dropdb", "dropuser"):
621
+ out.add("high", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}".strip())
622
+ return
623
+ if bin_ == "redis-cli" and any(a.lower() in _FLUSHERS for a in args):
624
+ out.add("high", "redis flushes every key")
625
+ return
626
+ # --- host ---
627
+ if bin_ in _POWER:
628
+ if bin_ == "shutdown" and ("-c" in args or "--cancel" in args):
629
+ return
630
+ out.add("high", f"{bin_}: reboots or powers off the host")
631
+ return
632
+ if bin_ == "init" and args and args[0] in ("0", "6"):
633
+ out.add("high", "init 0/6: reboots or powers off the host")
634
+ return
635
+ if bin_ == "systemctl":
636
+ if sub in ("reboot", "poweroff", "halt", "kexec", "emergency", "rescue", "suspend", "hibernate"):
637
+ out.add("high", f"systemctl {sub}: takes the host down")
638
+ elif sub in ("stop", "disable", "mask") and any(re.match(r"^(ssh|sshd|networking|NetworkManager|systemd-networkd|"
639
+ r"tailscaled|pve-cluster|pvedaemon|pveproxy)(\.service)?$", a)
640
+ for a in args[1:]):
641
+ out.add("high", f"systemctl {sub} {' '.join(args[1:3])}: cuts remote access".strip())
642
+ return
643
+ if bin_ in ("kill", "pkill", "killall", "killall5"):
644
+ if bin_ == "killall5" or "-1" in args or any(a in ("-r", "--regexp") for a in args) and "." in args:
645
+ out.add("high", f"{bin_}: kills every process")
646
+ elif bin_ in ("pkill", "killall") and any(re.match(r"^(sshd|ssh|init|systemd|pve\w*)$", a) for a in args):
647
+ out.add("high", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}: cuts remote access")
648
+ return
649
+ if bin_ in ("iptables", "ip6tables", "nft", "ufw", "firewall-cmd"):
650
+ if _short_has([a for a in args if a.startswith("-")], "FX", ("--flush", "--delete-chain")) and bin_.endswith("tables") \
651
+ or bin_ == "nft" and "flush" in args and "ruleset" in args \
652
+ or bin_ == "ufw" and sub in ("reset", "disable") \
653
+ or bin_ == "firewall-cmd" and any(a in ("--panic-on", "--complete-reload") for a in args):
654
+ out.add("high", f"{bin_} {' '.join(args[:2])}: drops the firewall".strip())
655
+ return
656
+ if bin_ in ("ip", "ifdown", "ifconfig", "nmcli"):
657
+ if bin_ == "ifdown" or (bin_ == "ip" and sub in ("link", "addr", "address") and any(
658
+ a in ("down", "flush", "del", "delete") for a in args)) \
659
+ or (bin_ == "ifconfig" and "down" in args) or (bin_ == "nmcli" and any(a in ("down", "delete") for a in args)):
660
+ out.add("high", f"{bin_} {' '.join(args[:4])}: may drop the network".strip())
661
+ return
662
+ if bin_ in ("userdel", "deluser", "groupdel"):
663
+ rm = _short_has([a for a in args if a.startswith("-")], "r", ("--remove", "--remove-home", "--remove-all-files"))
664
+ out.add("high" if rm else "medium", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}"
665
+ + (" and their files" if rm else ""))
666
+ return
667
+ if bin_ == "passwd" and args and (args[0] in ("-d", "-l", "--delete", "--lock") or "root" in args):
668
+ out.add("high", f"passwd {' '.join(args)}: changes a login")
669
+ return
670
+ if bin_ == "crontab" and "-r" in args:
671
+ out.add("high", "crontab -r: deletes every scheduled job")
672
+ return
673
+ if bin_ in ("apt", "apt-get", "dnf", "yum", "pacman", "apk", "zypper") and sub in ("remove", "purge", "autoremove",
674
+ "erase", "del", "-R", "-Rs", "-Rns"):
675
+ if any(re.match(r"^(openssh|ssh|sudo|systemd|linux-image|grub|pve-|proxmox|qemu-server|libc6|bash|coreutils)", a)
676
+ for a in args[1:]):
677
+ out.add("high", f"{bin_} {sub} {' '.join(a for a in args[1:] if not a.startswith('-'))}: removes a package "
678
+ "the host needs to stay reachable")
679
+ return
680
+ if bin_ == "history" and "-c" in args:
681
+ out.add("medium", "history -c")
682
+ return
683
+
684
+
685
+ def _read_target(raw: str, cwd: str | None, out: _Sink) -> None:
686
+ p = _norm(raw, cwd)
687
+ if p is None:
688
+ return
689
+ for h in scan_path(p, "read"):
690
+ out.add(h.severity, h.reason)
691
+
692
+
693
+ def _write_target(raw: str, cwd: str | None, out: _Sink) -> None:
694
+ p = _norm(raw, cwd)
695
+ if p is None:
696
+ return
697
+ for h in scan_path(p, "write"):
698
+ out.add(h.severity, h.reason)
699
+
700
+
701
+ def _find(args: list[str], cwd: str | None, out: _Sink) -> None:
702
+ starts: list[str] = []
703
+ i = 0
704
+ while i < len(args) and args[i] in ("-H", "-L", "-P", "-O0", "-O1", "-O2", "-O3"):
705
+ i += 1
706
+ if i < len(args) and args[i] == "-D":
707
+ i += 2
708
+ while i < len(args) and not args[i].startswith(("-", "(", "!")):
709
+ starts.append(args[i])
710
+ i += 1
711
+ deleting = any(a in _FIND_DELETE for a in args)
712
+ recursive_rm = False
713
+ for j, a in enumerate(args):
714
+ if a in _FIND_EXEC and j + 1 < len(args):
715
+ cmd = _unwrap(args[j + 1:])
716
+ if cmd and cmd[0].rsplit("/", 1)[-1] in _DELETERS:
717
+ deleting = True
718
+ recursive_rm = recursive_rm or _short_has([x for x in cmd[1:] if x.startswith("-")], "rR",
719
+ ("--recursive",))
720
+ if not deleting:
721
+ return
722
+ for s in starts or ["."]:
723
+ _delete_target(s, cwd, True, "find -delete under", out)
724
+
725
+
726
+ def _remote(bin_: str, args: list[str], out: _Sink, depth: int) -> None:
727
+ """A command line run somewhere else: inside a container, guest or another host. Reported at
728
+ full severity with a prefix: a wiped guest is still a wiped machine."""
729
+ inner = _Sink()
730
+ if bin_ == "ssh":
731
+ # skip options and the host
732
+ i = 0
733
+ while i < len(args) and args[i].startswith("-"):
734
+ i += 2 if args[i] in ("-i", "-l", "-p", "-o", "-F", "-J", "-L", "-R", "-D", "-W", "-b", "-c", "-E",
735
+ "-I", "-m", "-O", "-Q", "-S", "-w", "-B") else 1
736
+ i += 1
737
+ if i < len(args):
738
+ _structured(" ".join(args[i:]), inner, depth + 1)
739
+ elif bin_ in ("pct", "docker", "podman", "lxc", "incus", "machinectl", "kubectl", "nsenter", "chroot", "qm"):
740
+ if "--" in args:
741
+ _structured(" ".join(args[args.index("--") + 1:]), inner, depth + 1)
742
+ elif bin_ in ("nsenter", "chroot"):
743
+ rest = [a for a in args if not a.startswith("-")]
744
+ if bin_ == "chroot":
745
+ rest = rest[1:]
746
+ if rest:
747
+ _structured(" ".join(rest), inner, depth + 1)
748
+ elif args and args[0] in ("exec", "shell", "run") and bin_ != "qm":
749
+ rest = args[1:]
750
+ while rest and rest[0].startswith("-"):
751
+ rest = rest[2:] if rest[0] in ("-u", "-w", "-e", "--user", "--workdir", "--env", "-n", "--namespace") else rest[1:]
752
+ if len(rest) > 1:
753
+ _structured(" ".join(rest[1:]), inner, depth + 1)
754
+ for h in inner.items:
755
+ out.add(h.severity, f"(inside {bin_}) {h.reason}")
756
+
757
+
758
+ def _docker(bin_: str, args: list[str], out: _Sink) -> None:
759
+ words = [a for a in args if not a.startswith("-")]
760
+ flags = [a for a in args if a.startswith("-")]
761
+ if not words:
762
+ return
763
+ sub = words[0]
764
+ sub2 = words[1] if len(words) > 1 else ""
765
+ if sub == "system" and sub2 == "prune":
766
+ out.add("high", f"{bin_} system prune" + (" including volumes" if "--volumes" in flags else ""))
767
+ elif sub == "volume" and sub2 in ("rm", "remove", "prune"):
768
+ out.add("high", f"{bin_} volume {sub2} {' '.join(words[2:5])}: deletes persistent data".strip())
769
+ elif sub == "compose" and "down" in words and (_short_has(flags, "v", ("--volumes",))):
770
+ out.add("high", f"{bin_} compose down -v: deletes the stack's volumes")
771
+ elif sub in ("rm", "container") and (sub == "rm" or sub2 in ("rm", "prune")):
772
+ forced = _short_has(flags, "f", ("--force",))
773
+ out.add("medium", f"{bin_} {sub} {sub2 if sub == 'container' else ''} {' '.join(flags)}".replace(" ", " ").strip()
774
+ + (": removes running containers" if forced else ""))
775
+ elif sub in ("rmi", "image") and (sub == "rmi" or sub2 in ("rm", "prune")):
776
+ if _short_has(flags, "a", ("--all",)) or _short_has(flags, "f", ("--force",)):
777
+ out.add("medium", f"{bin_} {sub} {sub2 if sub == 'image' else ''} {' '.join(flags)}".replace(" ", " ").strip())
778
+ elif sub == "network" and sub2 == "prune":
779
+ out.add("medium", f"{bin_} network prune")
780
+ elif sub == "swarm" and sub2 == "leave" and _short_has(flags, "f", ("--force",)):
781
+ out.add("high", f"{bin_} swarm leave --force")
782
+ elif sub == "stack" and sub2 in ("rm", "remove"):
783
+ out.add("medium", f"{bin_} stack rm {' '.join(words[2:4])}".strip())
784
+
785
+
786
+ def _git(args: list[str], out: _Sink) -> None:
787
+ words = [a for a in args if not a.startswith("-")]
788
+ flags = [a for a in args if a.startswith("-")]
789
+ if words and words[0] == "-C":
790
+ words = words[2:]
791
+ if not words:
792
+ return
793
+ sub = words[0]
794
+ if sub == "push":
795
+ if "--force" in flags or _short_has(flags, "f") or any(w.startswith("+") for w in words[1:]):
796
+ out.add("high", "git push --force")
797
+ elif any(f.startswith("--force-with-lease") for f in flags) or "--delete" in flags or _short_has(flags, "d"):
798
+ out.add("medium", "git push " + " ".join(flags))
799
+ elif sub == "reset" and "--hard" in flags:
800
+ out.add("high", "git reset --hard")
801
+ elif sub == "clean":
802
+ if _short_has(flags, "f", ("--force",)) and _short_has(flags, "x"):
803
+ out.add("high", f"git clean {' '.join(flags)}: deletes ignored and untracked files")
804
+ elif _short_has(flags, "f", ("--force",)):
805
+ out.add("medium", "git clean -f")
806
+ elif sub == "branch" and _short_has(flags, "D", ("--delete", "--force")):
807
+ out.add("medium", f"git branch -D {' '.join(words[1:3])}".strip())
808
+ elif sub in ("checkout", "restore") and ("." in words[1:] or "--" in args and args[-1] in (".", "*")):
809
+ out.add("medium", f"git {sub} .: discards working changes")
810
+ elif sub == "stash" and words[1:2] and words[1] in ("drop", "clear"):
811
+ out.add("medium", f"git stash {words[1]}")
812
+ elif sub in ("filter-branch", "filter-repo"):
813
+ out.add("high", f"git {sub}: rewrites history")
814
+ elif sub == "gc" and "--prune=now" in flags:
815
+ out.add("medium", "git gc --prune=now")
816
+
817
+
818
+ # ---- lexical pass -------------------------------------------------------------------------------
819
+ _RM_RE = re.compile(r"(?<![\w.-])(?:\S*/)?rm\s+((?:-[\w-]+\s+)*)([^;&|<>\n'\"`)]+)")
820
+ _CD_RM_RE = re.compile(r"\bcd\s+(\S+)\s*(?:&&|;)\s*(?:\S*/)?rm\s+((?:-[\w-]+\s+)*)([^;&|<>\n'\"`)]+)")
821
+ _RMTREE_RE = re.compile(r"\b(?:shutil\.)?rmtree\(\s*(?:(['\"])(?P<lit>[^'\"]*)\1|(?P<expr>[^)]*))")
822
+ _RIMRAF_RE = re.compile(r"\b(?:rimraf|rm\.-rf)\s+([^\s;&|'\"`]+)")
823
+ _FSRM_RE = re.compile(r"\.rm(?:Sync)?\(\s*(['\"])([^'\"]*)\1[^)]*recursive")
824
+ _FIND_RE = re.compile(r"\bfind\s+([~./][^\s;&|]*(?:\s+[~./][^\s;&|]*)*)[^;&|\n]*?(?:-delete\b|-exec\w*\s+rm\b)")
825
+ _FORMAT_RE = re.compile(r"(?<![\w./-])(mkfs(?:\.\w+)?|wipefs|blkdiscard)[ \t]+([^\n;|&]*)")
826
+ _DEVICE_WRITE_RE = re.compile(r"(?:\bof=|>\s*)(/dev/(?:sd|nvme|vd|hd|xvd|md|mapper/|dm-|zvol/|mmcblk|nbd|rbd)\S*)")
827
+ _REDIRECT_RE = re.compile(r"(?<!\d)(?<![<>])>>?\s*((?:/|~/|\$HOME/|\$\{HOME\}/)[^\s;&|'\"`)]*)")
828
+ _SQL_RE = re.compile(r"\b(DROP\s+(?:DATABASE|SCHEMA|TABLE|USER|ROLE)\b[^;'\"]{0,60}|TRUNCATE(?:\s+TABLE)?\s+\w[^;'\"]{0,60})",
829
+ re.IGNORECASE)
830
+ _MONGO_RE = re.compile(r"\bdropDatabase\s*\(|\bdrop\s*\(\s*\)")
831
+ _FLUSH_RE = re.compile(r"\b(FLUSHALL|FLUSHDB)\b", re.IGNORECASE)
832
+ _FORK_BOMB_RE = re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
833
+ _CHMOD_RE = re.compile(r"\b(chmod|chown|chgrp)\s+(?:-\S+\s+)*-R\S*\s+\S+\s+(/(?:\*|\s|$|[a-z]+/?(?:\*|\s|$)))")
834
+
835
+
836
+ def _lexical(command: str, out: _Sink) -> None:
837
+ if "--no-preserve-root" in command:
838
+ out.add("critical", "rm --no-preserve-root")
839
+ if _FORK_BOMB_RE.search(command):
840
+ out.add("critical", "fork bomb")
841
+ for m in _RM_RE.finditer(command):
842
+ flags = m.group(1).split()
843
+ if not _short_has(flags, "rR", ("--recursive",)):
844
+ continue
845
+ for raw in m.group(2).split():
846
+ # relative targets are the structured pass's job; here they are mostly `{}` and `+`
847
+ if raw.startswith("-") or not _HOME.match(raw) and not raw.startswith("/"):
848
+ continue
849
+ _delete_target(raw.rstrip("\\"), None, True, "recursive delete of", out, weak=True)
850
+ for m in _CD_RM_RE.finditer(command):
851
+ flags = m.group(2).split()
852
+ if not _short_has(flags, "rR", ("--recursive",)):
853
+ continue
854
+ for raw in m.group(3).split():
855
+ if not raw.startswith("-"):
856
+ _delete_target(raw, _norm(m.group(1), None), True, "recursive delete of", out, weak=True)
857
+ for m in _RMTREE_RE.finditer(command):
858
+ lit = m.group("lit")
859
+ if lit is not None:
860
+ _delete_target(lit, None, True, "shutil.rmtree of", out, weak=True)
861
+ else:
862
+ out.add_weak("medium", f"shutil.rmtree({(m.group('expr') or '').strip()[:40]})")
863
+ for m in _RIMRAF_RE.finditer(command):
864
+ _delete_target(m.group(1), None, True, "rimraf of", out, weak=True)
865
+ for m in _FSRM_RE.finditer(command):
866
+ _delete_target(m.group(2), None, True, "fs.rm recursive of", out, weak=True)
867
+ for m in _FIND_RE.finditer(command):
868
+ for s in m.group(1).split():
869
+ _delete_target(s, None, True, "find -delete under", out, weak=True)
870
+ for m in _FORMAT_RE.finditer(command):
871
+ words = [w.strip("'\"") for w in m.group(2).split() if not w.startswith("-")]
872
+ victims = [w for w in words if _DEVICE.match(w) or w.startswith("$")] or words[-1:]
873
+ for w in victims:
874
+ out.add_weak("high", f"{m.group(1)} destroys the contents of {w}")
875
+ for m in _DEVICE_WRITE_RE.finditer(command):
876
+ out.add_weak("high", f"writes raw bytes to block device {m.group(1)}")
877
+ for m in _REDIRECT_RE.finditer(command):
878
+ p = _norm(m.group(1), None)
879
+ for h in scan_path(p, "write") if p else ():
880
+ out.add_weak(h.severity, h.reason)
881
+ for m in _SQL_RE.finditer(command):
882
+ out.add_weak("high", f"SQL {' '.join(m.group(1).split())[:60]}")
883
+ if _MONGO_RE.search(command):
884
+ out.add_weak("high", "drops a MongoDB database or collection")
885
+ if _FLUSH_RE.search(command) and "redis" in command.lower():
886
+ out.add_weak("high", "redis flushes every key")
887
+ for m in _CHMOD_RE.finditer(command):
888
+ p = _norm(m.group(2).strip(), None)
889
+ scope = _perm_scope(p) if p else None
890
+ if scope:
891
+ out.add_weak(scope, f"recursive {m.group(1)} of {p}")
892
+
893
+
894
+ # ---- standalone: CLI and Claude Code hook -------------------------------------------------------
895
+ def hook_response(command: str) -> dict[str, Any] | None:
896
+ """Claude Code PreToolUse output for a Bash command. None when nothing is flagged.
897
+ critical -> deny, high -> ask (even where an allow rule would have let it through),
898
+ medium -> allowed, with a note the user sees."""
899
+ hz = scan_command(command)
900
+ level = worst(hz)
901
+ if level is None:
902
+ return None
903
+ text = "Keywright hazard check: " + describe(hz)
904
+ if level == "medium":
905
+ return {"systemMessage": text}
906
+ decision = "deny" if level == "critical" else "ask"
907
+ reason = text + (". Refused: if this must happen, the user runs it by hand." if level == "critical"
908
+ else ". Flagged as destructive; an existing allow rule does not cover it.")
909
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision,
910
+ "permissionDecisionReason": reason},
911
+ "systemMessage": text}
912
+
913
+
914
+ def main(argv: list[str] | None = None) -> int:
915
+ import argparse
916
+
917
+ p = argparse.ArgumentParser(prog="keywright hazards",
918
+ description="Flag destructive shell commands. Exit 0 none, 1 medium, 2 high, 3 critical.")
919
+ p.add_argument("command", nargs="?", help="command line to check (omit to read stdin)")
920
+ p.add_argument("--hook", action="store_true",
921
+ help="Claude Code PreToolUse mode: read the hook JSON on stdin, print a decision")
922
+ p.add_argument("--json", action="store_true", help="print hazards as JSON")
923
+ a = p.parse_args(argv)
924
+ if a.hook:
925
+ try:
926
+ payload = json.load(sys.stdin)
927
+ except ValueError:
928
+ return 0
929
+ if payload.get("tool_name") != "Bash":
930
+ return 0
931
+ command = (payload.get("tool_input") or {}).get("command") or ""
932
+ resp = hook_response(command)
933
+ if resp:
934
+ print(json.dumps(resp))
935
+ return 0
936
+ command = a.command if a.command is not None else sys.stdin.read()
937
+ hz = scan_command(command)
938
+ level = worst(hz)
939
+ if a.json:
940
+ print(json.dumps({"level": level, "hazards": as_dicts(hz)}))
941
+ else:
942
+ for h in hz:
943
+ print(f"{h.severity}: {h.reason}")
944
+ return {None: 0, "medium": 1, "high": 2, "critical": 3}[level]
945
+
946
+
947
+ if __name__ == "__main__":
948
+ sys.exit(main())