@miller-tech/uap 2.1.7 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +7 -6
  2. package/config/capacity-policy.json +32 -0
  3. package/config/classify-thresholds.json +9 -0
  4. package/config/supervise-policy.json +13 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/bin/cli.js +87 -2
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/capacity/policy.d.ts +67 -0
  9. package/dist/capacity/policy.d.ts.map +1 -0
  10. package/dist/capacity/policy.js +133 -0
  11. package/dist/capacity/policy.js.map +1 -0
  12. package/dist/capacity/probe.d.ts +23 -0
  13. package/dist/capacity/probe.d.ts.map +1 -0
  14. package/dist/capacity/probe.js +93 -0
  15. package/dist/capacity/probe.js.map +1 -0
  16. package/dist/classify/baseline.d.ts +24 -0
  17. package/dist/classify/baseline.d.ts.map +1 -0
  18. package/dist/classify/baseline.js +140 -0
  19. package/dist/classify/baseline.js.map +1 -0
  20. package/dist/classify/index.d.ts +30 -0
  21. package/dist/classify/index.d.ts.map +1 -0
  22. package/dist/classify/index.js +84 -0
  23. package/dist/classify/index.js.map +1 -0
  24. package/dist/classify/shadow.d.ts +34 -0
  25. package/dist/classify/shadow.d.ts.map +1 -0
  26. package/dist/classify/shadow.js +33 -0
  27. package/dist/classify/shadow.js.map +1 -0
  28. package/dist/classify/types.d.ts +64 -0
  29. package/dist/classify/types.d.ts.map +1 -0
  30. package/dist/classify/types.js +37 -0
  31. package/dist/classify/types.js.map +1 -0
  32. package/dist/cli/classify.d.ts +24 -0
  33. package/dist/cli/classify.d.ts.map +1 -0
  34. package/dist/cli/classify.js +94 -0
  35. package/dist/cli/classify.js.map +1 -0
  36. package/dist/cli/deliver.d.ts.map +1 -1
  37. package/dist/cli/deliver.js +26 -0
  38. package/dist/cli/deliver.js.map +1 -1
  39. package/dist/cli/doctor.d.ts +9 -0
  40. package/dist/cli/doctor.d.ts.map +1 -0
  41. package/dist/cli/doctor.js +49 -0
  42. package/dist/cli/doctor.js.map +1 -0
  43. package/dist/cli/review.d.ts +12 -0
  44. package/dist/cli/review.d.ts.map +1 -0
  45. package/dist/cli/review.js +118 -0
  46. package/dist/cli/review.js.map +1 -0
  47. package/dist/cli/supervise.d.ts +8 -0
  48. package/dist/cli/supervise.d.ts.map +1 -0
  49. package/dist/cli/supervise.js +76 -0
  50. package/dist/cli/supervise.js.map +1 -0
  51. package/dist/delivery/convergence-loop.d.ts +8 -0
  52. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  53. package/dist/delivery/convergence-loop.js +1 -0
  54. package/dist/delivery/convergence-loop.js.map +1 -1
  55. package/dist/delivery/delivery-result.d.ts.map +1 -1
  56. package/dist/delivery/delivery-result.js +5 -0
  57. package/dist/delivery/delivery-result.js.map +1 -1
  58. package/dist/delivery/gate-evidence.d.ts +143 -0
  59. package/dist/delivery/gate-evidence.d.ts.map +1 -0
  60. package/dist/delivery/gate-evidence.js +239 -0
  61. package/dist/delivery/gate-evidence.js.map +1 -0
  62. package/dist/delivery/index.d.ts +1 -0
  63. package/dist/delivery/index.d.ts.map +1 -1
  64. package/dist/delivery/index.js +1 -0
  65. package/dist/delivery/index.js.map +1 -1
  66. package/dist/evals/routing-corpus.d.ts +35 -0
  67. package/dist/evals/routing-corpus.d.ts.map +1 -0
  68. package/dist/evals/routing-corpus.js +142 -0
  69. package/dist/evals/routing-corpus.js.map +1 -0
  70. package/dist/evals/routing-eval.d.ts +104 -0
  71. package/dist/evals/routing-eval.d.ts.map +1 -0
  72. package/dist/evals/routing-eval.js +231 -0
  73. package/dist/evals/routing-eval.js.map +1 -0
  74. package/dist/evals/tfidf.d.ts +35 -0
  75. package/dist/evals/tfidf.d.ts.map +1 -0
  76. package/dist/evals/tfidf.js +132 -0
  77. package/dist/evals/tfidf.js.map +1 -0
  78. package/dist/review/prepass.d.ts +88 -0
  79. package/dist/review/prepass.d.ts.map +1 -0
  80. package/dist/review/prepass.js +428 -0
  81. package/dist/review/prepass.js.map +1 -0
  82. package/dist/review/visual-captures.d.ts +60 -0
  83. package/dist/review/visual-captures.d.ts.map +1 -0
  84. package/dist/review/visual-captures.js +222 -0
  85. package/dist/review/visual-captures.js.map +1 -0
  86. package/dist/supervise/assess.d.ts +73 -0
  87. package/dist/supervise/assess.d.ts.map +1 -0
  88. package/dist/supervise/assess.js +215 -0
  89. package/dist/supervise/assess.js.map +1 -0
  90. package/dist/supervise/config.d.ts +13 -0
  91. package/dist/supervise/config.d.ts.map +1 -0
  92. package/dist/supervise/config.js +81 -0
  93. package/dist/supervise/config.js.map +1 -0
  94. package/dist/supervise/loop.d.ts +78 -0
  95. package/dist/supervise/loop.d.ts.map +1 -0
  96. package/dist/supervise/loop.js +311 -0
  97. package/dist/supervise/loop.js.map +1 -0
  98. package/dist/supervise/observe.d.ts +31 -0
  99. package/dist/supervise/observe.d.ts.map +1 -0
  100. package/dist/supervise/observe.js +172 -0
  101. package/dist/supervise/observe.js.map +1 -0
  102. package/dist/supervise/policy.d.ts +16 -0
  103. package/dist/supervise/policy.d.ts.map +1 -0
  104. package/dist/supervise/policy.js +147 -0
  105. package/dist/supervise/policy.js.map +1 -0
  106. package/dist/supervise/types.d.ts +109 -0
  107. package/dist/supervise/types.d.ts.map +1 -0
  108. package/dist/supervise/types.js +11 -0
  109. package/dist/supervise/types.js.map +1 -0
  110. package/docs/INDEX.md +15 -4
  111. package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
  112. package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
  113. package/docs/guides/CAPACITY_POLICY.md +99 -0
  114. package/docs/performance/automode-risk-calibration.md +72 -0
  115. package/docs/performance/reliability-ladder.md +78 -0
  116. package/docs/plans/system1-uplift-2026-09-18.md +190 -0
  117. package/docs/reference/automode-risk.md +134 -0
  118. package/docs/reference/evidence-bound-ship.md +146 -0
  119. package/docs/reference/review-prepass.md +77 -0
  120. package/docs/reference/routing-evals.md +71 -0
  121. package/docs/reference/semantic-supervisor.md +140 -0
  122. package/docs/reference/system1-classifier.md +103 -0
  123. package/docs/reference/visual-captures.md +113 -0
  124. package/package.json +5 -3
  125. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  126. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  127. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  128. package/src/policies/enforcers/enforcement_self_protect.py +25 -2
  129. package/src/policies/enforcers/evidence_bound_ship.py +257 -0
  130. package/src/policies/enforcers/expert_review_required.py +120 -0
  131. package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
  132. package/src/policies/schemas/policies/visual-verification.md +8 -0
  133. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  134. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  135. package/tools/agents/scripts/anthropic_proxy.py +782 -19
  136. package/tools/agents/scripts/project_telemetry.py +52 -0
  137. package/tools/agents/scripts/tool_risk.py +789 -0
  138. package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
  139. package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
  140. package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
  141. package/tools/agents/tests/test_gate_evidence.py +56 -0
  142. package/tools/agents/tests/test_tool_risk.py +521 -0
  143. package/tools/agents/tests/test_verbatim_compact.py +629 -0
