@miller-tech/uap 1.210.8 → 1.211.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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +7 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/schema-diff.d.ts +86 -5
- package/dist/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +523 -28
- package/dist/cli/schema-diff.js.map +1 -1
- package/docs/reference/CONFIGURATION.md +2 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +7 -0
- package/src/policies/enforcers/schema_diff_gate.py +566 -28
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +94 -7
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_gate_failclosed_schema_diff.py +410 -0
- package/tools/agents/tests/test_schema_diff_gate.py +193 -1
- package/tools/agents/tests/test_schema_diff_inline.py +429 -0
|
@@ -172,7 +172,8 @@ bypass = re.search(
|
|
|
172
172
|
r"|UAP_SELF_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_NO_WORKTREE\s*=\s*[\x27\"]?1"
|
|
173
173
|
r"|UAP_WORKDIR_SCOPE_OFF\s*=\s*[\x27\"]?1|UAP_USER_VALIDATION\s*=\s*[\x27\"]?0"
|
|
174
174
|
r"|UAP_DELIVER_NO_LOCK\s*=\s*[\x27\"]?1|UAP_NO_REVIEW\s*=\s*[\x27\"]?1"
|
|
175
|
-
r"|UAP_INFRA_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_ALLOW_GATELESS_ROOT\s*=\s*[\x27\"]?1"
|
|
175
|
+
r"|UAP_INFRA_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_ALLOW_GATELESS_ROOT\s*=\s*[\x27\"]?1"
|
|
176
|
+
r"|UAP_SCHEMA_DIFF_INLINE\s*=\s*[\x27\"]?1|UAP_ORACLE_CONSISTENCY\s*=\s*[\x27\"]?0",
|
|
176
177
|
cmd, re.I)
|
|
177
178
|
print("1" if (hit or bypass) else "0")
|
|
178
179
|
' 2>/dev/null || echo 1)"
|
|
@@ -239,8 +240,32 @@ sys.exit(0 if (is_test or trivial) else 1)
|
|
|
239
240
|
esac
|
|
240
241
|
fi
|
|
241
242
|
|
|
243
|
+
# $1 = short reason, $2 = the enforcer that could not run.
|
|
244
|
+
# The message has to name the RIGHT enforcer and an override that actually
|
|
245
|
+
# works. Routing schema_diff_gate through the self-protect wording told the
|
|
246
|
+
# operator the wrong thing had failed and pointed at UAP_SELF_PROTECT_OFF=1,
|
|
247
|
+
# which cleared SEC_SENSITIVE and not COMMIT_OP. A refusal that describes the
|
|
248
|
+
# wrong thing and offers no alternative is how a loop survives a guard.
|
|
242
249
|
fail_closed() {
|
|
243
|
-
|
|
250
|
+
local why="${1:-machinery unavailable}"
|
|
251
|
+
local who="${2:-enforcement_self_protect}"
|
|
252
|
+
# Record before exiting. This is the most serious verdict the gate can
|
|
253
|
+
# reach and it was the one that left no trace: measured, policy_executions
|
|
254
|
+
# was unchanged across a fail-closed block while ordinary blocks recorded,
|
|
255
|
+
# so the compliance view showed zero blocks for exactly this failure mode.
|
|
256
|
+
# Guarded with declare -F because the earliest call sites (policies.db
|
|
257
|
+
# missing, no sqlite3) fire before record_execution is defined -- and in
|
|
258
|
+
# those states there is nothing to write to anyway.
|
|
259
|
+
declare -F record_execution >/dev/null \
|
|
260
|
+
&& record_execution 0 "$who" "FAIL-CLOSED: $why"
|
|
261
|
+
case "$who" in
|
|
262
|
+
schema_diff_gate)
|
|
263
|
+
echo "[UAP policy gate] FAIL-CLOSED: this commit touches watched schema paths but the schema-diff enforcer could not run (${why}). Blocked so a broken/absent gate can't become a bypass. Restore it with: uap policy verify --repair (or uap policy install schema-diff-gate if the manifest is gone; npm run build if dist/ is missing). Operator override: UAP_SELF_PROTECT_OFF=1 in the environment." >&2
|
|
264
|
+
;;
|
|
265
|
+
*)
|
|
266
|
+
echo "[UAP policy gate] FAIL-CLOSED: this operation touches the enforcement control surface but the self-protect enforcer could not run (${why}). Blocked so a broken/absent gate can't become a bypass. (Operator override: UAP_SELF_PROTECT_OFF=1.)" >&2
|
|
267
|
+
;;
|
|
268
|
+
esac
|
|
244
269
|
exit 2
|
|
245
270
|
}
|
|
246
271
|
|
|
@@ -330,26 +355,88 @@ fi
|
|
|
330
355
|
# Did the self-protect enforcer actually run and make a decision this call?
|
|
331
356
|
sec_enforcer_ran=0
|
|
332
357
|
|
|
358
|
+
# Is this a commit or a push? schema_diff_gate is the only control standing
|
|
359
|
+
# between a breaking schema change and history, and like self-protect its
|
|
360
|
+
# failure must not be read as consent -- the loop below otherwise maps an
|
|
361
|
+
# errored or missing enforcer to ALLOW for everything except self-protect.
|
|
362
|
+
# Scoped to commit/push so a broken schema enforcer cannot block every shell
|
|
363
|
+
# command in the session; it mirrors the enforcer's own activation test.
|
|
364
|
+
COMMIT_OP="$(printf '%s' "$ARGS" | python3 -c '
|
|
365
|
+
import json, re, sys
|
|
366
|
+
try: a = json.loads(sys.stdin.read() or "{}")
|
|
367
|
+
except Exception: a = {}
|
|
368
|
+
cmd = str(a.get("command") or "")
|
|
369
|
+
# Quoted text is prose, not an invocation. While the enforcer was broken,
|
|
370
|
+
# echo "next step: git commit" and grep -rn "git push" docs/ were hard blocks.
|
|
371
|
+
# The enforcer over-matches the same way, but when IT over-matches it runs,
|
|
372
|
+
# finds no watched paths and allows -- so the fail-closed net has to be
|
|
373
|
+
# STRICTER than the thing it protects, not looser.
|
|
374
|
+
bare = re.sub(r"\x27[^\x27]*\x27|\"[^\"]*\"", " ", cmd).lower()
|
|
375
|
+
inv = re.search(r"(?:^|[;&|]|\bthen\b|\bdo\b)\s*git\b[^;&|]*\b(?:commit|push)\b", bare)
|
|
376
|
+
# --help and --dry-run store nothing, so refusing them is pure friction. The
|
|
377
|
+
# match itself ends at the verb, so the flags are searched in the rest of the
|
|
378
|
+
# command SEGMENT -- searching inv.group(0) found nothing and blocked them.
|
|
379
|
+
seg = re.split(r"[;&|]", bare[inv.start():])[0] if inv else ""
|
|
380
|
+
inert = inv and re.search(r"--help|--dry-run|(?:^|\s)-h(?:\s|$)", seg)
|
|
381
|
+
print("1" if (inv and not inert) else "0")
|
|
382
|
+
' 2>/dev/null || echo 1)"
|
|
383
|
+
|
|
384
|
+
# The operator hatch clears the whole net. It already zeroed SEC_SENSITIVE
|
|
385
|
+
# further up; leaving COMMIT_OP armed made the override named in the refusal a
|
|
386
|
+
# no-op for exactly the case that prints it -- so a stale .policy-tools copy
|
|
387
|
+
# refused every commit and push in the session with no working remedy.
|
|
388
|
+
[[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && COMMIT_OP=0
|
|
389
|
+
|
|
390
|
+
# Which enforcers must fail CLOSED, and when. Called from `if`, never as the
|
|
391
|
+
# left side of `&&`: this script runs under `set -e`, and `if` suspends it for
|
|
392
|
+
# the whole condition.
|
|
393
|
+
must_fail_closed() {
|
|
394
|
+
case "$1" in
|
|
395
|
+
enforcement_self_protect) [[ "$SEC_SENSITIVE" == "1" ]] ;;
|
|
396
|
+
schema_diff_gate) [[ "$COMMIT_OP" == "1" ]] ;;
|
|
397
|
+
*) false ;;
|
|
398
|
+
esac
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# Bound every enforcer. Without this a hung one stalled the hook until the
|
|
402
|
+
# harness killed the whole process -- and a killed HOOK is not a fail-closed,
|
|
403
|
+
# it is an unbounded stall with the outcome decided elsewhere. On timeout the
|
|
404
|
+
# output is empty, which the parser below already reads as allowed=2, so the
|
|
405
|
+
# existing net decides what that means per enforcer.
|
|
406
|
+
#
|
|
407
|
+
# The layers must nest, innermost shortest: the schema enforcer's own inline
|
|
408
|
+
# checker (10s x up to 2 sources) < this (30s) < the harness hook budget.
|
|
409
|
+
# gtimeout is the macOS spelling; with neither present the call is unbounded,
|
|
410
|
+
# which is the behaviour that shipped.
|
|
411
|
+
TIMEOUT_BIN="$(command -v timeout || command -v gtimeout || true)"
|
|
412
|
+
ENFORCER_TIMEOUT="${UAP_ENFORCER_TIMEOUT:-30}"
|
|
413
|
+
|
|
333
414
|
# Iterate active policies with attached executable tools
|
|
334
415
|
while IFS='|' read -r pid pname tool; do
|
|
335
416
|
[[ -z "$pid" ]] && continue
|
|
336
417
|
enforcer="$MAIN_ROOT/.policy-tools/${pid}_${tool}.py"
|
|
337
418
|
if [[ ! -f "$enforcer" ]]; then
|
|
338
|
-
# A missing
|
|
339
|
-
|
|
419
|
+
# A missing fail-closed enforcer on an operation it guards = fail closed.
|
|
420
|
+
if must_fail_closed "$tool"; then fail_closed "enforcer file missing" "$tool"; fi
|
|
340
421
|
continue
|
|
341
422
|
fi
|
|
342
|
-
|
|
423
|
+
if [[ -n "$TIMEOUT_BIN" ]]; then
|
|
424
|
+
out="$("$TIMEOUT_BIN" "${ENFORCER_TIMEOUT}s" python3 "$enforcer" --operation "$TOOL" --args "$ARGS" 2>/dev/null || true)"
|
|
425
|
+
else
|
|
426
|
+
out="$(python3 "$enforcer" --operation "$TOOL" --args "$ARGS" 2>/dev/null || true)"
|
|
427
|
+
fi
|
|
343
428
|
allowed="$(printf '%s' "$out" | python3 -c 'import json,sys;
|
|
344
429
|
try: d=json.loads(sys.stdin.read()); print("1" if d.get("allowed",True) else "0")
|
|
345
430
|
except: print("2")' 2>/dev/null || echo 2)"
|
|
346
431
|
# allowed=2 => enforcer errored / emitted unparseable output. For a sensitive
|
|
347
432
|
# op via the self-protect enforcer, that error must NOT default to allow.
|
|
433
|
+
if [[ "$allowed" == "2" ]] && must_fail_closed "$tool"; then
|
|
434
|
+
fail_closed "enforcer errored" "$tool"
|
|
435
|
+
fi
|
|
348
436
|
if [[ "$tool" == "enforcement_self_protect" ]]; then
|
|
349
|
-
[[ "$SEC_SENSITIVE" == "1" && "$allowed" == "2" ]] && fail_closed "enforcer errored"
|
|
350
437
|
sec_enforcer_ran=1
|
|
351
438
|
fi
|
|
352
|
-
# For
|
|
439
|
+
# For every other enforcer, an error still fails open (unchanged behavior).
|
|
353
440
|
[[ "$allowed" == "2" ]] && allowed=1
|
|
354
441
|
if [[ "$allowed" == "0" ]]; then
|
|
355
442
|
# R1: consume the enforcer's route:deliver signal (log intent, opt-in
|
|
Binary file
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""The schema-diff gate's failure must not read as consent.
|
|
2
|
+
|
|
3
|
+
Two residuals left over from the inline-check pivot (PR #733), both in files
|
|
4
|
+
the agent that wrote that change could not edit:
|
|
5
|
+
|
|
6
|
+
1. `UAP_SCHEMA_DIFF_INLINE=1` was the only gate switch of its shape absent
|
|
7
|
+
from self-protect's BYPASS_PATTERNS. It no longer allows anything on its
|
|
8
|
+
own -- it skips the inline checker and lands in the fallback -- but it
|
|
9
|
+
still WEAKENS the control, and an inline assignment is written by the
|
|
10
|
+
constrained party into its own command line. Every comparable switch
|
|
11
|
+
(UAP_SELF_PROTECT_OFF, UAP_NO_REVIEW, UAP_DELIVER_NO_LOCK, ...) is
|
|
12
|
+
refused inline for exactly that reason.
|
|
13
|
+
|
|
14
|
+
2. uap-policy-gate.sh maps an errored or missing enforcer to ALLOW for
|
|
15
|
+
everything except enforcement_self_protect. schema_diff_gate is the only
|
|
16
|
+
thing standing between a breaking schema change and history, so a crash
|
|
17
|
+
in it was a silent bypass. It now fails closed -- but only on a commit or
|
|
18
|
+
push, so a broken schema enforcer cannot block every shell command in a
|
|
19
|
+
session.
|
|
20
|
+
|
|
21
|
+
The drift test at the bottom is the one that will actually catch a regression:
|
|
22
|
+
the hook has NINE copies in this repo, and `uap worktree create` seeds new
|
|
23
|
+
worktrees from templates/hooks/, so patching only .claude/hooks/ silently
|
|
24
|
+
reverts the fix on the next worktree.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sqlite3
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import unittest
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
40
|
+
ENFORCERS = REPO / "src" / "policies" / "enforcers"
|
|
41
|
+
SELF_PROTECT = ENFORCERS / "enforcement_self_protect.py"
|
|
42
|
+
PRIMARY_HOOK = REPO / ".claude" / "hooks" / "uap-policy-gate.sh"
|
|
43
|
+
HOOK_NAME = "uap-policy-gate.sh"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def hook_copies() -> list[Path]:
|
|
47
|
+
"""Every copy of the hook in this checkout.
|
|
48
|
+
|
|
49
|
+
Exclusions are matched on the path RELATIVE to REPO. Matching the absolute
|
|
50
|
+
path excluded the entire tree whenever REPO was itself a worktree, since
|
|
51
|
+
every path under .worktrees/240-x/ contains ".worktrees" -- so this
|
|
52
|
+
returned [] exactly where it runs, and the two sweeps below passed by
|
|
53
|
+
iterating nothing. Caught by running it for real; a scratch copy in /tmp
|
|
54
|
+
could not reproduce it.
|
|
55
|
+
"""
|
|
56
|
+
tracked = subprocess.run(
|
|
57
|
+
["git", "ls-files", "--full-name", "*" + HOOK_NAME],
|
|
58
|
+
cwd=REPO, capture_output=True, text=True,
|
|
59
|
+
).stdout.split()
|
|
60
|
+
# TRACKED copies only. An rglob also swept `uap hooks install` output --
|
|
61
|
+
# .uap/omp/ and .codex|.cursor|.forge|.opencode/hooks/ are all gitignored --
|
|
62
|
+
# so on any machine whose installed hooks predate a change, the drift
|
|
63
|
+
# assertions failed on files no PR can commit. What matters here is that
|
|
64
|
+
# every copy the repo SHIPS stays in step, templates/hooks/ above all.
|
|
65
|
+
return sorted(REPO / t for t in tracked if (REPO / t).is_file())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_self_protect(command: str, root: Path) -> tuple[bool, str]:
|
|
69
|
+
env = {
|
|
70
|
+
"UAP_REPO_ROOT": str(root),
|
|
71
|
+
"UAP_WORKTREE_ROOT": str(root),
|
|
72
|
+
"PYTHONPATH": str(ENFORCERS),
|
|
73
|
+
"PATH": "/usr/bin:/bin",
|
|
74
|
+
}
|
|
75
|
+
r = subprocess.run(
|
|
76
|
+
[sys.executable, str(SELF_PROTECT), "--operation", "Bash",
|
|
77
|
+
"--args", json.dumps({"command": command})],
|
|
78
|
+
cwd=root, env=env, capture_output=True, text=True, timeout=60,
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
payload = json.loads(r.stdout or "{}")
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return False, f"unparseable: {r.stdout[:120]}"
|
|
84
|
+
return payload.get("allowed", True), payload.get("reason", "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class InlineGuardIsABypassPattern(unittest.TestCase):
|
|
88
|
+
def test_setting_it_inline_is_refused(self):
|
|
89
|
+
allowed, reason = run_self_protect(
|
|
90
|
+
"UAP_SCHEMA_DIFF_INLINE=1 git commit -m x", REPO
|
|
91
|
+
)
|
|
92
|
+
self.assertFalse(allowed, f"an inline off-switch must be refused: {reason}")
|
|
93
|
+
|
|
94
|
+
def test_a_comparable_switch_behaves_the_same(self):
|
|
95
|
+
"""Anchors the assertion above to existing, agreed behaviour."""
|
|
96
|
+
allowed, _ = run_self_protect("UAP_NO_REVIEW=1 git commit -m x", REPO)
|
|
97
|
+
self.assertFalse(allowed)
|
|
98
|
+
|
|
99
|
+
def test_merely_naming_the_variable_is_not_refused(self):
|
|
100
|
+
"""Docs, commit messages and greps must keep working.
|
|
101
|
+
|
|
102
|
+
The pattern requires an assignment to 1, not the bare name -- the same
|
|
103
|
+
distinction that made GATELESS_FLAG_RE scan `scannable_command`
|
|
104
|
+
instead of raw text after bare flag names started refusing honest work.
|
|
105
|
+
"""
|
|
106
|
+
for cmd in (
|
|
107
|
+
"echo the UAP_SCHEMA_DIFF_INLINE guard is documented",
|
|
108
|
+
"grep -rn UAP_SCHEMA_DIFF_INLINE src/",
|
|
109
|
+
):
|
|
110
|
+
with self.subTest(cmd=cmd):
|
|
111
|
+
allowed, reason = run_self_protect(cmd, REPO)
|
|
112
|
+
self.assertTrue(allowed, f"{cmd} must stay allowed: {reason}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class HookFailsClosedOnCommits(unittest.TestCase):
|
|
116
|
+
"""The hook's own logic, extracted and executed rather than eyeballed."""
|
|
117
|
+
|
|
118
|
+
def commit_op(self, command: str) -> str:
|
|
119
|
+
gate = PRIMARY_HOOK.read_text()
|
|
120
|
+
start = 'COMMIT_OP="$(printf \'%s\' "$ARGS" | python3 -c \''
|
|
121
|
+
end = "' 2>/dev/null || echo 1)\""
|
|
122
|
+
i = gate.index(start) + len(start)
|
|
123
|
+
code = gate[i:gate.index(end, i)]
|
|
124
|
+
p = subprocess.run(
|
|
125
|
+
[sys.executable, "-c", code],
|
|
126
|
+
input=json.dumps({"command": command}),
|
|
127
|
+
capture_output=True, text=True, timeout=30,
|
|
128
|
+
env={"PATH": "/usr/bin:/bin"},
|
|
129
|
+
)
|
|
130
|
+
return p.stdout.strip()
|
|
131
|
+
|
|
132
|
+
def must_fail_closed(self, tool: str, sec: str, commit: str) -> bool:
|
|
133
|
+
gate = PRIMARY_HOOK.read_text()
|
|
134
|
+
start = gate.index("must_fail_closed() {")
|
|
135
|
+
fn = gate[start:gate.index("\n}\n", start) + 3]
|
|
136
|
+
script = (
|
|
137
|
+
"set -euo pipefail\n"
|
|
138
|
+
f'SEC_SENSITIVE="{sec}"\nCOMMIT_OP="{commit}"\n'
|
|
139
|
+
+ fn
|
|
140
|
+
+ f'\nif must_fail_closed "{tool}"; then echo CLOSED; else echo open; fi\n'
|
|
141
|
+
)
|
|
142
|
+
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
|
|
143
|
+
self.assertEqual(p.returncode, 0, f"set -e tripped: {p.stderr[:200]}")
|
|
144
|
+
return p.stdout.strip() == "CLOSED"
|
|
145
|
+
|
|
146
|
+
def test_commit_and_push_are_recognised(self):
|
|
147
|
+
for cmd in ("git commit -m x", "git push origin master", "git commit -am x && echo ok"):
|
|
148
|
+
with self.subTest(cmd=cmd):
|
|
149
|
+
self.assertEqual(self.commit_op(cmd), "1")
|
|
150
|
+
|
|
151
|
+
def test_ordinary_commands_are_not(self):
|
|
152
|
+
# Scoped deliberately: a broken schema enforcer must not turn every
|
|
153
|
+
# shell command in the session into a hard block.
|
|
154
|
+
for cmd in ("ls -la", "npm test", "cat README.md"):
|
|
155
|
+
with self.subTest(cmd=cmd):
|
|
156
|
+
self.assertEqual(self.commit_op(cmd), "0")
|
|
157
|
+
|
|
158
|
+
def test_the_schema_gate_fails_closed_on_a_commit(self):
|
|
159
|
+
self.assertTrue(self.must_fail_closed("schema_diff_gate", "0", "1"))
|
|
160
|
+
|
|
161
|
+
def test_the_schema_gate_does_not_fail_closed_otherwise(self):
|
|
162
|
+
self.assertFalse(self.must_fail_closed("schema_diff_gate", "0", "0"))
|
|
163
|
+
|
|
164
|
+
def test_self_protect_keeps_its_existing_condition(self):
|
|
165
|
+
self.assertTrue(self.must_fail_closed("enforcement_self_protect", "1", "0"))
|
|
166
|
+
self.assertFalse(self.must_fail_closed("enforcement_self_protect", "0", "0"))
|
|
167
|
+
|
|
168
|
+
def test_an_unrelated_enforcer_still_fails_open(self):
|
|
169
|
+
"""Widening this to every enforcer would be a session-wide deadlock."""
|
|
170
|
+
self.assertFalse(self.must_fail_closed("worktree_required", "1", "1"))
|
|
171
|
+
|
|
172
|
+
def test_the_hook_parses(self):
|
|
173
|
+
for copy in hook_copies():
|
|
174
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
175
|
+
p = subprocess.run(["bash", "-n", str(copy)], capture_output=True, text=True)
|
|
176
|
+
self.assertEqual(p.returncode, 0, p.stderr[:200])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class HookCopiesDoNotDrift(unittest.TestCase):
|
|
180
|
+
"""Nine copies, and the one that matters most is the one nobody edits.
|
|
181
|
+
|
|
182
|
+
`uap worktree create` seeds a new worktree from templates/hooks/, so a fix
|
|
183
|
+
applied only to .claude/hooks/ is reverted the next time anyone starts a
|
|
184
|
+
branch -- a documented failure in this repo. Any change to the gate has to
|
|
185
|
+
land in all of them.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def test_the_sweep_actually_finds_the_copies(self):
|
|
189
|
+
"""Guards the two sweeps below against passing on an empty list.
|
|
190
|
+
|
|
191
|
+
hook_copies() returned [] in a worktree because its exclusions matched
|
|
192
|
+
the absolute path, and both drift tests went green while checking
|
|
193
|
+
nothing. An assertion about a collection is worthless without an
|
|
194
|
+
assertion that the collection is non-empty.
|
|
195
|
+
"""
|
|
196
|
+
found = hook_copies()
|
|
197
|
+
self.assertGreaterEqual(
|
|
198
|
+
len(found), 2,
|
|
199
|
+
f"expected several hook copies under {REPO}, found {[str(p) for p in found]}",
|
|
200
|
+
)
|
|
201
|
+
self.assertIn(PRIMARY_HOOK, found)
|
|
202
|
+
|
|
203
|
+
def test_every_copy_is_identical_to_the_primary(self):
|
|
204
|
+
primary = PRIMARY_HOOK.read_text()
|
|
205
|
+
for copy in hook_copies():
|
|
206
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
207
|
+
self.assertEqual(
|
|
208
|
+
copy.read_text(), primary,
|
|
209
|
+
f"{copy.relative_to(REPO)} has drifted from .claude/hooks/",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def test_the_template_copy_carries_the_fail_closed_logic(self):
|
|
213
|
+
template = REPO / "templates" / "hooks" / HOOK_NAME
|
|
214
|
+
self.assertTrue(template.is_file(), "templates/hooks copy is missing")
|
|
215
|
+
self.assertIn(
|
|
216
|
+
"must_fail_closed", template.read_text(),
|
|
217
|
+
"new worktrees would be seeded with a gate that fails open",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
class FailClosedIsAuditable(unittest.TestCase):
|
|
221
|
+
"""The most serious verdict the gate can reach was the one leaving no trace.
|
|
222
|
+
|
|
223
|
+
fail_closed() exits, and it exited before record_execution ever ran --
|
|
224
|
+
measured, policy_executions was unchanged across a fail-closed block while
|
|
225
|
+
ordinary blocks recorded. The compliance view therefore showed zero blocks
|
|
226
|
+
for exactly the failure mode most worth seeing.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def fail_closed_fn(self) -> str:
|
|
230
|
+
gate = PRIMARY_HOOK.read_text()
|
|
231
|
+
start = gate.index("fail_closed() {")
|
|
232
|
+
return gate[start:gate.index("\n}\n", start) + 3]
|
|
233
|
+
|
|
234
|
+
def test_it_records_before_exiting(self):
|
|
235
|
+
script = (
|
|
236
|
+
"set -euo pipefail\n"
|
|
237
|
+
'record_execution() { echo "RECORDED allowed=$1 policy=$2 reason=$3"; }\n'
|
|
238
|
+
+ self.fail_closed_fn()
|
|
239
|
+
+ '\nfail_closed "enforcer errored" "schema_diff_gate" || true\n'
|
|
240
|
+
)
|
|
241
|
+
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
|
|
242
|
+
self.assertIn("RECORDED allowed=0", p.stdout)
|
|
243
|
+
self.assertIn("policy=schema_diff_gate", p.stdout, "the row must name the enforcer")
|
|
244
|
+
self.assertIn("FAIL-CLOSED", p.stdout)
|
|
245
|
+
|
|
246
|
+
def test_it_still_works_before_record_execution_exists(self):
|
|
247
|
+
"""The earliest call sites fire before that function is defined.
|
|
248
|
+
|
|
249
|
+
`policies.db not found` and `sqlite3 not on PATH` both call fail_closed
|
|
250
|
+
from above record_execution's definition -- and in those states there is
|
|
251
|
+
nothing to write to anyway. An unguarded call would turn the refusal
|
|
252
|
+
into a bash error.
|
|
253
|
+
"""
|
|
254
|
+
script = "set -euo pipefail\n" + self.fail_closed_fn() + '\nfail_closed "policies.db not found" || true\n'
|
|
255
|
+
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
|
|
256
|
+
self.assertIn("FAIL-CLOSED", p.stderr)
|
|
257
|
+
self.assertNotIn("command not found", p.stderr)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class EnforcersAreBounded(unittest.TestCase):
|
|
261
|
+
"""A hung enforcer stalled the hook until the harness killed the process.
|
|
262
|
+
|
|
263
|
+
A killed HOOK is not a fail-closed -- it is an unbounded stall whose outcome
|
|
264
|
+
is decided somewhere else entirely. Measured at 20.2s with a sleeping
|
|
265
|
+
enforcer before this.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def test_the_invocation_is_wrapped(self):
|
|
269
|
+
gate = PRIMARY_HOOK.read_text()
|
|
270
|
+
self.assertIn("TIMEOUT_BIN", gate)
|
|
271
|
+
self.assertIn('"${ENFORCER_TIMEOUT}s" python3 "$enforcer"', gate)
|
|
272
|
+
|
|
273
|
+
def test_it_degrades_to_the_shipped_behaviour_without_a_timeout_binary(self):
|
|
274
|
+
"""macOS spells it gtimeout; with neither, unbounded is what shipped."""
|
|
275
|
+
gate = PRIMARY_HOOK.read_text()
|
|
276
|
+
self.assertIn("command -v timeout || command -v gtimeout || true", gate)
|
|
277
|
+
self.assertIn('if [[ -n "$TIMEOUT_BIN" ]]; then', gate)
|
|
278
|
+
|
|
279
|
+
def test_the_timeout_layers_nest_innermost_shortest(self):
|
|
280
|
+
"""The arithmetic that makes the bound meaningful rather than decorative.
|
|
281
|
+
|
|
282
|
+
The schema enforcer may run its checker twice (index and worktree). If
|
|
283
|
+
that worst case can exceed the hook's per-enforcer bound, a slow but
|
|
284
|
+
healthy check is killed and -- on a commit -- refused. Previously
|
|
285
|
+
2 x 15s met the 30s budget exactly, with nothing left over.
|
|
286
|
+
"""
|
|
287
|
+
gate = PRIMARY_HOOK.read_text()
|
|
288
|
+
enforcer = (REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py").read_text()
|
|
289
|
+
hook_bound = int(
|
|
290
|
+
re.search(r'ENFORCER_TIMEOUT="\$\{UAP_ENFORCER_TIMEOUT:-(\d+)\}"', gate).group(1)
|
|
291
|
+
)
|
|
292
|
+
inline = float(re.search(r"INLINE_TIMEOUT = ([\d.]+)", enforcer).group(1))
|
|
293
|
+
self.assertLess(
|
|
294
|
+
inline * 2, hook_bound,
|
|
295
|
+
"the enforcer's worst case must fit inside the hook's bound, or a "
|
|
296
|
+
"slow healthy check becomes a refusal",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class GateCallSitesAreWired(unittest.TestCase):
|
|
301
|
+
"""Drives the REAL hook script.
|
|
302
|
+
|
|
303
|
+
Everything above extracts a bash fragment and runs it, which cannot notice
|
|
304
|
+
if the fragment is never CALLED. Delete `must_fail_closed` from either call
|
|
305
|
+
site, or move the COMMIT_OP assignment below the loop, and every other test
|
|
306
|
+
here still passes. This one fails.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
PID = "22222222-2222-2222-2222-222222222222"
|
|
310
|
+
|
|
311
|
+
def setUp(self):
|
|
312
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="gate-e2e-")
|
|
313
|
+
self.sb = Path(self._tmp.name)
|
|
314
|
+
for d in (".policy-tools", "agents/data/memory", ".claude/hooks"):
|
|
315
|
+
(self.sb / d).mkdir(parents=True, exist_ok=True)
|
|
316
|
+
subprocess.run(["git", "init", "-q"], cwd=self.sb, capture_output=True)
|
|
317
|
+
|
|
318
|
+
helper = (
|
|
319
|
+
"import json, sys\n"
|
|
320
|
+
"def parse_cli():\n"
|
|
321
|
+
" a = sys.argv\n"
|
|
322
|
+
" op = a[a.index('--operation') + 1] if '--operation' in a else ''\n"
|
|
323
|
+
" ar = json.loads(a[a.index('--args') + 1]) if '--args' in a else {}\n"
|
|
324
|
+
" return op, ar\n"
|
|
325
|
+
"def emit(allowed, reason):\n"
|
|
326
|
+
" print(json.dumps({'allowed': bool(allowed), 'reason': reason}))\n"
|
|
327
|
+
" sys.exit(0)\n"
|
|
328
|
+
)
|
|
329
|
+
(self.sb / ".policy-tools/_common.py").write_text(helper)
|
|
330
|
+
|
|
331
|
+
db = sqlite3.connect(self.sb / "agents/data/memory/policies.db")
|
|
332
|
+
db.execute("CREATE TABLE policies (id TEXT, name TEXT, category TEXT, level TEXT,"
|
|
333
|
+
" rawMarkdown TEXT, convertedFormat TEXT, executableTools TEXT, tags TEXT,"
|
|
334
|
+
" createdAt TEXT, updatedAt TEXT, version INT, isActive INT, priority INT,"
|
|
335
|
+
" enforcementStage TEXT)")
|
|
336
|
+
db.execute("CREATE TABLE executable_tools (id TEXT, policyId TEXT, toolName TEXT,"
|
|
337
|
+
" code TEXT, language TEXT, createdAt TEXT)")
|
|
338
|
+
db.execute("CREATE TABLE policy_executions (id TEXT)")
|
|
339
|
+
db.execute("INSERT INTO policies VALUES (?,?,?,?,?,?,?,?,?,?,1,1,1,'pre-exec')",
|
|
340
|
+
(self.PID, "Schema Diff Gate", "quality", "REQUIRED",
|
|
341
|
+
"# Schema Diff Gate", "", "", "", "", ""))
|
|
342
|
+
db.execute("INSERT INTO executable_tools VALUES (?,?,?,?,?,?)",
|
|
343
|
+
("t1", self.PID, "schema_diff_gate", "", "python", ""))
|
|
344
|
+
db.commit()
|
|
345
|
+
db.close()
|
|
346
|
+
|
|
347
|
+
def tearDown(self):
|
|
348
|
+
self._tmp.cleanup()
|
|
349
|
+
|
|
350
|
+
def install_enforcer(self, body: str | None) -> None:
|
|
351
|
+
p = self.sb / f".policy-tools/{self.PID}_schema_diff_gate.py"
|
|
352
|
+
if body is None:
|
|
353
|
+
if p.exists():
|
|
354
|
+
p.unlink()
|
|
355
|
+
return
|
|
356
|
+
p.write_text(body)
|
|
357
|
+
|
|
358
|
+
def gate(self, command: str, env_extra: dict | None = None) -> int:
|
|
359
|
+
dst = self.sb / ".claude/hooks/uap-policy-gate.sh"
|
|
360
|
+
dst.write_text(PRIMARY_HOOK.read_text())
|
|
361
|
+
dst.chmod(0o755)
|
|
362
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
363
|
+
env.update(env_extra or {})
|
|
364
|
+
payload = json.dumps({"tool_name": "Bash", "cwd": str(self.sb),
|
|
365
|
+
"tool_input": {"command": command}})
|
|
366
|
+
p = subprocess.run(["bash", str(dst)], input=payload, capture_output=True,
|
|
367
|
+
text=True, cwd=self.sb, env=env, timeout=180)
|
|
368
|
+
return p.returncode
|
|
369
|
+
|
|
370
|
+
BROKEN = "raise SystemExit('boom')\n"
|
|
371
|
+
HEALTHY = (
|
|
372
|
+
"import json\n"
|
|
373
|
+
"print(json.dumps({'allowed': True, 'reason': 'ok'}))\n"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def test_a_broken_enforcer_refuses_a_commit(self):
|
|
377
|
+
self.install_enforcer(self.BROKEN)
|
|
378
|
+
self.assertEqual(self.gate("git commit -m x"), 2)
|
|
379
|
+
|
|
380
|
+
def test_a_missing_enforcer_refuses_a_commit(self):
|
|
381
|
+
self.install_enforcer(None)
|
|
382
|
+
self.assertEqual(self.gate("git commit -m x"), 2)
|
|
383
|
+
|
|
384
|
+
def test_a_broken_enforcer_does_not_block_ordinary_work(self):
|
|
385
|
+
"""The scoping that keeps this from being a session-wide deadlock."""
|
|
386
|
+
self.install_enforcer(self.BROKEN)
|
|
387
|
+
for command in ("ls -la", "npm test", "git status", "git commit --help"):
|
|
388
|
+
with self.subTest(command=command):
|
|
389
|
+
self.assertEqual(self.gate(command), 0, command)
|
|
390
|
+
|
|
391
|
+
def test_the_operator_hatch_actually_clears_it(self):
|
|
392
|
+
"""The refusal names this override, so it has to work.
|
|
393
|
+
|
|
394
|
+
It cleared SEC_SENSITIVE only, so for a schema-gate fail-closed it did
|
|
395
|
+
nothing -- every commit refused, with the message pointing at a switch
|
|
396
|
+
that had no effect on the branch printing it.
|
|
397
|
+
"""
|
|
398
|
+
self.install_enforcer(self.BROKEN)
|
|
399
|
+
self.assertEqual(self.gate("git commit -m x"), 2)
|
|
400
|
+
self.assertEqual(
|
|
401
|
+
self.gate("git commit -m x", {"UAP_SELF_PROTECT_OFF": "1"}), 0,
|
|
402
|
+
"the advertised override must clear the schema-gate branch too",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def test_a_healthy_enforcer_is_unaffected(self):
|
|
406
|
+
self.install_enforcer(self.HEALTHY)
|
|
407
|
+
self.assertEqual(self.gate("git commit -m x"), 0)
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
unittest.main()
|