@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.
@@ -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
- echo "[UAP policy gate] FAIL-CLOSED: this operation touches the enforcement control surface but the self-protect enforcer could not run (${1:-machinery unavailable}). Blocked so a broken/absent gate can't become a bypass. (Operator override: UAP_SELF_PROTECT_OFF=1.)" >&2
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 self-protect enforcer on a sensitive op = fail closed.
339
- [[ "$SEC_SENSITIVE" == "1" && "$tool" == "enforcement_self_protect" ]] && fail_closed "enforcer file missing"
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
- out="$(python3 "$enforcer" --operation "$TOOL" --args "$ARGS" 2>/dev/null || true)"
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 all other enforcers, an error still fails open (unchanged behavior).
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
@@ -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()