@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.
- package/README.md +7 -6
- package/config/capacity-policy.json +32 -0
- package/config/classify-thresholds.json +9 -0
- package/config/supervise-policy.json +13 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +87 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +67 -0
- package/dist/capacity/policy.d.ts.map +1 -0
- package/dist/capacity/policy.js +133 -0
- package/dist/capacity/policy.js.map +1 -0
- package/dist/capacity/probe.d.ts +23 -0
- package/dist/capacity/probe.d.ts.map +1 -0
- package/dist/capacity/probe.js +93 -0
- package/dist/capacity/probe.js.map +1 -0
- package/dist/classify/baseline.d.ts +24 -0
- package/dist/classify/baseline.d.ts.map +1 -0
- package/dist/classify/baseline.js +140 -0
- package/dist/classify/baseline.js.map +1 -0
- package/dist/classify/index.d.ts +30 -0
- package/dist/classify/index.d.ts.map +1 -0
- package/dist/classify/index.js +84 -0
- package/dist/classify/index.js.map +1 -0
- package/dist/classify/shadow.d.ts +34 -0
- package/dist/classify/shadow.d.ts.map +1 -0
- package/dist/classify/shadow.js +33 -0
- package/dist/classify/shadow.js.map +1 -0
- package/dist/classify/types.d.ts +64 -0
- package/dist/classify/types.d.ts.map +1 -0
- package/dist/classify/types.js +37 -0
- package/dist/classify/types.js.map +1 -0
- package/dist/cli/classify.d.ts +24 -0
- package/dist/cli/classify.d.ts.map +1 -0
- package/dist/cli/classify.js +94 -0
- package/dist/cli/classify.js.map +1 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +26 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/doctor.d.ts +9 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +49 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/review.d.ts +12 -0
- package/dist/cli/review.d.ts.map +1 -0
- package/dist/cli/review.js +118 -0
- package/dist/cli/review.js.map +1 -0
- package/dist/cli/supervise.d.ts +8 -0
- package/dist/cli/supervise.d.ts.map +1 -0
- package/dist/cli/supervise.js +76 -0
- package/dist/cli/supervise.js.map +1 -0
- package/dist/delivery/convergence-loop.d.ts +8 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +1 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/delivery-result.d.ts.map +1 -1
- package/dist/delivery/delivery-result.js +5 -0
- package/dist/delivery/delivery-result.js.map +1 -1
- package/dist/delivery/gate-evidence.d.ts +143 -0
- package/dist/delivery/gate-evidence.d.ts.map +1 -0
- package/dist/delivery/gate-evidence.js +239 -0
- package/dist/delivery/gate-evidence.js.map +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/index.js.map +1 -1
- package/dist/evals/routing-corpus.d.ts +35 -0
- package/dist/evals/routing-corpus.d.ts.map +1 -0
- package/dist/evals/routing-corpus.js +142 -0
- package/dist/evals/routing-corpus.js.map +1 -0
- package/dist/evals/routing-eval.d.ts +104 -0
- package/dist/evals/routing-eval.d.ts.map +1 -0
- package/dist/evals/routing-eval.js +231 -0
- package/dist/evals/routing-eval.js.map +1 -0
- package/dist/evals/tfidf.d.ts +35 -0
- package/dist/evals/tfidf.d.ts.map +1 -0
- package/dist/evals/tfidf.js +132 -0
- package/dist/evals/tfidf.js.map +1 -0
- package/dist/review/prepass.d.ts +88 -0
- package/dist/review/prepass.d.ts.map +1 -0
- package/dist/review/prepass.js +428 -0
- package/dist/review/prepass.js.map +1 -0
- package/dist/review/visual-captures.d.ts +60 -0
- package/dist/review/visual-captures.d.ts.map +1 -0
- package/dist/review/visual-captures.js +222 -0
- package/dist/review/visual-captures.js.map +1 -0
- package/dist/supervise/assess.d.ts +73 -0
- package/dist/supervise/assess.d.ts.map +1 -0
- package/dist/supervise/assess.js +215 -0
- package/dist/supervise/assess.js.map +1 -0
- package/dist/supervise/config.d.ts +13 -0
- package/dist/supervise/config.d.ts.map +1 -0
- package/dist/supervise/config.js +81 -0
- package/dist/supervise/config.js.map +1 -0
- package/dist/supervise/loop.d.ts +78 -0
- package/dist/supervise/loop.d.ts.map +1 -0
- package/dist/supervise/loop.js +311 -0
- package/dist/supervise/loop.js.map +1 -0
- package/dist/supervise/observe.d.ts +31 -0
- package/dist/supervise/observe.d.ts.map +1 -0
- package/dist/supervise/observe.js +172 -0
- package/dist/supervise/observe.js.map +1 -0
- package/dist/supervise/policy.d.ts +16 -0
- package/dist/supervise/policy.d.ts.map +1 -0
- package/dist/supervise/policy.js +147 -0
- package/dist/supervise/policy.js.map +1 -0
- package/dist/supervise/types.d.ts +109 -0
- package/dist/supervise/types.d.ts.map +1 -0
- package/dist/supervise/types.js +11 -0
- package/dist/supervise/types.js.map +1 -0
- package/docs/INDEX.md +15 -4
- package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
- package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
- package/docs/guides/CAPACITY_POLICY.md +99 -0
- package/docs/performance/automode-risk-calibration.md +72 -0
- package/docs/performance/reliability-ladder.md +78 -0
- package/docs/plans/system1-uplift-2026-09-18.md +190 -0
- package/docs/reference/automode-risk.md +134 -0
- package/docs/reference/evidence-bound-ship.md +146 -0
- package/docs/reference/review-prepass.md +77 -0
- package/docs/reference/routing-evals.md +71 -0
- package/docs/reference/semantic-supervisor.md +140 -0
- package/docs/reference/system1-classifier.md +103 -0
- package/docs/reference/visual-captures.md +113 -0
- package/package.json +5 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +25 -2
- package/src/policies/enforcers/evidence_bound_ship.py +257 -0
- package/src/policies/enforcers/expert_review_required.py +120 -0
- package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
- package/src/policies/schemas/policies/visual-verification.md +8 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +782 -19
- package/tools/agents/scripts/project_telemetry.py +52 -0
- package/tools/agents/scripts/tool_risk.py +789 -0
- package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
- package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
- package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
- package/tools/agents/tests/test_gate_evidence.py +56 -0
- package/tools/agents/tests/test_tool_risk.py +521 -0
- 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()
|