@miller-tech/uap 2.1.7 → 2.11.0

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 (142) hide show
  1. package/config/capacity-policy.json +32 -0
  2. package/config/classify-thresholds.json +9 -0
  3. package/config/supervise-policy.json +13 -0
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/bin/cli.js +87 -2
  6. package/dist/bin/cli.js.map +1 -1
  7. package/dist/capacity/policy.d.ts +67 -0
  8. package/dist/capacity/policy.d.ts.map +1 -0
  9. package/dist/capacity/policy.js +133 -0
  10. package/dist/capacity/policy.js.map +1 -0
  11. package/dist/capacity/probe.d.ts +23 -0
  12. package/dist/capacity/probe.d.ts.map +1 -0
  13. package/dist/capacity/probe.js +93 -0
  14. package/dist/capacity/probe.js.map +1 -0
  15. package/dist/classify/baseline.d.ts +24 -0
  16. package/dist/classify/baseline.d.ts.map +1 -0
  17. package/dist/classify/baseline.js +140 -0
  18. package/dist/classify/baseline.js.map +1 -0
  19. package/dist/classify/index.d.ts +30 -0
  20. package/dist/classify/index.d.ts.map +1 -0
  21. package/dist/classify/index.js +84 -0
  22. package/dist/classify/index.js.map +1 -0
  23. package/dist/classify/shadow.d.ts +34 -0
  24. package/dist/classify/shadow.d.ts.map +1 -0
  25. package/dist/classify/shadow.js +33 -0
  26. package/dist/classify/shadow.js.map +1 -0
  27. package/dist/classify/types.d.ts +64 -0
  28. package/dist/classify/types.d.ts.map +1 -0
  29. package/dist/classify/types.js +37 -0
  30. package/dist/classify/types.js.map +1 -0
  31. package/dist/cli/classify.d.ts +24 -0
  32. package/dist/cli/classify.d.ts.map +1 -0
  33. package/dist/cli/classify.js +94 -0
  34. package/dist/cli/classify.js.map +1 -0
  35. package/dist/cli/deliver.d.ts.map +1 -1
  36. package/dist/cli/deliver.js +26 -0
  37. package/dist/cli/deliver.js.map +1 -1
  38. package/dist/cli/doctor.d.ts +9 -0
  39. package/dist/cli/doctor.d.ts.map +1 -0
  40. package/dist/cli/doctor.js +49 -0
  41. package/dist/cli/doctor.js.map +1 -0
  42. package/dist/cli/review.d.ts +12 -0
  43. package/dist/cli/review.d.ts.map +1 -0
  44. package/dist/cli/review.js +118 -0
  45. package/dist/cli/review.js.map +1 -0
  46. package/dist/cli/supervise.d.ts +8 -0
  47. package/dist/cli/supervise.d.ts.map +1 -0
  48. package/dist/cli/supervise.js +76 -0
  49. package/dist/cli/supervise.js.map +1 -0
  50. package/dist/delivery/convergence-loop.d.ts +8 -0
  51. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  52. package/dist/delivery/convergence-loop.js +1 -0
  53. package/dist/delivery/convergence-loop.js.map +1 -1
  54. package/dist/delivery/delivery-result.d.ts.map +1 -1
  55. package/dist/delivery/delivery-result.js +5 -0
  56. package/dist/delivery/delivery-result.js.map +1 -1
  57. package/dist/delivery/gate-evidence.d.ts +143 -0
  58. package/dist/delivery/gate-evidence.d.ts.map +1 -0
  59. package/dist/delivery/gate-evidence.js +239 -0
  60. package/dist/delivery/gate-evidence.js.map +1 -0
  61. package/dist/delivery/index.d.ts +1 -0
  62. package/dist/delivery/index.d.ts.map +1 -1
  63. package/dist/delivery/index.js +1 -0
  64. package/dist/delivery/index.js.map +1 -1
  65. package/dist/evals/routing-corpus.d.ts +35 -0
  66. package/dist/evals/routing-corpus.d.ts.map +1 -0
  67. package/dist/evals/routing-corpus.js +142 -0
  68. package/dist/evals/routing-corpus.js.map +1 -0
  69. package/dist/evals/routing-eval.d.ts +104 -0
  70. package/dist/evals/routing-eval.d.ts.map +1 -0
  71. package/dist/evals/routing-eval.js +231 -0
  72. package/dist/evals/routing-eval.js.map +1 -0
  73. package/dist/evals/tfidf.d.ts +35 -0
  74. package/dist/evals/tfidf.d.ts.map +1 -0
  75. package/dist/evals/tfidf.js +132 -0
  76. package/dist/evals/tfidf.js.map +1 -0
  77. package/dist/review/prepass.d.ts +88 -0
  78. package/dist/review/prepass.d.ts.map +1 -0
  79. package/dist/review/prepass.js +428 -0
  80. package/dist/review/prepass.js.map +1 -0
  81. package/dist/review/visual-captures.d.ts +60 -0
  82. package/dist/review/visual-captures.d.ts.map +1 -0
  83. package/dist/review/visual-captures.js +222 -0
  84. package/dist/review/visual-captures.js.map +1 -0
  85. package/dist/supervise/assess.d.ts +73 -0
  86. package/dist/supervise/assess.d.ts.map +1 -0
  87. package/dist/supervise/assess.js +215 -0
  88. package/dist/supervise/assess.js.map +1 -0
  89. package/dist/supervise/config.d.ts +13 -0
  90. package/dist/supervise/config.d.ts.map +1 -0
  91. package/dist/supervise/config.js +81 -0
  92. package/dist/supervise/config.js.map +1 -0
  93. package/dist/supervise/loop.d.ts +78 -0
  94. package/dist/supervise/loop.d.ts.map +1 -0
  95. package/dist/supervise/loop.js +311 -0
  96. package/dist/supervise/loop.js.map +1 -0
  97. package/dist/supervise/observe.d.ts +31 -0
  98. package/dist/supervise/observe.d.ts.map +1 -0
  99. package/dist/supervise/observe.js +172 -0
  100. package/dist/supervise/observe.js.map +1 -0
  101. package/dist/supervise/policy.d.ts +16 -0
  102. package/dist/supervise/policy.d.ts.map +1 -0
  103. package/dist/supervise/policy.js +147 -0
  104. package/dist/supervise/policy.js.map +1 -0
  105. package/dist/supervise/types.d.ts +109 -0
  106. package/dist/supervise/types.d.ts.map +1 -0
  107. package/dist/supervise/types.js +11 -0
  108. package/dist/supervise/types.js.map +1 -0
  109. package/docs/INDEX.md +9 -3
  110. package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
  111. package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
  112. package/docs/guides/CAPACITY_POLICY.md +98 -0
  113. package/docs/performance/automode-risk-calibration.md +72 -0
  114. package/docs/performance/reliability-ladder.md +78 -0
  115. package/docs/plans/system1-uplift-2026-09-18.md +191 -0
  116. package/docs/reference/automode-risk.md +134 -0
  117. package/docs/reference/evidence-bound-ship.md +145 -0
  118. package/docs/reference/review-prepass.md +77 -0
  119. package/docs/reference/routing-evals.md +71 -0
  120. package/docs/reference/semantic-supervisor.md +140 -0
  121. package/docs/reference/system1-classifier.md +103 -0
  122. package/docs/reference/visual-captures.md +113 -0
  123. package/package.json +5 -3
  124. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  125. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  126. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  127. package/src/policies/enforcers/enforcement_self_protect.py +25 -2
  128. package/src/policies/enforcers/evidence_bound_ship.py +257 -0
  129. package/src/policies/enforcers/expert_review_required.py +120 -0
  130. package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
  131. package/src/policies/schemas/policies/visual-verification.md +8 -0
  132. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  133. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  134. package/tools/agents/scripts/anthropic_proxy.py +782 -19
  135. package/tools/agents/scripts/project_telemetry.py +52 -0
  136. package/tools/agents/scripts/tool_risk.py +789 -0
  137. package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
  138. package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
  139. package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
  140. package/tools/agents/tests/test_gate_evidence.py +56 -0
  141. package/tools/agents/tests/test_tool_risk.py +521 -0
  142. package/tools/agents/tests/test_verbatim_compact.py +629 -0