@@ -0,0 +1,789 @@
1
+ """AutoMode pre-execution risk classification — advisory heuristic scorer.
2
+
3
+ Uplift 1.3. Classifies each tool call BEFORE dispatch into one of five risk
4
+ classes (destructive / write / execute / read / network) with a 1-5 score.
5
+ The output is ADVISORY to the deterministic policy chain: the enforcers in
6
+ src/policies/enforcers/*.py remain the blocking floor; this scorer never
7
+ blocks and never loosens anything. It exists so the proxy/enforcer stack has
8
+ an AutoMode risk signal NOW. Its vocabulary is INSPIRED BY the unmerged
9
+ TypeScript SYS1 baseline (src/classify/baseline.ts on
10
+ feature/301-sys1-classifier, the ESCALATION_TOKENS / DESTRUCTIVE_TOKENS
11
+ lists) — deliberately not "ported": that baseline scores prose state
12
+ descriptions while this scorer parses shell structure, so the domains and
13
+ machinery differ. The SYS1 promotion path is a Python-resident head (or
14
+ sidecar) behind this same classify_tool_call contract, gated by the
15
+ calibration harness at held-out destructive recall 1.0 — the wiring, harness
16
+ and doctrine do not change when it lands.
17
+
18
+ Design rules:
19
+ * PURE: no I/O, no clock except the latency measurement at the boundary.
20
+ * AUDITABLE: every point of score is attributable to a named signal in
21
+ ``signals`` (format "name:class+weight").
22
+ * FAIL-CLOSED ON DESTRUCTIVE: a missed destructive call is the unacceptable
23
+ error (held-out destructive recall is gated at 1.0 by
24
+ tool_risk_calibrate.py --check); false positives are advisory-only cost.
25
+ * ALIGNED, NOT DUPLICATED: launcher prefixes, conditional-verb semantics
26
+ (sed -i, find -delete) and protected-surface markers mirror
27
+ src/policies/enforcers/enforcement_self_protect.py so both layers see the
28
+ same evasion shapes (nohup/timeout/sudo/env prefixes, &&/;/| chaining,
29
+ trailing-slash and ./ path variants, .uap/.git self-targets).
30
+
31
+ Calibration: docs/performance/automode-risk-calibration.md, generated by
32
+ tool_risk_calibrate.py from tools/agents/tests/fixtures/tool_risk_cases.json.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import posixpath
37
+ import re
38
+ import shlex
39
+ import time
40
+ from collections.abc import Callable
41
+ from dataclasses import dataclass
42
+ from typing import Literal
43
+
44
+ RiskClass = Literal["destructive", "write", "execute", "read", "network"]
45
+ RISK_CLASSES: tuple[RiskClass, ...] = (
46
+ "destructive", "write", "execute", "read", "network",
47
+ )
48
+
49
+ # Tie-break order when two classes tie on total signal weight: the more
50
+ # severe class wins (advisory pessimism is cheap; a missed destructive call
51
+ # is not).
52
+ _SEVERITY: tuple[RiskClass, ...] = (
53
+ "destructive", "network", "write", "execute", "read",
54
+ )
55
+
56
+ _BASE_SCORE: dict[RiskClass, int] = {
57
+ "read": 1, "execute": 2, "write": 3, "network": 3, "destructive": 4,
58
+ }
59
+ # A class whose signals reach this total weight is a "hard" instance of the
60
+ # class and scores base+1 (capped at 5).
61
+ _HARD_SIGNAL_WEIGHT = 5
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RiskAssessment:
66
+ """Outcome of one advisory classification.
67
+
68
+ risk_class: one of RISK_CLASSES. risk_score: 1 (benign) .. 5 (hard
69
+ destructive). signals: every signal that fired, "name:class+weight" —
70
+ the score is exactly derivable from this list. latency_ms: wall time of
71
+ the pure computation (populated by classify_tool_call).
72
+ """
73
+
74
+ risk_class: RiskClass
75
+ risk_score: int
76
+ signals: list[str]
77
+ latency_ms: float
78
+
79
+
80
+ # A signal is (name, class, weight); formatted into RiskAssessment.signals.
81
+ _Signal = tuple[str, RiskClass, int]
82
+ _SignalFn = Callable[[str, list[str], str, int, "list[_Signal]"], None]
83
+
84
+
85
+ # --- shell lexing (aligned with enforcers/enforcement_self_protect.py) -----
86
+
87
+ # Split chained commands. Single & is a separator (backgrounding) — the
88
+ # enforcer uses the same class and accepts that `2>&1` splits here too.
89
+ _SEGMENT_SPLIT = re.compile(r"(?:\|\||&&|[;\n|&])")
90
+ _ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*$")
91
+ # Output redirection. The negative lookahead keeps fd duplication (`2>&1`)
92
+ # from counting as a file write; the target group accepts a quoted token so
93
+ # `> "out file.txt"` is still one target.
94
+ _REDIRECT = re.compile(
95
+ r">>?(?!\s*&)\s*(\"[^\"\n]*\"|'[^'\n]*'|[^\s;|&]*)"
96
+ )
97
+ _QUOTED = re.compile(r'"[^"\n]*"|\'[^\'\n]*\'')
98
+
99
+
100
+ def _blank_quotes(text: str) -> str:
101
+ """Blank quoted INTERIORS to spaces, preserving the quote characters and
102
+ the total length. Two properties ride on this:
103
+
104
+ * offsets are preserved, so a separator or operator position in the
105
+ blanked copy is the same position in the original (segment splitting
106
+ and redirect scanning can slice the original by the blanked span);
107
+ * a quoted TOKEN survives as a quoted token, so a quoted redirect target
108
+ (`> ".uap/x"`, `> "out file.txt"`) keeps its quotes and its span —
109
+ while quoted PROSE (`echo "rm -rf /"`) blanks to inert spaces and can
110
+ never reach verb position.
111
+
112
+ Known residual (documented in docs/reference/automode-risk.md): an
113
+ ESCAPED quote inside a double-quoted string (`"a=\\";\\"; rm -rf /x"`)
114
+ ends the match early, because the regex does not model backslash escapes
115
+ — same accepted-residual class as paths held in shell variables."""
116
+ def _sub(m: "re.Match") -> str:
117
+ s = m.group(0)
118
+ return s[0] + " " * (len(s) - 2) + s[-1]
119
+
120
+ return _QUOTED.sub(_sub, text)
121
+ # Launcher prefixes that run the REAL command as their tail — stepping over
122
+ # them (and their flags/values) is what makes `sudo rm -rf /`,
123
+ # `nohup rm -rf x &` and `timeout 10 rm -rf y` visible. Same list the
124
+ # self-protect enforcer steps over.
125
+ _LAUNCHERS = frozenset({
126
+ "nohup", "timeout", "command", "builtin", "setsid", "sudo", "doas",
127
+ "nice", "ionice", "stdbuf", "time", "unbuffer", "env",
128
+ })
129
+ _SHELL_VERBS = frozenset({"sh", "bash", "zsh", "dash", "ash", "ksh", "mksh"})
130
+ _INTERPRETERS = frozenset({
131
+ "python", "python2", "python3", "perl", "ruby", "node", "nodejs", "php", "lua",
132
+ })
133
+ _SQL_CLIENTS = frozenset({
134
+ "psql", "mysql", "mariadb", "sqlite3", "sqlplus",
135
+ "redis-cli", "mongo", "mongosh", "clickhouse-client",
136
+ })
137
+ _PKG_MANAGERS = frozenset({
138
+ "npm", "yarn", "pnpm", "pip", "pip3", "pipx",
139
+ "apt", "apt-get", "dpkg", "dnf", "yum", "brew",
140
+ })
141
+ _PLATFORM_CLIS = frozenset({
142
+ "docker", "podman", "kubectl", "terraform", "tofu", "aws", "gcloud",
143
+ "az", "systemctl", "go", "cargo",
144
+ })
145
+ # Regenerable trees whose removal is routine maintenance, not destruction:
146
+ # `rm -rf ./node_modules/.cache` must NOT classify destructive. Membership is
147
+ # by path COMPONENT so `./build`, `pkg/dist` and `/tmp/x` all resolve.
148
+ _SCRATCH_COMPONENTS = frozenset({
149
+ "node_modules", ".cache", "__pycache__", ".pytest_cache", ".mypy_cache",
150
+ ".ruff-cache", ".ruff_cache", ".next", ".turbo", ".parcel-cache",
151
+ "dist", "build", "coverage", "out", "target", ".venv",
152
+ })
153
+ # The enforcement control surface. A destructive op naming any of these gets
154
+ # a score bump — this is the advisory mirror of the self-protect enforcer's
155
+ # PROTECTED_MARKERS (deliberately a coarse substring/regex net: advisory
156
+ # over-trigger is free).
157
+ _PROTECTED_RE = re.compile(
158
+ r"\.uap(?:[\s/'\".]|$)|(?:^|[\s/'\"])\.git(?:[\s/'\"]|$)"
159
+ r"|src/policies|\.policy-tools|policies\.db|anthropic-proxy\.env"
160
+ )
161
+
162
+ _READ_VERBS = frozenset({
163
+ "ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg", "ag",
164
+ "ack", "ps", "df", "du", "pwd", "which", "file", "stat", "jq", "tree",
165
+ "less", "more", "id", "uname", "hostname", "date", "echo", "printf",
166
+ "cd", "printenv", "whoami", "lsblk", "lsof", "true", "false", "test",
167
+ })
168
+ _WRITE_VERBS = frozenset({
169
+ "cp", "mv", "mkdir", "touch", "ln", "tee", "install", "patch",
170
+ "unzip", "unrar", "gunzip", "bunzip2", "chattr", "zip", "gzip", "bzip2",
171
+ })
172
+ _EXEC_VERBS = frozenset({
173
+ "make", "ninja", "cmake", "pytest", "jest", "vitest", "mocha", "tox",
174
+ "nox", "gradle", "mvn", "kill", "pkill", "killall", "xargs", "source",
175
+ })
176
+ # find only mutates under a flag; sed only under -i. Mirrors the enforcer's
177
+ # CONDITIONAL_VERBS semantics.
178
+ _FIND_MUTATING = frozenset({
179
+ "-delete", "-exec", "-execdir", "-ok", "-okdir",
180
+ "-fprint", "-fprint0", "-fprintf", "-fls",
181
+ })
182
+ _INTERP_DESTRUCTIVE_RE = re.compile(
183
+ r"shutil\.rmtree|rmtree|os\.remove|os\.unlink|\.unlink\(|"
184
+ r"os\.removedirs|os\.rmdir|pathlib.*\.rmtree"
185
+ )
186
+ _INTERP_WRITE_RE = re.compile(
187
+ r"open\s*\([^)]*['\"][wax+]|\.write\(|\.writelines\(|json\.dump\("
188
+ )
189
+ _SQL_DROP_RE = re.compile(
190
+ r"\bdrop\s+(table|database|schema|index|collection)\b|dropdatabase\s*\(|"
191
+ r"\bflush(all|db)\b",
192
+ re.I,
193
+ )
194
+ _SQL_TRUNCATE_RE = re.compile(r"\btruncate\b", re.I)
195
+ _SQL_DELETE_RE = re.compile(r"\bdelete\s+from\b", re.I)
196
+ _SQL_WRITE_RE = re.compile(r"\b(insert\s+into|update\s+\w+\s+set|alter\s+table|create\s+table)\b", re.I)
197
+
198
+
199
+ def _verb_of(token: str) -> str:
200
+ """The command a token invokes, quoting/\\-escaping/path-prefix removed.
201
+
202
+ Same normalisation as the self-protect enforcer's `_verb_of`.
203
+ """
204
+ return token.strip("\"'").lstrip("\\").rsplit("/", 1)[-1].lower()
205
+
206
+
207
+ def _split_tokens(segment: str) -> list[str]:
208
+ """shlex split with a naive fallback for unbalanced quotes."""
209
+ try:
210
+ return shlex.split(segment, posix=True)
211
+ except ValueError:
212
+ return segment.split()
213
+
214
+
215
+ def _is_scratch_target(token: str) -> bool:
216
+ """True when an rm target is a regenerable scratch path (or under /tmp)."""
217
+ t = token.strip("\"'").rstrip("/")
218
+ norm = posixpath.normpath(t) if "/" in t else t
219
+ if norm.startswith("/tmp/"):
220
+ return True
221
+ return any(part in _SCRATCH_COMPONENTS for part in norm.split("/"))
222
+
223
+
224
+ def _first_operand(rest: list[str]) -> tuple[str, list[str]]:
225
+ """First non-flag token and what follows it (subcommand extraction)."""
226
+ for i, tok in enumerate(rest):
227
+ if not tok.startswith("-"):
228
+ return tok.lower(), rest[i + 1:]
229
+ return "", []
230
+
231
+
232
+ # --- per-verb signal functions (each small; one audit unit per rule) -------
233
+ # Uniform signature: fn(verb, rest, segment, depth, signals) -> None
234
+
235
+ def _sig_redirects(segment: str, signals: list[_Signal]) -> None:
236
+ """Output redirects are writes; a device target is destructive.
237
+
238
+ Operators are found on the quote-BLANKED text (so `echo "a > b"` is not
239
+ a write), but the target is sliced from the ORIGINAL segment at the same
240
+ span — blanking preserves length, so a quoted target (`> ".uap/x"`,
241
+ `> "out file.txt"`) arrives with its real name for the protected check.
242
+ """
243
+ blanked = _blank_quotes(segment)
244
+ for m in _REDIRECT.finditer(blanked):
245
+ target = segment[m.start(1):m.end(1)].strip("\"'")
246
+ if not target:
247
+ tail = segment[m.end():].split()
248
+ target = tail[0].strip("\"'") if tail else ""
249
+ if not target or target == "/dev/null":
250
+ continue
251
+ if target.startswith("/dev/"):
252
+ signals.append(("device-write", "destructive", 5))
253
+ elif _PROTECTED_RE.search(target.lower()):
254
+ signals.append(("protected-redirect", "write", 3))
255
+ else:
256
+ signals.append(("file-redirect", "write", 2))
257
+
258
+
259
+ def _sig_shell_wrap(verb: str, rest: list[str], segment: str, depth: int,
260
+ signals: list[_Signal]) -> None:
261
+ """sh/bash -c '...' and eval: unwrap and classify the inner command."""
262
+ signals.append(("shell-wrap", "execute", 1))
263
+ if depth >= 2: # bounded: bash -c "bash -c ..." can't recurse forever
264
+ return
265
+ if verb == "eval":
266
+ inner = " ".join(rest).strip()
267
+ if inner:
268
+ _classify_command(inner, depth + 1, signals)
269
+ return
270
+ for i, tok in enumerate(rest):
271
+ if tok == "-c" and i + 1 < len(rest):
272
+ _classify_command(rest[i + 1], depth + 1, signals)
273
+ return
274
+ # No -c: a bare `bash script.sh` runs the script.
275
+ if rest and not rest[0].startswith("-"):
276
+ signals.append(("shell-script", "execute", 2))
277
+
278
+
279
+ def _sig_interpreter(verb: str, rest: list[str], segment: str, depth: int,
280
+ signals: list[_Signal]) -> None:
281
+ """python/node/perl -c code is scanned for write/destructive primitives;
282
+ a script path or -m module is an ordinary execution."""
283
+ if "-c" in rest:
284
+ if _INTERP_DESTRUCTIVE_RE.search(segment):
285
+ signals.append(("interpreter-destructive", "destructive", 5))
286
+ elif _INTERP_WRITE_RE.search(segment):
287
+ signals.append(("interpreter-write", "write", 2))
288
+ else:
289
+ signals.append(("interpreter-code", "execute", 2))
290
+ elif rest and (not rest[0].startswith("-") or rest[0] == "-m"):
291
+ signals.append(("interpreter-script", "execute", 2))
292
+ else:
293
+ signals.append(("interpreter-run", "execute", 1))
294
+
295
+
296
+ def _git_subcommand(rest: list[str]) -> tuple[str, list[str]]:
297
+ """The git subcommand, stepping over global flags like -C/-c/--git-dir."""
298
+ i = 0
299
+ while i < len(rest):
300
+ tok = rest[i]
301
+ if tok in ("-C", "-c", "--git-dir", "--work-tree"):
302
+ i += 2
303
+ continue
304
+ if tok.startswith("-"):
305
+ i += 1
306
+ continue
307
+ return tok.lower(), rest[i + 1:]
308
+ return "", []
309
+
310
+
311
+ def _sig_git_push(args: list[str], signals: list[_Signal]) -> None:
312
+ flags = [t for t in args if t.startswith("-")]
313
+ # --force-with-lease / --force-if-includes are the SAFE force spellings:
314
+ # they verify the remote ref before overwriting, so they classify as the
315
+ # network operation they are, not as destructive (a measured lookalike).
316
+ safe_force = any(
317
+ t == "--force-with-lease" or t.startswith("--force-with-lease=")
318
+ or t == "--force-if-includes"
319
+ for t in flags
320
+ )
321
+ hard_force = any(
322
+ t in ("-f", "--force")
323
+ or (t.startswith("-") and not t.startswith("--") and "f" in t[1:])
324
+ for t in flags
325
+ )
326
+ # Hard force is judged FIRST: git ignores the lease when a plain --force
327
+ # is present, so `--force --force-with-lease` is destructive regardless
328
+ # of argument order. `--no-force-with-lease` matches neither list and
329
+ # correctly stays an ordinary push.
330
+ if hard_force:
331
+ signals.append(("git-push-force", "destructive", 5))
332
+ elif safe_force:
333
+ signals.append(("git-push-lease", "network", 3))
334
+ else:
335
+ signals.append(("git-push", "network", 3))
336
+
337
+
338
+ def _sig_git(verb: str, rest: list[str], segment: str, depth: int,
339
+ signals: list[_Signal]) -> None:
340
+ sub, args = _git_subcommand(rest)
341
+ if sub == "push":
342
+ _sig_git_push(args, signals)
343
+ elif sub == "reset":
344
+ if "--hard" in args:
345
+ signals.append(("git-reset-hard", "destructive", 5))
346
+ else:
347
+ signals.append(("git-reset-soft", "write", 2))
348
+ elif sub == "clean":
349
+ joined = "".join(t for t in args if t.startswith("-"))
350
+ if "f" in joined:
351
+ signals.append(("git-clean-force", "destructive", 4))
352
+ else:
353
+ signals.append(("git-clean-dry", "read", 1))
354
+ elif sub in ("checkout", "restore", "switch"):
355
+ # `checkout -- <path>` / `restore <path>` DISCARDS worktree edits.
356
+ if "--" in args or (sub == "restore" and any(not t.startswith("-") for t in args)):
357
+ signals.append(("git-discard-worktree", "destructive", 4))
358
+ else:
359
+ signals.append(("git-checkout", "write", 2))
360
+ elif sub == "branch":
361
+ if "-D" in args:
362
+ signals.append(("git-branch-force-delete", "destructive", 4))
363
+ elif "-d" in args:
364
+ signals.append(("git-branch-delete", "write", 2))
365
+ else:
366
+ signals.append(("git-branch", "read", 1))
367
+ elif sub in ("fetch", "pull", "clone", "remote", "ls-remote"):
368
+ signals.append(("git-network", "network", 3))
369
+ elif sub in ("add", "commit", "tag", "stash", "rebase", "merge",
370
+ "cherry-pick", "mv", "rm", "worktree"):
371
+ signals.append(("git-write", "write", 2))
372
+ else:
373
+ # status, log, diff, show, blame, describe, rev-parse, ...
374
+ signals.append(("git-read", "read", 1))
375
+
376
+
377
+ def _sig_rm_family(verb: str, rest: list[str], segment: str, depth: int,
378
+ signals: list[_Signal]) -> None:
379
+ if verb == "shred":
380
+ signals.append(("shred", "destructive", 5))
381
+ return
382
+ if verb == "unlink":
383
+ signals.append(("unlink-file", "destructive", 3))
384
+ return
385
+ if verb == "rmdir":
386
+ signals.append(("rmdir", "destructive", 2))
387
+ return
388
+ flags = [t for t in rest if t.startswith("-")]
389
+ # Only SHORT-flag clusters carry the recursive letter (-r, -rf, -Rf).
390
+ # Long flags match exactly: --force does NOT contain a recursive "r"
391
+ # (`rm --force file` is a plain file delete), --recursive does.
392
+ recursive = any(
393
+ t == "--recursive"
394
+ or (not t.startswith("--") and ("r" in t[1:] or "R" in t[1:]))
395
+ for t in flags
396
+ )
397
+ targets = [t for t in rest if not t.startswith("-")]
398
+ # The scratch carve-out: removing ONLY regenerable trees (node_modules,
399
+ # build output, /tmp scratch) is maintenance, not destruction. Mixed
400
+ # target lists do not qualify — one real target keeps the class.
401
+ if targets and all(_is_scratch_target(t) for t in targets):
402
+ signals.append(("rm-scratch-target", "write", 2))
403
+ elif recursive:
404
+ signals.append(("rm-recursive", "destructive", 5))
405
+ else:
406
+ signals.append(("rm-file", "destructive", 3))
407
+
408
+
409
+ def _sig_find(verb: str, rest: list[str], segment: str, depth: int,
410
+ signals: list[_Signal]) -> None:
411
+ # Same rule as the enforcer: -delete / -exec-family / -f* writers mutate.
412
+ if any(t in _FIND_MUTATING for t in rest):
413
+ signals.append(("find-mutating", "destructive", 5))
414
+ else:
415
+ signals.append(("find-read", "read", 1))
416
+
417
+
418
+ def _sig_sed(verb: str, rest: list[str], segment: str, depth: int,
419
+ signals: list[_Signal]) -> None:
420
+ # GNU sed mutates only with -i / --in-place (enforcer parity).
421
+ in_place = False
422
+ for tok in rest:
423
+ if tok.startswith("--"):
424
+ in_place = in_place or tok.startswith("--in-place")
425
+ elif tok.startswith("-") and len(tok) > 1:
426
+ # short-flag cluster: -i, -i.bak, -ni, -Ei all carry the write
427
+ if "i" in tok[1:].split(".", 1)[0]:
428
+ in_place = True
429
+ signals.append(("sed-in-place" if in_place else "sed-read",
430
+ "write" if in_place else "read", 3 if in_place else 1))
431
+
432
+
433
+ def _sig_sql(verb: str, rest: list[str], segment: str, depth: int,
434
+ signals: list[_Signal]) -> None:
435
+ if _SQL_DROP_RE.search(segment):
436
+ signals.append(("sql-drop", "destructive", 5))
437
+ elif _SQL_TRUNCATE_RE.search(segment):
438
+ signals.append(("sql-truncate", "destructive", 5))
439
+ elif _SQL_DELETE_RE.search(segment):
440
+ signals.append(("sql-delete", "destructive", 4))
441
+ elif _SQL_WRITE_RE.search(segment):
442
+ signals.append(("sql-write", "write", 3))
443
+ else:
444
+ signals.append(("sql-read", "read", 1))
445
+
446
+
447
+ def _sig_pkg_manager(verb: str, rest: list[str], segment: str, depth: int,
448
+ signals: list[_Signal]) -> None:
449
+ sub, _args = _first_operand(rest)
450
+ if sub in ("install", "i", "add", "ci", "update", "upgrade", "exec",
451
+ "test", "run", "build", "start", "create", "dedupe"):
452
+ # Installs RUN package code (postinstall scripts); tests/builds run
453
+ # project code. Both are executions.
454
+ signals.append(("pkg-install", "execute", 3))
455
+ elif sub == "publish":
456
+ signals.append(("pkg-publish", "network", 3))
457
+ elif sub in ("remove", "uninstall", "rm", "purge", "prune"):
458
+ signals.append(("pkg-remove", "destructive", 3))
459
+ elif sub in ("list", "ls", "freeze", "show", "info", "view", "search"):
460
+ signals.append(("pkg-read", "read", 1))
461
+ else:
462
+ signals.append(("pkg-other", "execute", 2))
463
+
464
+
465
+ def _sig_platform_cli(verb: str, rest: list[str], segment: str, depth: int,
466
+ signals: list[_Signal]) -> None:
467
+ sub, _args = _first_operand(rest)
468
+ if verb in ("docker", "podman"):
469
+ if sub in ("run", "build", "exec", "compose", "start", "up"):
470
+ signals.append(("container-run", "execute", 3))
471
+ elif sub in ("rm", "rmi", "prune", "system"):
472
+ signals.append(("container-remove", "destructive", 4))
473
+ elif sub in ("ps", "images", "logs", "inspect"):
474
+ signals.append(("container-read", "read", 1))
475
+ else:
476
+ signals.append(("container-other", "execute", 2))
477
+ elif verb == "kubectl":
478
+ if sub in ("get", "describe", "logs"):
479
+ signals.append(("kube-read", "read", 1))
480
+ elif sub in ("delete", "drain"):
481
+ signals.append(("kube-delete", "destructive", 4))
482
+ else:
483
+ signals.append(("kube-apply", "execute", 3))
484
+ elif verb in ("terraform", "tofu"):
485
+ if sub == "destroy":
486
+ signals.append(("iac-destroy", "destructive", 5))
487
+ elif sub == "apply":
488
+ signals.append(("iac-apply", "execute", 3))
489
+ elif sub in ("plan", "show", "output", "state"):
490
+ signals.append(("iac-read", "read", 1))
491
+ else:
492
+ signals.append(("iac-other", "execute", 2))
493
+ elif verb in ("aws", "gcloud", "az"):
494
+ low = segment.lower()
495
+ if "rotate-secret" in low or ("secretsmanager" in low and "rotate" in low):
496
+ # Secrets rotation invalidates deployed credentials — the blast
497
+ # radius is a production outage, so it classifies destructive.
498
+ signals.append(("secrets-rotation", "destructive", 4))
499
+ elif re.search(r"\bs3\s+(rm|rb|mv)\b", low):
500
+ signals.append(("cloud-storage-remove", "destructive", 4))
501
+ else:
502
+ signals.append(("cloud-api", "network", 2))
503
+ elif verb == "systemctl":
504
+ if sub in ("status", "is-active", "is-enabled", "list-units", "show"):
505
+ signals.append(("service-read", "read", 1))
506
+ else:
507
+ signals.append(("service-control", "execute", 3))
508
+ else: # go / cargo
509
+ if sub == "fmt":
510
+ signals.append(("toolchain-fmt", "write", 2))
511
+ else:
512
+ signals.append(("toolchain-run", "execute", 2))
513
+
514
+
515
+ def _sig_chmod(verb: str, rest: list[str], segment: str, depth: int,
516
+ signals: list[_Signal]) -> None:
517
+ recursive = any(
518
+ t == "-R" or t == "--recursive"
519
+ or (t.startswith("-") and not t.startswith("--") and "R" in t)
520
+ for t in rest
521
+ )
522
+ permissive = any(t in ("777", "666", "a+w", "o+w") for t in rest)
523
+ targets_root = any(t.rstrip("/") == "/" for t in rest if not t.startswith("-"))
524
+ if recursive and (permissive or targets_root):
525
+ # `chmod -R 777 /etc`-shaped: broad permission destruction.
526
+ signals.append(("chmod-recursive-permissive", "destructive", 4))
527
+ elif recursive:
528
+ signals.append(("chmod-recursive", "write", 3))
529
+ else:
530
+ signals.append(("chmod", "write", 2))
531
+
532
+
533
+ def _sig_dd(verb: str, rest: list[str], segment: str, depth: int,
534
+ signals: list[_Signal]) -> None:
535
+ of = ""
536
+ for tok in rest:
537
+ if tok.startswith("of="):
538
+ of = tok[3:]
539
+ if of.startswith("/dev/"):
540
+ signals.append(("dd-device", "destructive", 5))
541
+ elif of:
542
+ signals.append(("dd-file", "write", 3))
543
+ else:
544
+ signals.append(("dd-read", "read", 1))
545
+
546
+
547
+ def _sig_tar(verb: str, rest: list[str], segment: str, depth: int,
548
+ signals: list[_Signal]) -> None:
549
+ if not rest:
550
+ signals.append(("archive-read", "read", 1))
551
+ return
552
+ flags = "".join(t.lstrip("-") for t in rest if t.startswith("-") or t == rest[0])
553
+ if "x" in flags or "c" in flags:
554
+ signals.append(("archive-write", "write", 2))
555
+ else:
556
+ signals.append(("archive-read", "read", 1))
557
+
558
+
559
+ def _sig_rsync(verb: str, rest: list[str], segment: str, depth: int,
560
+ signals: list[_Signal]) -> None:
561
+ # A "host:" operand means the wire; otherwise it is a local copy.
562
+ if any(":" in t and not t.startswith("-") for t in rest):
563
+ signals.append(("rsync-remote", "network", 3))
564
+ else:
565
+ signals.append(("rsync-local", "write", 2))
566
+
567
+
568
+ def _sig_simple(weight_class: tuple[str, RiskClass, int]) -> _SignalFn:
569
+ """Build a constant-signaler for the table-driven trivial verbs."""
570
+ name, klass, weight = weight_class
571
+
572
+ def _emit(verb: str, rest: list[str], segment: str, depth: int,
573
+ signals: list[_Signal]) -> None:
574
+ signals.append((name, klass, weight))
575
+
576
+ return _emit
577
+
578
+
579
+ # Ordered verb dispatch. First match wins; the fallback at the bottom of
580
+ # _classify_segment is a generic execution.
581
+ _VERB_RULES: tuple[tuple[frozenset, _SignalFn], ...] = (
582
+ (_SHELL_VERBS | {"eval"}, _sig_shell_wrap),
583
+ (_INTERPRETERS, _sig_interpreter),
584
+ (frozenset({"git"}), _sig_git),
585
+ (frozenset({"rm", "unlink", "shred", "rmdir"}), _sig_rm_family),
586
+ (frozenset({"find"}), _sig_find),
587
+ (frozenset({"sed"}), _sig_sed),
588
+ (_SQL_CLIENTS, _sig_sql),
589
+ (_PKG_MANAGERS, _sig_pkg_manager),
590
+ (_PLATFORM_CLIS, _sig_platform_cli),
591
+ (frozenset({"chmod", "chown"}), _sig_chmod),
592
+ (frozenset({"dd"}), _sig_dd),
593
+ (frozenset({"tar"}), _sig_tar),
594
+ (frozenset({"rsync"}), _sig_rsync),
595
+ (frozenset({"truncate", "wipefs", "fdisk", "sfdisk", "parted", "mkswap"}),
596
+ _sig_simple(("wipe-device", "destructive", 4))),
597
+ (frozenset({"curl", "wget", "scp", "sftp", "ssh", "nc", "ncat"}),
598
+ _sig_simple(("network-call", "network", 3))),
599
+ (_READ_VERBS, _sig_simple(("read-command", "read", 1))),
600
+ (_WRITE_VERBS, _sig_simple(("write-command", "write", 2))),
601
+ (_EXEC_VERBS, _sig_simple(("exec-command", "execute", 2))),
602
+ )
603
+
604
+
605
+ def _classify_segment(segment: str, depth: int, signals: list[_Signal]) -> None:
606
+ # Redirects: operators are matched on quote-blanked text (quoted prose
607
+ # stays inert) while targets come from the original — see _sig_redirects.
608
+ _sig_redirects(segment, signals)
609
+ tokens = _split_tokens(segment)
610
+ while tokens and _ENV_ASSIGN.match(tokens[0]):
611
+ tokens = tokens[1:]
612
+ # Step over launcher prefixes (and their flag/value arguments) so the
613
+ # REAL verb is judged: `sudo rm -rf /`, `timeout 10 rm -rf x`,
614
+ # `env FOO=1 rm -rf y`. Same step-over as the self-protect enforcer,
615
+ # including its known limit: `sudo -u root rm ...` leaves `root` as the
616
+ # apparent verb (advisory-only cost, documented in the reference doc).
617
+ while len(tokens) > 1 and _verb_of(tokens[0]) in _LAUNCHERS:
618
+ tokens = tokens[1:]
619
+ while tokens and (tokens[0].startswith("-") or tokens[0].isdigit()):
620
+ tokens = tokens[1:]
621
+ while tokens and _ENV_ASSIGN.match(tokens[0]):
622
+ tokens = tokens[1:]
623
+ if not tokens:
624
+ return
625
+ verb = _verb_of(tokens[0])
626
+ if verb.startswith("mkfs"):
627
+ signals.append(("mkfs-device", "destructive", 5))
628
+ return
629
+ rest = tokens[1:]
630
+ for names, fn in _VERB_RULES:
631
+ if verb in names:
632
+ fn(verb, rest, segment, depth, signals)
633
+ return
634
+ # An unknown verb in a shell is a program execution (./script.sh included).
635
+ signals.append(("shell-command", "execute", 2))
636
+
637
+ def _split_segments(command: str) -> list[str]:
638
+ """Split a command into chained segments, ignoring separators inside
639
+ quotes. Quote interiors are blanked with equal-length space runs so
640
+ offsets in the blanked copy match the original (this is what keeps the
641
+ `;` inside `python3 -c "import shutil; shutil.rmtree(x)"` from hiding
642
+ the rmtree from the interpreter scan — the enforcer chain splits naively
643
+ and the same evasion shape must not open here)."""
644
+ blanked = _blank_quotes(command or "")
645
+ out: list[str] = []
646
+ last = 0
647
+ for m in _SEGMENT_SPLIT.finditer(blanked):
648
+ out.append(command[last:m.start()])
649
+ last = m.end()
650
+ out.append(command[last:])
651
+ return out
652
+
653
+
654
+ def _classify_command(command: str, depth: int, signals: list[_Signal]) -> None:
655
+ for segment in _split_segments(command):
656
+ segment = segment.strip()
657
+ if segment:
658
+ _classify_segment(segment, depth, signals)
659
+ # A destructive op naming the enforcement control surface (.uap/, .git,
660
+ # policy tree, proxy env) is the self-targeting evasion the self-protect
661
+ # enforcer exists for — bump it so it always lands at score 5.
662
+ if depth == 0 and _PROTECTED_RE.search(command.lower()):
663
+ if any(klass == "destructive" for _n, klass, _w in signals):
664
+ signals.append(("protected-target", "destructive", 1))
665
+
666
+
667
+ # --- tool-name routing -----------------------------------------------------
668
+
669
+ _READ_TOOLS = frozenset({
670
+ "read", "grep", "glob", "ls", "view", "read_file", "list_directory",
671
+ "search_files", "get_file_info",
672
+ })
673
+ _WRITE_TOOLS = frozenset({
674
+ "edit", "write", "multiedit", "notebookedit", "create", "create_file",
675
+ "write_file", "edit_file", "insert", "str_replace_editor",
676
+ })
677
+ _NETWORK_TOOLS = frozenset({
678
+ "webfetch", "websearch", "web_fetch", "web_search", "fetch", "browse",
679
+ })
680
+ _SHELL_TOOLS = frozenset({
681
+ "bash", "shell", "execute", "run", "terminal", "exec", "process",
682
+ "powershell", "cmd", "run_command", "execute_command", "run_terminal_cmd",
683
+ })
684
+ _COMMAND_KEYS = ("command", "cmd", "script", "code")
685
+ _PATH_KEYS = ("file_path", "path", "target", "filename", "filePath")
686
+
687
+
688
+ def _tool_family(tool_name: str) -> str:
689
+ leaf = (tool_name or "").lower().split("__")[-1].replace("-", "_")
690
+ if leaf in _READ_TOOLS:
691
+ return "read"
692
+ if leaf in _WRITE_TOOLS:
693
+ return "write"
694
+ if leaf in _NETWORK_TOOLS:
695
+ return "network"
696
+ if leaf in _SHELL_TOOLS:
697
+ return "shell"
698
+ # MCP-style names (mcp__server__do_thing): substring fallback.
699
+ if any(k in leaf for k in ("bash", "shell", "terminal", "powershell", "command")):
700
+ return "shell"
701
+ if any(k in leaf for k in ("write", "edit", "create", "patch")):
702
+ return "write"
703
+ if any(k in leaf for k in ("read", "grep", "glob", "list")):
704
+ return "read"
705
+ if any(k in leaf for k in ("fetch", "browse", "search", "http")):
706
+ return "network"
707
+ return "unknown"
708
+
709
+
710
+ def _extract_command(tool_input: dict) -> str:
711
+ for key in _COMMAND_KEYS:
712
+ value = tool_input.get(key)
713
+ if isinstance(value, str) and value.strip():
714
+ return value
715
+ return ""
716
+
717
+
718
+ def _extract_path(tool_input: dict) -> str:
719
+ for key in _PATH_KEYS:
720
+ value = tool_input.get(key)
721
+ if isinstance(value, str) and value.strip():
722
+ return value
723
+ return ""
724
+
725
+
726
+ # --- combination -----------------------------------------------------------
727
+
728
+
729
+ def _combine(signals: list[_Signal]) -> tuple[RiskClass, int]:
730
+ """Class = highest total signal weight, ties broken by severity. Score =
731
+ class base, +1 when the class's evidence is hard (>= _HARD_SIGNAL_WEIGHT),
732
+ capped at 5. Every point traces to the named signals that produced it."""
733
+ if not signals:
734
+ return "read", 1
735
+ totals: dict[RiskClass, int] = {}
736
+ for _name, klass, weight in signals:
737
+ totals[klass] = totals.get(klass, 0) + weight
738
+ best = max(totals, key=lambda k: (totals[k], -_SEVERITY.index(k)))
739
+ score = _BASE_SCORE[best] + (1 if totals[best] >= _HARD_SIGNAL_WEIGHT else 0)
740
+ return best, min(5, score)
741
+
742
+
743
+ def _assess(tool_name: str, tool_input: dict) -> tuple[RiskClass, int, list[str]]:
744
+ """The pure computation: no I/O, no clock. Separated from
745
+ classify_tool_call so the latency measurement wraps exactly this."""
746
+ signals: list[_Signal] = []
747
+ family = _tool_family(tool_name)
748
+ if family == "shell":
749
+ command = _extract_command(tool_input)
750
+ if command:
751
+ _classify_command(command, 0, signals)
752
+ else:
753
+ # An empty command runs nothing.
754
+ signals.append(("shell-empty", "read", 1))
755
+ elif family == "write":
756
+ signals.append(("tool-write", "write", 2))
757
+ path = _extract_path(tool_input)
758
+ if path and _PROTECTED_RE.search(path.lower()):
759
+ signals.append(("protected-target", "write", 3))
760
+ elif family == "read":
761
+ signals.append(("tool-read", "read", 1))
762
+ elif family == "network":
763
+ signals.append(("tool-network", "network", 3))
764
+ else:
765
+ # Unknown tool: assume it can act (advisory pessimism), at the lowest
766
+ # execution weight. The deterministic enforcers judge it for real.
767
+ signals.append(("tool-unknown", "execute", 1))
768
+ risk_class, score = _combine(signals)
769
+ formatted = [f"{name}:{klass}+{weight}" for name, klass, weight in signals]
770
+ return risk_class, score, formatted
771
+
772
+
773
+ def classify_tool_call(tool_name: str, tool_input: dict) -> RiskAssessment:
774
+ """Classify one tool call's risk. PURE: no I/O, no global state.
775
+
776
+ ``tool_input`` that is not a dict (or missing keys) degrades to the
777
+ tool-name-only classification — never an exception, never a block.
778
+ """
779
+ start = time.perf_counter()
780
+ if not isinstance(tool_input, dict):
781
+ tool_input = {}
782
+ risk_class, score, signals = _assess(str(tool_name or ""), tool_input)
783
+ latency_ms = (time.perf_counter() - start) * 1000.0
784
+ return RiskAssessment(
785
+ risk_class=risk_class,
786
+ risk_score=score,
787
+ signals=signals,
788
+ latency_ms=latency_ms,
789
+ )