@miller-tech/uap 1.185.0 → 1.186.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/policy.d.ts.map +1 -1
- package/dist/cli/policy.js +56 -0
- package/dist/cli/policy.js.map +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +5 -1
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/integrity/enforcer-manifest.d.ts +46 -0
- package/dist/integrity/enforcer-manifest.d.ts.map +1 -0
- package/dist/integrity/enforcer-manifest.js +145 -0
- package/dist/integrity/enforcer-manifest.js.map +1 -0
- package/dist/policies/policy-tools.d.ts.map +1 -1
- package/dist/policies/policy-tools.js +9 -0
- package/dist/policies/policy-tools.js.map +1 -1
- package/dist/types/config.d.ts +93 -93
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +12 -3
- package/dist/types/config.js.map +1 -1
- package/docs/getting-started/CONFIGURATION.md +1 -1
- package/docs/reference/CONFIGURATION.md +1 -1
- package/docs/reference/CONFIGURATION_REFERENCE.md +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +149 -24
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +99 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +52 -9
- package/tools/agents/tests/test_gate_failclosed_indirection.py +383 -0
- package/tools/agents/tests/test_gate_integrity.py +180 -0
- package/tools/agents/tests/test_models_context_window.py +84 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The enforcement surface must not be reachable by moving the target around.
|
|
3
|
+
|
|
4
|
+
Two holes, both found by breaking the guard for real rather than reading it:
|
|
5
|
+
|
|
6
|
+
FINDING 1 — the fail-closed net was blind to Bash.
|
|
7
|
+
SEC_SENSITIVE in uap-policy-gate.sh scanned only the file_path argument, so
|
|
8
|
+
for every Bash call it was 0 unless the command set a bypass var, and the
|
|
9
|
+
"self-protect errored => fail closed" branch never fired for shell ops.
|
|
10
|
+
Deleting .policy-tools/_common.py broke all 29 materialized enforcers at
|
|
11
|
+
import (each does `from _common import ...`) and the next append into
|
|
12
|
+
.uap/evidence/ was ALLOWED.
|
|
13
|
+
|
|
14
|
+
FINDING 2 — argument indirection walked past the text scan.
|
|
15
|
+
A direct removal naming the path was blocked; the same removal taking its
|
|
16
|
+
targets from a file or a pipe was not. That is how _common.py was deleted.
|
|
17
|
+
|
|
18
|
+
The first fix for Finding 2 answered the WRONG QUESTION — "is a protected path
|
|
19
|
+
mentioned anywhere in this command" — and a parallel review caught it blocking
|
|
20
|
+
six ordinary commands (`cp package.json /tmp/x-$(date +%s).json` trips because
|
|
21
|
+
package.json NAMES src/policies/...) while five trivial variants still walked
|
|
22
|
+
through. It now answers "will a protected path be an ARGUMENT to something
|
|
23
|
+
destructive", resolving the argument set where that is knowable and refusing to
|
|
24
|
+
guess where it is not.
|
|
25
|
+
|
|
26
|
+
ACCEPTED RESIDUALS, asserted below so they are decisions and not surprises:
|
|
27
|
+
- a path held in shell state (`P=.policy-tools; rm $P/x`) is unreachable by
|
|
28
|
+
any scan of command TEXT;
|
|
29
|
+
- `grep ... | xargs sed` is allowed, because grep's output is not knowable
|
|
30
|
+
here and guessing from the pattern text is what broke ordinary refactors.
|
|
31
|
+
Both are covered by the gate's fail-closed and the _common.py self-heal.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
import unittest
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
ROOT = Path(__file__).resolve().parents[3]
|
|
43
|
+
ENFORCERS = ROOT / "src" / "policies" / "enforcers"
|
|
44
|
+
SELF_PROTECT = ENFORCERS / "enforcement_self_protect.py"
|
|
45
|
+
GATE = ROOT / ".claude" / "hooks" / "uap-policy-gate.sh"
|
|
46
|
+
GATE_COPIES = [
|
|
47
|
+
ROOT / ".claude" / "hooks" / "uap-policy-gate.sh",
|
|
48
|
+
ROOT / ".factory" / "hooks" / "uap-policy-gate.sh",
|
|
49
|
+
ROOT / ".omp" / "hooks" / "uap-policy-gate.sh",
|
|
50
|
+
ROOT / "templates" / "hooks" / "uap-policy-gate.sh",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_enforcer(op, args, root):
|
|
55
|
+
env = dict(os.environ)
|
|
56
|
+
env["UAP_REPO_ROOT"] = str(root)
|
|
57
|
+
env["UAP_WORKTREE_ROOT"] = str(root)
|
|
58
|
+
env["PYTHONPATH"] = str(ENFORCERS)
|
|
59
|
+
env.pop("UAP_SELF_PROTECT_OFF", None)
|
|
60
|
+
p = subprocess.run(
|
|
61
|
+
[sys.executable, str(SELF_PROTECT), "--operation", op, "--args", json.dumps(args)],
|
|
62
|
+
capture_output=True, text=True, env=env, cwd=str(root), timeout=30,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(p.stdout)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
return {"allowed": True, "reason": f"<unparseable {p.stdout!r} {p.stderr!r}>"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Sandbox(unittest.TestCase):
|
|
71
|
+
"""A repo-shaped tmpdir with the argument-source fixtures the cases use."""
|
|
72
|
+
|
|
73
|
+
def setUp(self):
|
|
74
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
75
|
+
self.root = Path(self._tmp.name)
|
|
76
|
+
(self.root / ".policy-tools").mkdir()
|
|
77
|
+
(self.root / "LIST").write_text(".policy-tools/_common.py\n")
|
|
78
|
+
# One exempt line used to make every other line invisible.
|
|
79
|
+
(self.root / "POISONED").write_text(
|
|
80
|
+
".policy-tools/_common.py\npolicies/waivers/ok\n")
|
|
81
|
+
(self.root / "TARGET").write_text(".uap/evidence/reads.log\n")
|
|
82
|
+
(self.root / "HARMLESS").write_text("/tmp/a.log\n/tmp/b.log\n")
|
|
83
|
+
# Larger than the read cap: the cap must bound the READ, not skip the
|
|
84
|
+
# file, or padding a list is a one-line bypass.
|
|
85
|
+
(self.root / "PADDED").write_text(
|
|
86
|
+
".policy-tools/_common.py\n" + "x" * 200_000)
|
|
87
|
+
# Real repo files whose CONTENTS mention protected paths. Naming one of
|
|
88
|
+
# these in an ordinary command must not block it.
|
|
89
|
+
for name in ("package.json", ".gitignore", "CHANGELOG.md"):
|
|
90
|
+
(self.root / name).write_text("mentions src/policies/ and .policy-tools/\n")
|
|
91
|
+
|
|
92
|
+
def tearDown(self):
|
|
93
|
+
self._tmp.cleanup()
|
|
94
|
+
|
|
95
|
+
def assertBlocked(self, cmd):
|
|
96
|
+
self.assertFalse(run_enforcer("Bash", {"command": cmd}, self.root)["allowed"], cmd)
|
|
97
|
+
|
|
98
|
+
def assertAllowed(self, cmd):
|
|
99
|
+
self.assertTrue(run_enforcer("Bash", {"command": cmd}, self.root)["allowed"], cmd)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestIndirectionIsNotAnEscape(_Sandbox):
|
|
103
|
+
def test_targets_read_from_a_file(self):
|
|
104
|
+
self.assertBlocked("xargs rm -v < LIST")
|
|
105
|
+
self.assertBlocked("cat LIST | xargs -n1 rm -f")
|
|
106
|
+
self.assertBlocked("head -1 LIST | xargs rm")
|
|
107
|
+
self.assertBlocked("rm $(cat LIST)")
|
|
108
|
+
|
|
109
|
+
def test_target_in_a_different_pipeline_stage(self):
|
|
110
|
+
self.assertBlocked("echo .policy-tools/x.py | xargs rm")
|
|
111
|
+
|
|
112
|
+
def test_shell_wrappers_do_not_launder_the_verb(self):
|
|
113
|
+
# `bash -c` was the reviewer's headline miss: not a destructive verb, and
|
|
114
|
+
# the payload never reached the per-segment scan.
|
|
115
|
+
self.assertBlocked('bash -c "rm .policy-tools/_common.py"')
|
|
116
|
+
self.assertBlocked('sh -c "rm .policy-tools/_common.py"')
|
|
117
|
+
self.assertBlocked("env rm .policy-tools/_common.py")
|
|
118
|
+
self.assertBlocked("eval rm .policy-tools/_common.py")
|
|
119
|
+
|
|
120
|
+
def test_xargs_arg_file_flags(self):
|
|
121
|
+
self.assertBlocked("xargs --arg-file=LIST rm")
|
|
122
|
+
self.assertBlocked("xargs -a LIST rm")
|
|
123
|
+
|
|
124
|
+
def test_cd_then_destructive(self):
|
|
125
|
+
# The path and the verb sat in different segments, so neither looked bad.
|
|
126
|
+
self.assertBlocked("cd .policy-tools && rm -f _common.py")
|
|
127
|
+
|
|
128
|
+
def test_redirect_target_from_a_substitution(self):
|
|
129
|
+
# Forging gate evidence, laundered through the indirection this fix is
|
|
130
|
+
# about: `>` is not a verb, so the destructive-intent check must count it.
|
|
131
|
+
self.assertBlocked('echo hi > "$(cat TARGET)"')
|
|
132
|
+
|
|
133
|
+
def test_exempt_path_does_not_dilute_the_rest(self):
|
|
134
|
+
# PROTECTED_EXEMPT was evaluated over the whole blob, so appending one
|
|
135
|
+
# innocuous line to a deletion list disabled the check entirely.
|
|
136
|
+
self.assertBlocked("xargs rm < POISONED")
|
|
137
|
+
self.assertBlocked("rm -rf .policy-tools # policies/waivers")
|
|
138
|
+
|
|
139
|
+
def test_oversized_source_is_still_examined(self):
|
|
140
|
+
# The cap used to SKIP the file, making pad-to-bypass a one-liner.
|
|
141
|
+
self.assertBlocked("xargs rm < PADDED")
|
|
142
|
+
|
|
143
|
+
def test_direct_forms_still_blocked(self):
|
|
144
|
+
self.assertBlocked("rm .policy-tools/_common.py")
|
|
145
|
+
self.assertBlocked("rm -rf src/policies/enforcers")
|
|
146
|
+
self.assertBlocked("echo x >> .uap/evidence/reads.log")
|
|
147
|
+
self.assertBlocked("chmod 000 .policy-tools/x.py")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestOrdinaryWorkIsNotBlocked(_Sandbox):
|
|
151
|
+
"""Over-blocking is the expensive failure: it makes people disable the gate.
|
|
152
|
+
|
|
153
|
+
Every command here was BLOCKED by the first implementation and verified by
|
|
154
|
+
execution before this suite existed.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def test_commands_naming_a_file_whose_contents_mention_protected_paths(self):
|
|
158
|
+
self.assertAllowed("cp package.json /tmp/x-$(date +%s).json")
|
|
159
|
+
self.assertAllowed("cp .gitignore /tmp/g.bak")
|
|
160
|
+
self.assertAllowed("cp CHANGELOG.md /tmp/c.md")
|
|
161
|
+
|
|
162
|
+
def test_prose_that_merely_names_a_removal(self):
|
|
163
|
+
self.assertAllowed('git commit -m "fix: block xargs rm against .policy-tools/"')
|
|
164
|
+
self.assertAllowed('echo "do not rm .policy-tools/x $(date)"')
|
|
165
|
+
|
|
166
|
+
def test_unknowable_producer_is_allowed_not_guessed(self):
|
|
167
|
+
# grep's output cannot be known here. Inferring it from the pattern text
|
|
168
|
+
# blocked ordinary refactors, so this is a deliberate allow.
|
|
169
|
+
self.assertAllowed("grep -rl policies/ docs/ | xargs sed -i s/a/b/")
|
|
170
|
+
self.assertAllowed("find docs -name '*.md' | xargs grep -l .uap.json")
|
|
171
|
+
|
|
172
|
+
def test_everyday_commands(self):
|
|
173
|
+
for cmd in ("rm -rf node_modules", "npm run build", "git status --short",
|
|
174
|
+
"npm test 2>&1 | tee /tmp/t.log", "xargs rm < HARMLESS",
|
|
175
|
+
"xargs rm < MISSING-FILE", "echo 0 > .uap/verify-cadence",
|
|
176
|
+
"cat .uap/pending-deliver.jsonl"):
|
|
177
|
+
self.assertAllowed(cmd)
|
|
178
|
+
|
|
179
|
+
def test_accepted_residual_is_asserted_not_assumed(self):
|
|
180
|
+
# Documented limit: shell state is not resolvable from command text.
|
|
181
|
+
# Asserted so a future change that "fixes" it must do so deliberately.
|
|
182
|
+
self.assertAllowed("rm $TMPDIR/scratch")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestGateSensitivity(unittest.TestCase):
|
|
186
|
+
"""SEC_SENSITIVE decides whether a BROKEN enforcer fails closed."""
|
|
187
|
+
|
|
188
|
+
START = 'SEC_SENSITIVE="$(printf \'%s\' "$ARGS" | TOOL="$TOOL" python3 -c \''
|
|
189
|
+
END = "' 2>/dev/null || echo 1)\""
|
|
190
|
+
|
|
191
|
+
def sensitive(self, args, tool="Bash"):
|
|
192
|
+
text = GATE.read_text()
|
|
193
|
+
i = text.index(self.START) + len(self.START)
|
|
194
|
+
code = text[i:text.index(self.END, i)]
|
|
195
|
+
p = subprocess.run([sys.executable, "-c", code], input=json.dumps(args),
|
|
196
|
+
capture_output=True, text=True, timeout=30,
|
|
197
|
+
env={"TOOL": tool, "PATH": "/usr/bin:/bin"})
|
|
198
|
+
return p.stdout.strip() == "1"
|
|
199
|
+
|
|
200
|
+
def test_shell_ops_touching_the_surface(self):
|
|
201
|
+
for cmd in ("rm .policy-tools/_common.py",
|
|
202
|
+
"echo x >> .uap/evidence/reads.log",
|
|
203
|
+
"cp /dev/null .uap.json",
|
|
204
|
+
"sed -i s/x/y/ src/policies/enforcers/a.py",
|
|
205
|
+
"vi .claude/hooks/uap-policy-gate.sh"):
|
|
206
|
+
self.assertTrue(self.sensitive({"command": cmd}), cmd)
|
|
207
|
+
|
|
208
|
+
def test_directory_and_quoted_forms(self):
|
|
209
|
+
# Markers are slash-terminated, so a plain substring test missed the
|
|
210
|
+
# DIRECTORY forms — the most destructive commands scored 0.
|
|
211
|
+
for cmd in ("rm -rf .policy-tools", "rm -rf src/policies", "rm -rf .uap",
|
|
212
|
+
'rm ".policy-tools/_common.py"', "rm ./.policy-tools/x.py"):
|
|
213
|
+
self.assertTrue(self.sensitive({"command": cmd}), cmd)
|
|
214
|
+
|
|
215
|
+
def test_readonly_commands_do_not_arm_fail_closed(self):
|
|
216
|
+
# Arming these turns `cat .uap.json` into a hard block on any checkout
|
|
217
|
+
# where self-protect is not attached — a state this repo has been in.
|
|
218
|
+
for cmd in ("cat .uap.json", "grep -r foo policies/", "ls -la .policy-tools/"):
|
|
219
|
+
self.assertFalse(self.sensitive({"command": cmd}), cmd)
|
|
220
|
+
|
|
221
|
+
def test_ordinary_shell_work(self):
|
|
222
|
+
for cmd in ("npm run build", "git status --short", "rm -rf node_modules"):
|
|
223
|
+
self.assertFalse(self.sensitive({"command": cmd}), cmd)
|
|
224
|
+
|
|
225
|
+
def test_file_path_coverage_unchanged(self):
|
|
226
|
+
self.assertTrue(self.sensitive({"file_path": "/r/src/policies/enforcers/a.py"}, "Edit"))
|
|
227
|
+
self.assertFalse(self.sensitive({"file_path": "/r/src/cli/memory.ts"}, "Edit"))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TestGateEndToEnd(unittest.TestCase):
|
|
231
|
+
"""Runs the REAL gate script. The previous version grepped for strings — it
|
|
232
|
+
would have passed against a `cp` with its arguments swapped."""
|
|
233
|
+
|
|
234
|
+
PID = "11111111-1111-1111-1111-111111111111"
|
|
235
|
+
|
|
236
|
+
def setUp(self):
|
|
237
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
238
|
+
self.sb = Path(self._tmp.name)
|
|
239
|
+
for d in (".policy-tools", "agents/data/memory", "src/policies/enforcers",
|
|
240
|
+
".uap/evidence", ".claude/hooks"):
|
|
241
|
+
(self.sb / d).mkdir(parents=True, exist_ok=True)
|
|
242
|
+
subprocess.run(["git", "init", "-q"], cwd=self.sb, capture_output=True)
|
|
243
|
+
|
|
244
|
+
helper = (
|
|
245
|
+
"import json, sys\n"
|
|
246
|
+
"def parse_cli():\n"
|
|
247
|
+
" a = sys.argv\n"
|
|
248
|
+
" op = a[a.index('--operation') + 1] if '--operation' in a else ''\n"
|
|
249
|
+
" ar = json.loads(a[a.index('--args') + 1]) if '--args' in a else {}\n"
|
|
250
|
+
" return op, ar\n"
|
|
251
|
+
"def emit(allowed, reason):\n"
|
|
252
|
+
" print(json.dumps({'allowed': bool(allowed), 'reason': reason}))\n"
|
|
253
|
+
" sys.exit(0)\n"
|
|
254
|
+
)
|
|
255
|
+
(self.sb / "src/policies/enforcers/_common.py").write_text(helper)
|
|
256
|
+
(self.sb / ".policy-tools/_common.py").write_text(helper)
|
|
257
|
+
(self.sb / f".policy-tools/{self.PID}_enforcement_self_protect.py").write_text(
|
|
258
|
+
"import sys\n"
|
|
259
|
+
"from pathlib import Path\n"
|
|
260
|
+
"sys.path.insert(0, str(Path(__file__).parent))\n"
|
|
261
|
+
"from _common import emit, parse_cli\n" # dies if the helper is gone
|
|
262
|
+
"op, args = parse_cli()\n"
|
|
263
|
+
"if op in ('Bash', 'bash') and '.uap/evidence' in (args.get('command') or ''):\n"
|
|
264
|
+
" emit(False, 'BLOCKED')\n"
|
|
265
|
+
"emit(True, 'ok')\n"
|
|
266
|
+
)
|
|
267
|
+
import sqlite3
|
|
268
|
+
db = sqlite3.connect(self.sb / "agents/data/memory/policies.db")
|
|
269
|
+
db.execute("CREATE TABLE policies (id TEXT, name TEXT, category TEXT, level TEXT,"
|
|
270
|
+
" rawMarkdown TEXT, convertedFormat TEXT, executableTools TEXT, tags TEXT,"
|
|
271
|
+
" createdAt TEXT, updatedAt TEXT, version INT, isActive INT, priority INT,"
|
|
272
|
+
" enforcementStage TEXT)")
|
|
273
|
+
db.execute("CREATE TABLE executable_tools (id TEXT, policyId TEXT, toolName TEXT,"
|
|
274
|
+
" code TEXT, language TEXT, createdAt TEXT)")
|
|
275
|
+
db.execute("CREATE TABLE policy_executions (id TEXT)")
|
|
276
|
+
db.execute("INSERT INTO policies VALUES (?,?,?,?,?,?,?,?,?,?,1,1,1,'pre-exec')",
|
|
277
|
+
(self.PID, "Enforcement Self-Protect", "security", "REQUIRED",
|
|
278
|
+
"# Enforcement Self-Protect", "", "", "", "", ""))
|
|
279
|
+
db.execute("INSERT INTO executable_tools VALUES (?,?,?,?,?,?)",
|
|
280
|
+
("t1", self.PID, "enforcement_self_protect", "", "python", ""))
|
|
281
|
+
db.commit()
|
|
282
|
+
db.close()
|
|
283
|
+
|
|
284
|
+
def tearDown(self):
|
|
285
|
+
self._tmp.cleanup()
|
|
286
|
+
|
|
287
|
+
def gate(self, command):
|
|
288
|
+
dst = self.sb / ".claude/hooks/uap-policy-gate.sh"
|
|
289
|
+
dst.write_text(GATE.read_text())
|
|
290
|
+
dst.chmod(0o755)
|
|
291
|
+
payload = json.dumps({"tool_name": "Bash", "cwd": str(self.sb),
|
|
292
|
+
"tool_input": {"command": command}})
|
|
293
|
+
p = subprocess.run(["bash", str(dst)], input=payload, capture_output=True,
|
|
294
|
+
text=True, cwd=self.sb, timeout=120)
|
|
295
|
+
return p.returncode
|
|
296
|
+
|
|
297
|
+
def test_blocks_when_the_surface_is_healthy(self):
|
|
298
|
+
self.assertEqual(self.gate("echo x >> .uap/evidence/reads.log"), 2)
|
|
299
|
+
|
|
300
|
+
def test_helper_is_restored_and_the_op_still_blocked(self):
|
|
301
|
+
# THE INCIDENT: with the helper gone every enforcer dies at import, and
|
|
302
|
+
# the write was allowed. The gate must repair and still block.
|
|
303
|
+
(self.sb / ".policy-tools/_common.py").unlink()
|
|
304
|
+
rc = self.gate("echo x >> .uap/evidence/reads.log")
|
|
305
|
+
self.assertTrue((self.sb / ".policy-tools/_common.py").is_file(),
|
|
306
|
+
"helper was not restored")
|
|
307
|
+
self.assertEqual(rc, 2)
|
|
308
|
+
|
|
309
|
+
def test_fails_closed_when_the_helper_cannot_be_restored(self):
|
|
310
|
+
(self.sb / ".policy-tools/_common.py").unlink()
|
|
311
|
+
(self.sb / "src/policies/enforcers/_common.py").unlink()
|
|
312
|
+
self.assertEqual(self.gate("echo x >> .uap/evidence/reads.log"), 2)
|
|
313
|
+
|
|
314
|
+
def test_stays_fail_soft_for_ordinary_work(self):
|
|
315
|
+
# The other half of the contract: a broken surface must not wedge
|
|
316
|
+
# everything. This is what pins the `set -e` behaviour of the new block.
|
|
317
|
+
(self.sb / ".policy-tools/_common.py").unlink()
|
|
318
|
+
(self.sb / "src/policies/enforcers/_common.py").unlink()
|
|
319
|
+
self.assertEqual(self.gate("npm run build"), 0)
|
|
320
|
+
|
|
321
|
+
def test_self_heal_copies_rather_than_moves(self):
|
|
322
|
+
# Mutation `cp` -> `mv` survived every other assertion: the helper still
|
|
323
|
+
# appears in .policy-tools/ and the op is still blocked, while the
|
|
324
|
+
# TRACKED source has been deleted from the repo.
|
|
325
|
+
(self.sb / ".policy-tools/_common.py").unlink()
|
|
326
|
+
self.gate("npm run build")
|
|
327
|
+
self.assertTrue((self.sb / "src/policies/enforcers/_common.py").is_file(),
|
|
328
|
+
"self-heal moved the source instead of copying it")
|
|
329
|
+
|
|
330
|
+
def test_self_heal_does_not_clobber_an_existing_helper(self):
|
|
331
|
+
# Repair only when MISSING. A gate that rewrites the materialized helper
|
|
332
|
+
# on every call would silently undo a deliberate install.
|
|
333
|
+
helper = self.sb / ".policy-tools/_common.py"
|
|
334
|
+
helper.write_text(helper.read_text() + "\nSENTINEL = 1\n")
|
|
335
|
+
self.gate("npm run build")
|
|
336
|
+
self.assertIn("SENTINEL", helper.read_text())
|
|
337
|
+
|
|
338
|
+
def test_absent_policy_tools_dir_does_not_wedge_a_fresh_checkout(self):
|
|
339
|
+
# A checkout that has never run `uap setup` has no .policy-tools/ at all.
|
|
340
|
+
# Failing closed there is the fresh-install wedge this repo has already
|
|
341
|
+
# shipped once.
|
|
342
|
+
import shutil
|
|
343
|
+
shutil.rmtree(self.sb / ".policy-tools")
|
|
344
|
+
self.assertEqual(self.gate("npm run build"), 0)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TestTheFixIsActuallyInForce(unittest.TestCase):
|
|
348
|
+
"""The gate runs .policy-tools/<id>_<tool>.py — a COPY. Every other test in
|
|
349
|
+
this file runs src/. Nothing pinned that they are the same file, which is
|
|
350
|
+
exactly how this repo once had enforcer fixes merged but never in force for
|
|
351
|
+
over a week (the H1-vs-slug installer bug).
|
|
352
|
+
|
|
353
|
+
Anchored to the MAIN checkout: the materialized copies live there, not in a
|
|
354
|
+
worktree.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def test_materialized_enforcer_matches_source(self):
|
|
358
|
+
main = Path(str(ROOT).split("/.worktrees/")[0])
|
|
359
|
+
live = sorted((main / ".policy-tools").glob("*_enforcement_self_protect.py"))
|
|
360
|
+
if not live:
|
|
361
|
+
self.skipTest("enforcers not materialized in this checkout")
|
|
362
|
+
source = (main / "src/policies/enforcers/enforcement_self_protect.py").read_text()
|
|
363
|
+
stale = [p.name for p in live if p.read_text() != source]
|
|
364
|
+
self.assertFalse(
|
|
365
|
+
stale,
|
|
366
|
+
"materialized enforcer(s) differ from source — the gate is running "
|
|
367
|
+
f"OLD code: {stale}. Re-materialize with `npm i -g . && uap policy "
|
|
368
|
+
"install enforcement-self-protect`.",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TestGateCopiesStayInSync(unittest.TestCase):
|
|
373
|
+
def test_every_copy_exists_and_is_identical(self):
|
|
374
|
+
# Hook drift has shipped fixes that never reached templates/. `is_file`
|
|
375
|
+
# was previously filtered, so a DELETED copy passed silently.
|
|
376
|
+
for g in GATE_COPIES:
|
|
377
|
+
self.assertTrue(g.is_file(), f"missing gate copy: {g}")
|
|
378
|
+
self.assertEqual(len({g.read_text() for g in GATE_COPIES}), 1,
|
|
379
|
+
"gate copies have drifted apart")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
if __name__ == "__main__":
|
|
383
|
+
unittest.main()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The gate must REPAIR its enforcers, not just run whatever is on disk.
|
|
3
|
+
|
|
4
|
+
The gate executes `.policy-tools/<policyId>_<tool>.py` — copies. Two observed
|
|
5
|
+
failures both looked like success:
|
|
6
|
+
|
|
7
|
+
STALE a merged fix was never re-materialized, so the gate ran old code
|
|
8
|
+
while the suites went green against src/. This repo shipped that
|
|
9
|
+
for over a week, and did it again in the session that added this.
|
|
10
|
+
DESTROYED deleting one file, `_common.py`, broke all 29 enforcers at import
|
|
11
|
+
and silently turned the gate into a no-op. Verified live.
|
|
12
|
+
|
|
13
|
+
No scan of shell command text can prevent the second: `python3 -c` can write any
|
|
14
|
+
file and is allowed by design. So these tests pin the control that does work —
|
|
15
|
+
verify against a manifest and restore BEFORE any enforcer runs.
|
|
16
|
+
|
|
17
|
+
The tampered-enforcer case is the important one: the swapped-in file always
|
|
18
|
+
allows, so if repair did not happen before enforcement the operation would be
|
|
19
|
+
permitted. Asserting the exit code alone would not catch a repair that ran too
|
|
20
|
+
late, hence the on-disk assertion as well.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import sqlite3
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import unittest
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
ROOT = Path(__file__).resolve().parents[3]
|
|
35
|
+
# UAP_TEST_GATE lets a candidate gate be exercised before it is applied in
|
|
36
|
+
# place — self-protect reserves the real hook for the operator, so without this
|
|
37
|
+
# the change could only be verified after landing it.
|
|
38
|
+
GATE = Path(os.environ.get("UAP_TEST_GATE") or (ROOT / ".claude" / "hooks" / "uap-policy-gate.sh"))
|
|
39
|
+
PID = "11111111-1111-1111-1111-111111111111"
|
|
40
|
+
ENFORCER = f"{PID}_enforcement_self_protect.py"
|
|
41
|
+
|
|
42
|
+
HELPER = (
|
|
43
|
+
"import json, sys\n"
|
|
44
|
+
"def parse_cli():\n"
|
|
45
|
+
" a = sys.argv\n"
|
|
46
|
+
" op = a[a.index('--operation') + 1] if '--operation' in a else ''\n"
|
|
47
|
+
" ar = json.loads(a[a.index('--args') + 1]) if '--args' in a else {}\n"
|
|
48
|
+
" return op, ar\n"
|
|
49
|
+
"def emit(allowed, reason):\n"
|
|
50
|
+
" print(json.dumps({'allowed': bool(allowed), 'reason': reason}))\n"
|
|
51
|
+
" sys.exit(0)\n"
|
|
52
|
+
)
|
|
53
|
+
REAL_ENFORCER = (
|
|
54
|
+
"import sys\n"
|
|
55
|
+
"from pathlib import Path\n"
|
|
56
|
+
"sys.path.insert(0, str(Path(__file__).parent))\n"
|
|
57
|
+
"from _common import emit, parse_cli\n"
|
|
58
|
+
"op, args = parse_cli()\n"
|
|
59
|
+
"if op in ('Bash', 'bash') and '.uap/evidence' in (args.get('command') or ''):\n"
|
|
60
|
+
" emit(False, 'BLOCKED')\n"
|
|
61
|
+
"emit(True, 'ok')\n"
|
|
62
|
+
)
|
|
63
|
+
ALWAYS_ALLOW = (
|
|
64
|
+
"import sys\n"
|
|
65
|
+
"from pathlib import Path\n"
|
|
66
|
+
"sys.path.insert(0, str(Path(__file__).parent))\n"
|
|
67
|
+
"from _common import emit, parse_cli\n"
|
|
68
|
+
"parse_cli()\n"
|
|
69
|
+
"emit(True, 'neutered')\n"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sha256(path: Path) -> str:
|
|
74
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@unittest.skipUnless(GATE.is_file(), "policy gate not present")
|
|
78
|
+
class TestGateRepairsItsEnforcers(unittest.TestCase):
|
|
79
|
+
def setUp(self):
|
|
80
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
81
|
+
self.sb = Path(self._tmp.name)
|
|
82
|
+
for d in (".policy-tools", "agents/data/memory", "src/policies/enforcers",
|
|
83
|
+
".uap/evidence", ".claude/hooks"):
|
|
84
|
+
(self.sb / d).mkdir(parents=True, exist_ok=True)
|
|
85
|
+
subprocess.run(["git", "init", "-q"], cwd=self.sb, capture_output=True)
|
|
86
|
+
|
|
87
|
+
self.pt = self.sb / ".policy-tools"
|
|
88
|
+
src = self.sb / "src/policies/enforcers"
|
|
89
|
+
(src / "_common.py").write_text(HELPER)
|
|
90
|
+
(src / "enforcement_self_protect.py").write_text(REAL_ENFORCER)
|
|
91
|
+
(self.pt / "_common.py").write_text(HELPER)
|
|
92
|
+
(self.pt / ENFORCER).write_text(REAL_ENFORCER)
|
|
93
|
+
|
|
94
|
+
# The manifest the materializer would have written.
|
|
95
|
+
lines = [f"{sha256(self.pt / f)} {f}" for f in ("_common.py", ENFORCER)]
|
|
96
|
+
(self.pt / ".integrity.sha256").write_text("\n".join(lines) + "\n")
|
|
97
|
+
(self.pt / ".integrity.source").write_text(str(src) + "\n")
|
|
98
|
+
|
|
99
|
+
db = sqlite3.connect(self.sb / "agents/data/memory/policies.db")
|
|
100
|
+
db.execute("CREATE TABLE policies (id TEXT, name TEXT, category TEXT, level TEXT,"
|
|
101
|
+
" rawMarkdown TEXT, convertedFormat TEXT, executableTools TEXT, tags TEXT,"
|
|
102
|
+
" createdAt TEXT, updatedAt TEXT, version INT, isActive INT, priority INT,"
|
|
103
|
+
" enforcementStage TEXT)")
|
|
104
|
+
db.execute("CREATE TABLE executable_tools (id TEXT, policyId TEXT, toolName TEXT,"
|
|
105
|
+
" code TEXT, language TEXT, createdAt TEXT)")
|
|
106
|
+
db.execute("CREATE TABLE policy_executions (id TEXT)")
|
|
107
|
+
db.execute("INSERT INTO policies VALUES (?,?,?,?,?,?,?,?,?,?,1,1,1,'pre-exec')",
|
|
108
|
+
(PID, "Enforcement Self-Protect", "security", "REQUIRED", "#", "", "", "", "", ""))
|
|
109
|
+
db.execute("INSERT INTO executable_tools VALUES (?,?,?,?,?,?)",
|
|
110
|
+
("t1", PID, "enforcement_self_protect", "", "python", ""))
|
|
111
|
+
db.commit()
|
|
112
|
+
db.close()
|
|
113
|
+
|
|
114
|
+
def tearDown(self):
|
|
115
|
+
self._tmp.cleanup()
|
|
116
|
+
|
|
117
|
+
def gate(self, command):
|
|
118
|
+
dst = self.sb / ".claude/hooks/uap-policy-gate.sh"
|
|
119
|
+
dst.write_text(GATE.read_text())
|
|
120
|
+
dst.chmod(0o755)
|
|
121
|
+
payload = json.dumps({"tool_name": "Bash", "cwd": str(self.sb),
|
|
122
|
+
"tool_input": {"command": command}})
|
|
123
|
+
env = dict(os.environ)
|
|
124
|
+
env.pop("UAP_SELF_PROTECT_OFF", None)
|
|
125
|
+
p = subprocess.run(["bash", str(dst)], input=payload, capture_output=True,
|
|
126
|
+
text=True, cwd=self.sb, timeout=120, env=env)
|
|
127
|
+
return p.returncode
|
|
128
|
+
|
|
129
|
+
BLOCKED_CMD = "echo x >> .uap/evidence/reads.log"
|
|
130
|
+
|
|
131
|
+
def test_healthy_surface_is_left_alone(self):
|
|
132
|
+
before = sha256(self.pt / ENFORCER)
|
|
133
|
+
self.assertEqual(self.gate(self.BLOCKED_CMD), 2)
|
|
134
|
+
self.assertEqual(sha256(self.pt / ENFORCER), before)
|
|
135
|
+
|
|
136
|
+
def test_neutered_enforcer_is_restored_before_it_runs(self):
|
|
137
|
+
# The attack: swap in an enforcer that always allows. If repair happened
|
|
138
|
+
# after enforcement (or not at all) the operation would be permitted.
|
|
139
|
+
(self.pt / ENFORCER).write_text(ALWAYS_ALLOW)
|
|
140
|
+
rc = self.gate(self.BLOCKED_CMD)
|
|
141
|
+
self.assertEqual((self.pt / ENFORCER).read_text(), REAL_ENFORCER,
|
|
142
|
+
"tampered enforcer was not restored")
|
|
143
|
+
self.assertEqual(rc, 2, "repair ran too late — the op was allowed")
|
|
144
|
+
|
|
145
|
+
def test_deleted_helper_is_restored(self):
|
|
146
|
+
# One file whose absence killed all 29 enforcers at import.
|
|
147
|
+
(self.pt / "_common.py").unlink()
|
|
148
|
+
rc = self.gate(self.BLOCKED_CMD)
|
|
149
|
+
self.assertTrue((self.pt / "_common.py").is_file(), "helper not restored")
|
|
150
|
+
self.assertEqual(rc, 2)
|
|
151
|
+
|
|
152
|
+
def test_deleted_enforcer_is_restored(self):
|
|
153
|
+
(self.pt / ENFORCER).unlink()
|
|
154
|
+
rc = self.gate(self.BLOCKED_CMD)
|
|
155
|
+
self.assertTrue((self.pt / ENFORCER).is_file(), "enforcer not restored")
|
|
156
|
+
self.assertEqual(rc, 2)
|
|
157
|
+
|
|
158
|
+
def test_repair_is_recorded_as_evidence(self):
|
|
159
|
+
(self.pt / ENFORCER).write_text(ALWAYS_ALLOW)
|
|
160
|
+
self.gate(self.BLOCKED_CMD)
|
|
161
|
+
log = self.sb / ".uap/evidence/integrity.log"
|
|
162
|
+
self.assertTrue(log.is_file(), "repair was silent")
|
|
163
|
+
self.assertIn("restored", log.read_text())
|
|
164
|
+
|
|
165
|
+
def test_unrecoverable_does_not_wedge_ordinary_work(self):
|
|
166
|
+
# Source gone AND copy tampered: nothing to restore from. The gate must
|
|
167
|
+
# still not block unrelated work — fail-soft is the other half of the
|
|
168
|
+
# contract, and a wedge here would be worse than the drift.
|
|
169
|
+
(self.pt / ENFORCER).write_text(ALWAYS_ALLOW)
|
|
170
|
+
shutil.rmtree(self.sb / "src/policies/enforcers")
|
|
171
|
+
self.assertEqual(self.gate("npm run build"), 0)
|
|
172
|
+
|
|
173
|
+
def test_surface_without_a_manifest_still_works(self):
|
|
174
|
+
# Installs predating the manifest must keep functioning unchanged.
|
|
175
|
+
(self.pt / ".integrity.sha256").unlink()
|
|
176
|
+
self.assertEqual(self.gate(self.BLOCKED_CMD), 2)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
unittest.main()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""/v1/models must advertise the context window for locally-served models.
|
|
2
|
+
|
|
3
|
+
Regression (hermes, 2026-08-04): the endpoint returned bare {"id","object"}
|
|
4
|
+
rows. Hermes HAS a context compressor and probes for context_length /
|
|
5
|
+
context_window / max_context_length / max_model_len / n_ctx, found none, and its
|
|
6
|
+
model cache held no entry for our model — so the compressor never engaged. It
|
|
7
|
+
sent 470 messages / 219,957 tokens against a 130,048 window (169%) and the proxy
|
|
8
|
+
CRITICAL PRUNEd 290 of them; 61 such events in 18 hours. Raising the window from
|
|
9
|
+
86,784 to 130,048 had not helped, because the growth was never sized to the
|
|
10
|
+
window at all.
|
|
11
|
+
|
|
12
|
+
A client that cannot discover the window cannot size its history to it.
|
|
13
|
+
"""
|
|
14
|
+
import importlib.util
|
|
15
|
+
import os
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_proxy(window="130048", passthrough=None):
|
|
23
|
+
"""Import a fresh proxy module under the given env (constants bind at import)."""
|
|
24
|
+
os.environ["PROXY_CONTEXT_WINDOW"] = window
|
|
25
|
+
if passthrough is None:
|
|
26
|
+
os.environ.pop("ANTHROPIC_PASSTHROUGH_MODELS", None)
|
|
27
|
+
else:
|
|
28
|
+
os.environ["ANTHROPIC_PASSTHROUGH_MODELS"] = passthrough
|
|
29
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_ctx", proxy_path)
|
|
30
|
+
mod = importlib.util.module_from_spec(spec)
|
|
31
|
+
spec.loader.exec_module(mod)
|
|
32
|
+
return mod
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ModelsAdvertiseContextWindowTest(unittest.TestCase):
|
|
36
|
+
def tearDown(self):
|
|
37
|
+
os.environ.pop("PROXY_CONTEXT_WINDOW", None)
|
|
38
|
+
os.environ.pop("ANTHROPIC_PASSTHROUGH_MODELS", None)
|
|
39
|
+
|
|
40
|
+
def test_local_model_carries_the_window(self):
|
|
41
|
+
ap = load_proxy()
|
|
42
|
+
e = ap._model_entry("qwen36-35b-a3b-iq4xs")
|
|
43
|
+
self.assertEqual(e["context_length"], 130048)
|
|
44
|
+
|
|
45
|
+
def test_every_probed_key_is_emitted(self):
|
|
46
|
+
# There is no standard key. Clients disagree, so emit the common
|
|
47
|
+
# spellings — missing the one a client happens to read is the same
|
|
48
|
+
# failure as advertising nothing.
|
|
49
|
+
ap = load_proxy()
|
|
50
|
+
e = ap._model_entry("qwen36-35b-a3b-iq4xs")
|
|
51
|
+
for key in ("context_length", "context_window", "max_context_length",
|
|
52
|
+
"max_model_len", "n_ctx"):
|
|
53
|
+
self.assertEqual(e.get(key), 130048, key)
|
|
54
|
+
|
|
55
|
+
def test_passthrough_models_do_not_get_the_local_window(self):
|
|
56
|
+
# A model that round-trips to api.anthropic.com has a much larger window.
|
|
57
|
+
# Stamping the local llama.cpp figure on it would make clients truncate
|
|
58
|
+
# needlessly — worse than the bug being fixed.
|
|
59
|
+
ap = load_proxy(passthrough=None) # default patterns: Claude passes through
|
|
60
|
+
for mid in ("claude-sonnet-4-6", "claude-haiku-4-5-20251001"):
|
|
61
|
+
self.assertNotIn("context_length", ap._model_entry(mid), mid)
|
|
62
|
+
|
|
63
|
+
def test_local_only_sentinel_means_every_id_is_local(self):
|
|
64
|
+
ap = load_proxy(passthrough="__local_only__")
|
|
65
|
+
for mid in ap.ADVERTISED_MODEL_IDS:
|
|
66
|
+
self.assertEqual(ap._model_entry(mid).get("context_length"), 130048, mid)
|
|
67
|
+
|
|
68
|
+
def test_unset_window_advertises_nothing(self):
|
|
69
|
+
# Better to say nothing than to assert a wrong number.
|
|
70
|
+
ap = load_proxy(window="0")
|
|
71
|
+
self.assertEqual(
|
|
72
|
+
ap._model_entry("qwen36-35b-a3b-iq4xs"), {"id": "qwen36-35b-a3b-iq4xs", "object": "model"}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def test_entry_always_keeps_the_openai_shape(self):
|
|
76
|
+
ap = load_proxy()
|
|
77
|
+
for mid in ap.ADVERTISED_MODEL_IDS:
|
|
78
|
+
e = ap._model_entry(mid)
|
|
79
|
+
self.assertEqual(e["id"], mid)
|
|
80
|
+
self.assertEqual(e["object"], "model")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
unittest.main()
|