@@ -0,0 +1,521 @@
1
+ """Tests for the AutoMode advisory risk classifier (uplift 1.3).
2
+
3
+ Covers: per-class fixture accuracy against the labeled corpus, evasion
4
+ variants (launcher prefixes, chaining, shell-wrap, quoted payloads), benign
5
+ lookalikes that must NOT classify destructive, calibration determinism and
6
+ the held-out destructive-recall gate, proxy wiring (classifier exception
7
+ passes through untouched, env hatch, debug log line, telemetry), and the
8
+ latency budget (p95 < 5ms over 10k calls — the 50ms AutoMode budget includes
9
+ future model calls; the heuristic floor must be far under).
10
+ """
11
+ import copy
12
+ import importlib.util
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import unittest
19
+ from pathlib import Path
20
+ from unittest import mock
21
+
22
+ _TESTS = Path(__file__).resolve().parent
23
+ _SCRIPTS = _TESTS.parent / "scripts"
24
+ _CORPUS = _TESTS / "fixtures" / "tool_risk_cases.json"
25
+
26
+
27
+ def _load(name: str, path: Path):
28
+ spec = importlib.util.spec_from_file_location(name, path)
29
+ module = importlib.util.module_from_spec(spec)
30
+ sys.modules[name] = module
31
+ spec.loader.exec_module(module)
32
+ return module
33
+
34
+
35
+ tool_risk = _load("tool_risk", _SCRIPTS / "tool_risk.py")
36
+ calibrate = _load("tool_risk_calibrate", _SCRIPTS / "tool_risk_calibrate.py")
37
+
38
+ # project_telemetry is imported lazily inside the proxy's scan helper; loading
39
+ # it here makes monkeypatching land on the same module object the proxy sees.
40
+ if str(_SCRIPTS) not in sys.path:
41
+ sys.path.insert(0, str(_SCRIPTS))
42
+ import project_telemetry # noqa: E402
43
+
44
+
45
+ def _classify(command: str) -> "tool_risk.RiskAssessment":
46
+ return tool_risk.classify_tool_call("Bash", {"command": command})
47
+
48
+
49
+ def _signal_names(assessment) -> list:
50
+ return [s.split(":", 1)[0] for s in assessment.signals]
51
+
52
+
53
+ class CorpusTest(unittest.TestCase):
54
+ @classmethod
55
+ def setUpClass(cls):
56
+ cls.cases = calibrate.load_corpus(_CORPUS)
57
+
58
+ def test_corpus_size_and_shape(self):
59
+ self.assertGreaterEqual(len(self.cases), 80)
60
+ ids = [c["id"] for c in self.cases]
61
+ self.assertEqual(len(ids), len(set(ids)), "duplicate case ids")
62
+
63
+ def test_corpus_covers_all_classes(self):
64
+ for klass in tool_risk.RISK_CLASSES:
65
+ support = sum(1 for c in self.cases if c["expect"] == klass)
66
+ self.assertGreaterEqual(support, 10, f"class {klass} under-represented")
67
+
68
+ def test_full_corpus_accuracy(self):
69
+ """Every labeled case classifies to its expected class. This is the
70
+ regression gate; the held-out calibration numbers live in
71
+ docs/performance/automode-risk-calibration.md."""
72
+ misses = []
73
+ for case in self.cases:
74
+ got = tool_risk.classify_tool_call(case["tool"], case["input"]).risk_class
75
+ if got != case["expect"]:
76
+ misses.append(f"{case['id']}: expected {case['expect']}, got {got}")
77
+ self.assertEqual(misses, [], "corpus misclassifications:\n" + "\n".join(misses))
78
+
79
+ def test_signals_format_auditable(self):
80
+ """Every signal is 'name:class+weight' so each score point is
81
+ attributable to a named rule."""
82
+ import re
83
+ pat = re.compile(r"^[a-z0-9-]+:(destructive|write|execute|read|network)\+\d+$")
84
+ for case in self.cases:
85
+ a = tool_risk.classify_tool_call(case["tool"], case["input"])
86
+ for sig in a.signals:
87
+ self.assertRegex(sig, pat, f"{case['id']}: malformed signal {sig!r}")
88
+
89
+
90
+ class DestructiveEvasionTest(unittest.TestCase):
91
+ """The evasion shapes the enforcers already know must not slip the scorer."""
92
+
93
+ def test_launcher_prefixes(self):
94
+ for cmd in ("sudo rm -rf /data", "nohup rm -rf /data &",
95
+ "timeout 10 rm -rf /data", "env X=1 rm -rf /data",
96
+ "nice -n 19 rm -rf /data"):
97
+ a = _classify(cmd)
98
+ self.assertEqual(a.risk_class, "destructive", cmd)
99
+ self.assertIn("rm-recursive", _signal_names(a), cmd)
100
+
101
+ def test_chained_commands(self):
102
+ a = _classify("cd /tmp/work && rm -rf *")
103
+ self.assertEqual(a.risk_class, "destructive")
104
+ a = _classify("echo ok; rm -rf ./src | cat")
105
+ self.assertEqual(a.risk_class, "destructive")
106
+
107
+ def test_shell_wrap_unwrapping(self):
108
+ for cmd in ("bash -c 'rm -rf ~/projects/x'", 'sh -c "git reset --hard"',
109
+ "eval 'rm -rf ./store'"):
110
+ a = _classify(cmd)
111
+ self.assertEqual(a.risk_class, "destructive", cmd)
112
+
113
+ def test_path_variants_and_self_targets(self):
114
+ for cmd in ("rm -rf .uap/", "rm -rf ./.uap", "rm -rf .git"):
115
+ a = _classify(cmd)
116
+ self.assertEqual(a.risk_class, "destructive", cmd)
117
+ self.assertEqual(a.risk_score, 5, cmd)
118
+ self.assertIn("protected-target", _signal_names(a), cmd)
119
+
120
+ def test_interpreter_mediated_delete(self):
121
+ a = _classify("python3 -c \"import shutil; shutil.rmtree('/data')\"")
122
+ self.assertEqual(a.risk_class, "destructive")
123
+ self.assertIn("interpreter-destructive", _signal_names(a))
124
+
125
+ def test_hard_destructive_scores_five(self):
126
+ for cmd in ("rm -rf /data", "git push --force origin main",
127
+ "git reset --hard", "dd if=/dev/zero of=/dev/sda",
128
+ "mkfs.ext4 /dev/sdb1"):
129
+ self.assertEqual(_classify(cmd).risk_score, 5, cmd)
130
+
131
+
132
+ class BenignLookalikeTest(unittest.TestCase):
133
+ """Lookalikes that must NOT classify destructive (advisory precision keeps
134
+ the signal trustworthy; the fail-closed doctrine only demands destructive
135
+ RECALL, it does not license flagging everything)."""
136
+
137
+ def test_ls_rf_is_read(self):
138
+ self.assertEqual(_classify("ls -rf").risk_class, "read")
139
+
140
+ def test_force_with_lease_is_network(self):
141
+ a = _classify("git push --force-with-lease origin docs-branch")
142
+ self.assertEqual(a.risk_class, "network")
143
+ self.assertIn("git-push-lease", _signal_names(a))
144
+
145
+ def test_hard_force_beats_lease_regardless_of_order(self):
146
+ # git ignores the lease when a plain --force is present, so a
147
+ # co-occurring hard force is destructive in EITHER argument order.
148
+ for cmd in ("git push --force --force-with-lease origin main",
149
+ "git push --force-with-lease --force origin main",
150
+ "git push -f --force-with-lease origin main"):
151
+ a = _classify(cmd)
152
+ self.assertEqual(a.risk_class, "destructive", cmd)
153
+ self.assertIn("git-push-force", _signal_names(a), cmd)
154
+
155
+ def test_no_force_with_lease_is_an_ordinary_push(self):
156
+ a = _classify("git push --no-force-with-lease origin main")
157
+ self.assertEqual(a.risk_class, "network")
158
+ self.assertIn("git-push", _signal_names(a))
159
+
160
+ def test_rm_long_force_is_not_recursive(self):
161
+ a = _classify("rm --force ./important.log")
162
+ self.assertEqual(a.risk_class, "destructive")
163
+ self.assertIn("rm-file", _signal_names(a))
164
+ self.assertNotIn("rm-recursive", _signal_names(a))
165
+ self.assertEqual(a.risk_score, 4)
166
+ a = _classify("rm --recursive ./data")
167
+ self.assertIn("rm-recursive", _signal_names(a))
168
+
169
+ def test_quoted_redirect_target_survives(self):
170
+ a = _classify('echo data > "out file.txt"')
171
+ self.assertEqual(a.risk_class, "write")
172
+ self.assertIn("file-redirect", _signal_names(a))
173
+ a = _classify('echo x > ".uap/evidence/y.json"')
174
+ self.assertEqual(a.risk_class, "write")
175
+ self.assertIn("protected-redirect", _signal_names(a))
176
+
177
+ def test_scratch_rm_is_write(self):
178
+ for cmd in ("rm -rf ./node_modules/.cache", "rm -rf /tmp/build-scratch",
179
+ "rm -rf dist"):
180
+ a = _classify(cmd)
181
+ self.assertEqual(a.risk_class, "write", cmd)
182
+ self.assertIn("rm-scratch-target", _signal_names(a), cmd)
183
+
184
+ def test_quoted_destructive_prose_is_not_destructive(self):
185
+ self.assertEqual(_classify('echo "rm -rf /" > notes.txt').risk_class, "write")
186
+ self.assertEqual(_classify('grep -r "rm -rf" docs/').risk_class, "read")
187
+
188
+ def test_conditional_verbs_without_write_flag_are_reads(self):
189
+ # Enforcer parity: sed without -i and find without -delete/-exec are
190
+ # stdout filters, not mutations.
191
+ self.assertEqual(_classify("sed -n '1,20p' file.py").risk_class, "read")
192
+ self.assertEqual(_classify("find . -name '*.py'").risk_class, "read")
193
+
194
+ def test_mixed_scratch_and_real_target_stays_destructive(self):
195
+ a = _classify("rm -rf ./node_modules ./src")
196
+ self.assertEqual(a.risk_class, "destructive")
197
+
198
+
199
+ class EnforcerParityDriftGuardTest(unittest.TestCase):
200
+ """tool_risk deliberately MIRRORS enforcement_self_protect's evasion
201
+ handling (launcher step-over, find-mutating flags, sed -i conditional).
202
+ This test makes copy-divergence a CI failure instead of silent drift:
203
+ if the enforcer's semantics change, these assertions fail until the
204
+ scorer is re-aligned (or the divergence is documented here)."""
205
+
206
+ @classmethod
207
+ def setUpClass(cls):
208
+ cls.esp = _load(
209
+ "enforcement_self_protect",
210
+ Path(__file__).resolve().parents[3]
211
+ / "src" / "policies" / "enforcers" / "enforcement_self_protect.py",
212
+ )
213
+
214
+ def test_launcher_sets_agree(self):
215
+ # The enforcer's launchers must ALL be stepped over by the scorer.
216
+ # The scorer's one addition is "env": the enforcer handles env via
217
+ # its _WRAPPERS path instead of the launcher list — a documented,
218
+ # asserted divergence, not drift.
219
+ enforcer = set(self.esp._LAUNCHERS)
220
+ scorer = set(tool_risk._LAUNCHERS)
221
+ self.assertLessEqual(enforcer, scorer)
222
+ self.assertEqual(scorer - enforcer, {"env"})
223
+
224
+ def test_find_mutating_semantics_agree(self):
225
+ batteries = [
226
+ (["-delete"], True),
227
+ (["-exec", "rm", "{}", "+"], True),
228
+ (["-execdir", "true", ";"], True),
229
+ (["-ok", "rm", "{}", ";"], True),
230
+ (["-okdir", "true", ";"], True),
231
+ (["-fprint", "/tmp/x"], True),
232
+ (["-fprint0", "/tmp/x"], True),
233
+ (["-fprintf", "/tmp/x", "%p"], True),
234
+ (["-fls", "/tmp/x"], True),
235
+ (["-name", "*.py"], False),
236
+ (["-type", "f", "-mtime", "+7"], False),
237
+ ]
238
+ for tokens, enforcer_writes in batteries:
239
+ self.assertEqual(self.esp._find_writes(tokens), enforcer_writes,
240
+ f"enforcer fixture wrong for {tokens}")
241
+ a = _classify("find /x " + " ".join(tokens))
242
+ scorer_writes = a.risk_class == "destructive"
243
+ self.assertEqual(scorer_writes, enforcer_writes,
244
+ f"scorer/enforcer disagree on find {' '.join(tokens)}")
245
+
246
+ def test_sed_conditional_semantics_agree(self):
247
+ batteries = [
248
+ (["-i", "s/a/b/", "f"], True),
249
+ (["-i.bak", "s/a/b/", "f"], True),
250
+ (["-ni", "-E", "s/a/b/", "f"], True),
251
+ (["-Ei", "s/a/b/", "f"], True),
252
+ (["--in-place", "s/a/b/", "f"], True),
253
+ (["--in-place=.bak", "s/a/b/", "f"], True),
254
+ (["-n", "1,5p", "f"], False),
255
+ (["-e", "s/a/b/", "f"], False),
256
+ (["s/a/b/", "f"], False),
257
+ ]
258
+ for tokens, enforcer_writes in batteries:
259
+ self.assertEqual(self.esp._sed_writes(tokens), enforcer_writes,
260
+ f"enforcer fixture wrong for {tokens}")
261
+ a = _classify("sed " + " ".join(tokens))
262
+ scorer_writes = a.risk_class == "write"
263
+ self.assertEqual(scorer_writes, enforcer_writes,
264
+ f"scorer/enforcer disagree on sed {' '.join(tokens)}")
265
+
266
+
267
+ class ClassSemanticsTest(unittest.TestCase):
268
+ def test_read_write_execute_network_representatives(self):
269
+ self.assertEqual(_classify("cat README.md").risk_class, "read")
270
+ self.assertEqual(_classify("cp a b").risk_class, "write")
271
+ self.assertEqual(_classify("pytest -q").risk_class, "execute")
272
+ self.assertEqual(_classify("curl https://example.com").risk_class, "network")
273
+
274
+ def test_tool_name_routing(self):
275
+ self.assertEqual(
276
+ tool_risk.classify_tool_call("Read", {"file_path": "x"}).risk_class, "read")
277
+ self.assertEqual(
278
+ tool_risk.classify_tool_call("Edit", {"file_path": "x"}).risk_class, "write")
279
+ self.assertEqual(
280
+ tool_risk.classify_tool_call("WebFetch", {"url": "https://x"}).risk_class,
281
+ "network")
282
+
283
+ def test_assessment_shape_and_bounds(self):
284
+ a = _classify("ls")
285
+ self.assertIn(a.risk_class, tool_risk.RISK_CLASSES)
286
+ self.assertGreaterEqual(a.risk_score, 1)
287
+ self.assertLessEqual(a.risk_score, 5)
288
+ self.assertIsInstance(a.signals, list)
289
+ self.assertGreaterEqual(a.latency_ms, 0.0)
290
+
291
+ def test_malformed_input_never_raises(self):
292
+ for bad in (None, "rm -rf /", 42, ["rm"]):
293
+ a = tool_risk.classify_tool_call("Bash", bad)
294
+ self.assertIn(a.risk_class, tool_risk.RISK_CLASSES)
295
+ a = tool_risk.classify_tool_call(None, None)
296
+ self.assertEqual(a.risk_class, "execute") # unknown tool: pessimistic floor
297
+ a = _classify("")
298
+ self.assertEqual(a.risk_score, 1)
299
+
300
+ def test_purity_same_input_same_output(self):
301
+ a1 = _classify("sudo rm -rf ./data && git push --force")
302
+ a2 = _classify("sudo rm -rf ./data && git push --force")
303
+ self.assertEqual(a1.risk_class, a2.risk_class)
304
+ self.assertEqual(a1.risk_score, a2.risk_score)
305
+ self.assertEqual(a1.signals, a2.signals)
306
+
307
+ def test_fd_duplication_is_not_a_file_write(self):
308
+ # `2>&1` duplicates a descriptor; only the quote-blanked redirect scan
309
+ # stands between it and a false write signal.
310
+ a = _classify("pytest -q 2>&1 | tail -5")
311
+ self.assertNotIn("file-redirect", _signal_names(a))
312
+
313
+
314
+ class CalibrationTest(unittest.TestCase):
315
+ @classmethod
316
+ def setUpClass(cls):
317
+ cls.cases = calibrate.load_corpus(_CORPUS)
318
+
319
+ def test_split_deterministic(self):
320
+ train1, held1 = calibrate.split_cases(self.cases)
321
+ train2, held2 = calibrate.split_cases(self.cases)
322
+ self.assertEqual([c["id"] for c in held1], [c["id"] for c in held2])
323
+ self.assertEqual([c["id"] for c in train1], [c["id"] for c in train2])
324
+ self.assertTrue(held1, "held-out split must not be empty")
325
+
326
+ def test_report_regeneration_determinism(self):
327
+ """Same seed -> same numbers (the calibration doc commits to them)."""
328
+ _t1, held1 = calibrate.split_cases(self.cases)
329
+ _t2, held2 = calibrate.split_cases(self.cases)
330
+ self.assertEqual(calibrate.evaluate(held1), calibrate.evaluate(held2))
331
+
332
+ def test_held_out_destructive_recall_is_one(self):
333
+ """The fail-closed gate: a missed destructive call is the unacceptable
334
+ error. False positives are advisory-only cost."""
335
+ _train, held = calibrate.split_cases(self.cases)
336
+ metrics = calibrate.evaluate(held)
337
+ self.assertEqual(metrics["per_class"]["destructive"]["recall"], 1.0)
338
+ self.assertGreater(metrics["per_class"]["destructive"]["support"], 0)
339
+
340
+ def test_check_mode_passes(self):
341
+ self.assertEqual(calibrate.main(["--check", "--corpus", str(_CORPUS)]), 0)
342
+
343
+
344
+ class LatencyBenchTest(unittest.TestCase):
345
+ def test_p95_under_5ms_over_10k_calls(self):
346
+ """The 50ms AutoMode budget includes future model calls; the heuristic
347
+ floor must be far under it (pure regex/dict work, microseconds)."""
348
+ cases = calibrate.load_corpus(_CORPUS)
349
+ n = 10_000
350
+ latencies = []
351
+ for i in range(n):
352
+ case = cases[i % len(cases)]
353
+ start = time.perf_counter()
354
+ tool_risk.classify_tool_call(case["tool"], case["input"])
355
+ latencies.append((time.perf_counter() - start) * 1000.0)
356
+ latencies.sort()
357
+ p95 = latencies[int(0.95 * n)]
358
+ self.assertLess(p95, 5.0, f"p95 latency {p95:.3f}ms exceeds 5ms budget")
359
+
360
+
361
+ class ProxyWiringTest(unittest.TestCase):
362
+ """The proxy hook observes and records ONLY: never blocks, never mutates
363
+ the stream, degrades to pass-through + a warning on classifier error."""
364
+
365
+ @classmethod
366
+ def setUpClass(cls):
367
+ cls.proxy = _load("anthropic_proxy_automode", _SCRIPTS / "anthropic_proxy.py")
368
+
369
+ def test_env_hatch_off_disables(self):
370
+ proxy = self.proxy
371
+ calls = []
372
+ with mock.patch.object(proxy, "PROXY_AUTOMODE_RISK", False), \
373
+ mock.patch.object(proxy, "_automode_classify",
374
+ lambda *a: calls.append(a) or None):
375
+ resp = {"content": [{"type": "tool_use", "id": "t1", "name": "Bash",
376
+ "input": {"command": "ls"}}]}
377
+ self.assertEqual(proxy._automode_risk_scan_response(resp, {}), [])
378
+ self.assertEqual(calls, [], "classifier must not run when hatched off")
379
+
380
+ def test_env_var_off_at_import(self):
381
+ with mock.patch.dict(os.environ, {"PROXY_AUTOMODE_RISK": "off"}):
382
+ fresh = _load("anthropic_proxy_automode_off",
383
+ _SCRIPTS / "anthropic_proxy.py")
384
+ self.assertIs(fresh.PROXY_AUTOMODE_RISK, False)
385
+
386
+ def test_classifier_exception_passes_stream_through(self):
387
+ """On classifier error: log a warning, skip the call, response dict
388
+ byte-identical. The deterministic enforcers remain the floor."""
389
+ proxy = self.proxy
390
+
391
+ def _boom(name, inp):
392
+ raise RuntimeError("classifier exploded")
393
+
394
+ resp = {"content": [
395
+ {"type": "text", "text": "removing now"},
396
+ {"type": "tool_use", "id": "t1", "name": "Bash",
397
+ "input": {"command": "rm -rf ./src"}},
398
+ ]}
399
+ before = copy.deepcopy(resp)
400
+ with mock.patch.object(proxy, "PROXY_AUTOMODE_RISK", True), \
401
+ mock.patch.object(proxy, "_AUTOMODE_RISK_OK", True), \
402
+ mock.patch.object(proxy, "_automode_classify", _boom):
403
+ with self.assertLogs(proxy.logger, level="WARNING") as logs:
404
+ results = proxy._automode_risk_scan_response(resp, {})
405
+ self.assertEqual(results, [])
406
+ self.assertEqual(resp, before, "stream must pass through unmodified")
407
+ self.assertTrue(any("AUTOMODE RISK" in line for line in logs.output))
408
+
409
+ def test_scan_classifies_and_logs_at_debug(self):
410
+ proxy = self.proxy
411
+ resp = {"content": [{"type": "tool_use", "id": "t1", "name": "Bash",
412
+ "input": {"command": "rm -rf ./src"}}]}
413
+ with mock.patch.object(proxy, "PROXY_AUTOMODE_RISK", True), \
414
+ mock.patch.object(proxy, "_AUTOMODE_RISK_OK", True):
415
+ with self.assertLogs(proxy.logger, level="DEBUG") as logs:
416
+ results = proxy._automode_risk_scan_response(resp, {})
417
+ self.assertEqual(len(results), 1)
418
+ _tid, _name, assessment = results[0]
419
+ self.assertEqual(assessment.risk_class, "destructive")
420
+ self.assertTrue(any("AUTOMODE RISK" in line and "destructive" in line
421
+ for line in logs.output))
422
+
423
+ def test_high_risk_calls_record_telemetry(self):
424
+ proxy = self.proxy
425
+ recorded = []
426
+ resp = {"content": [{"type": "tool_use", "id": "t1", "name": "Bash",
427
+ "input": {"command": "git push --force origin main"}}]}
428
+ with mock.patch.object(proxy, "PROXY_AUTOMODE_RISK", True), \
429
+ mock.patch.object(proxy, "_AUTOMODE_RISK_OK", True), \
430
+ mock.patch.object(project_telemetry, "derive_project_dir",
431
+ return_value="/tmp/x"), \
432
+ mock.patch.object(project_telemetry, "record_risk_event",
433
+ lambda *a: recorded.append(a) or True):
434
+ proxy._automode_risk_scan_response(resp, {"messages": []})
435
+ self.assertEqual(len(recorded), 1)
436
+ self.assertEqual(recorded[0][2], "destructive")
437
+
438
+ def test_low_risk_calls_skip_telemetry(self):
439
+ proxy = self.proxy
440
+ recorded = []
441
+ resp = {"content": [{"type": "tool_use", "id": "t1", "name": "Read",
442
+ "input": {"file_path": "README.md"}}]}
443
+ with mock.patch.object(proxy, "PROXY_AUTOMODE_RISK", True), \
444
+ mock.patch.object(proxy, "_AUTOMODE_RISK_OK", True), \
445
+ mock.patch.object(project_telemetry, "derive_project_dir",
446
+ return_value="/tmp/x"), \
447
+ mock.patch.object(project_telemetry, "record_risk_event",
448
+ lambda *a: recorded.append(a) or True):
449
+ proxy._automode_risk_scan_response(resp, {"messages": []})
450
+ self.assertEqual(recorded, [], "routine reads must not flood the feed")
451
+
452
+ def test_tool_args_dict_tolerates_truncation(self):
453
+ proxy = self.proxy
454
+ self.assertEqual(proxy._tool_args_dict('{"command": "ls"}'),
455
+ {"command": "ls"})
456
+ self.assertEqual(proxy._tool_args_dict('{"command": "ls'), {})
457
+ self.assertEqual(proxy._tool_args_dict(""), {})
458
+
459
+ def test_record_risk_event_writes_dashboard_row(self):
460
+ with tempfile.TemporaryDirectory() as tmp:
461
+ os.makedirs(os.path.join(tmp, "agents", "data", "memory"))
462
+ ok = project_telemetry.record_risk_event(
463
+ tmp, "Bash", "destructive", 5, ["rm-recursive:destructive+5"], 0.01)
464
+ self.assertTrue(ok)
465
+ import sqlite3
466
+ conn = sqlite3.connect(
467
+ os.path.join(tmp, "agents", "data", "memory", "telemetry.db"))
468
+ try:
469
+ rows = conn.execute(
470
+ "SELECT category, type, severity, title FROM dashboard_events"
471
+ ).fetchall()
472
+ finally:
473
+ conn.close()
474
+ self.assertEqual(len(rows), 1)
475
+ self.assertEqual(rows[0][0:3], ("automode", "risk.classified", "warning"))
476
+
477
+ def test_record_risk_event_never_raises(self):
478
+ self.assertFalse(project_telemetry.record_risk_event(
479
+ "/nonexistent/no-such-project", "Bash", "destructive", 5, []))
480
+ self.assertFalse(project_telemetry.record_risk_event(
481
+ "", "Bash", "destructive", 5, []))
482
+
483
+ def test_tool_name_sanitized_at_sinks(self):
484
+ """Model-controlled tool names must not forge log lines or dashboard
485
+ rows: control chars become '?' and the name is capped at 64 chars."""
486
+ proxy = self.proxy
487
+ forged = "Bash\nFORGED-LOG-LINE\x00 " + "x" * 100
488
+ resp = {"content": [{"type": "tool_use", "id": "t1", "name": forged,
489
+ "input": {"command": "git push --force origin m"}}]}
490
+ recorded = []
491
+ with mock.patch.object(proxy, "PROXY_AUTOMODE_RISK", True), \
492
+ mock.patch.object(proxy, "_AUTOMODE_RISK_OK", True), \
493
+ mock.patch.object(project_telemetry, "derive_project_dir",
494
+ return_value="/tmp/x"), \
495
+ mock.patch.object(project_telemetry, "record_risk_event",
496
+ lambda *a: recorded.append(a) or True):
497
+ with self.assertLogs(proxy.logger, level="DEBUG") as logs:
498
+ proxy._automode_risk_scan_response(resp, {"messages": []})
499
+ self.assertEqual(len(recorded), 1)
500
+ logged_name = recorded[0][1]
501
+ self.assertNotIn("\n", logged_name)
502
+ self.assertNotIn("\x00", logged_name)
503
+ self.assertLessEqual(len(logged_name), 64)
504
+ self.assertTrue(any("Bash?FORGED-LOG-LINE?" in line
505
+ for line in logs.output), logs.output)
506
+
507
+ def test_startup_warning_when_classifier_import_fails(self):
508
+ """A default-on feature must never be silently dead: when the
509
+ tool_risk import fails, the proxy logs ONE warning at startup
510
+ (import time), naming the disabled capability and the floor."""
511
+ with mock.patch.dict(sys.modules, {"tool_risk": None}), \
512
+ self.assertLogs("uap.anthropic_proxy", level="WARNING") as logs:
513
+ fresh = _load("anthropic_proxy_automode_broken",
514
+ _SCRIPTS / "anthropic_proxy.py")
515
+ self.assertIs(fresh._AUTOMODE_RISK_OK, False)
516
+ self.assertTrue(any("tool_risk import failed" in line
517
+ for line in logs.output), logs.output)
518
+
519
+
520
+ if __name__ == "__main__":
521
+ unittest.main()