@miller-tech/uap 1.210.8 → 1.211.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.
@@ -10,8 +10,10 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import os
13
+ import shutil
13
14
  import sqlite3
14
15
  import subprocess
16
+ import sys
15
17
  import tempfile
16
18
  import unittest
17
19
  from datetime import datetime, timedelta, timezone
@@ -19,16 +21,41 @@ from pathlib import Path
19
21
 
20
22
  REPO = Path(__file__).resolve().parents[3]
21
23
  ENFORCER = REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py"
24
+ GIT_BIN = "git"
25
+
26
+
27
+ def _git_only_path() -> str:
28
+ """A PATH holding git and nothing else, so no checker can be found.
29
+
30
+ Every test in this file is about the MARKER layer, which the gate reaches
31
+ only when the inline checker cannot answer. Left to the ambient PATH these
32
+ tests passed for an accidental reason: the globally installed `uap`
33
+ predates --json, so the inline layer errored and fell through. The day that
34
+ binary is updated the inline layer would answer first and roughly a third
35
+ of the assertions below would silently invert -- green either way, but
36
+ testing something else entirely. Pinning the layer makes the choice
37
+ deliberate; the inline layer has its own suite in
38
+ test_schema_diff_inline.py.
39
+ """
40
+ d = Path(tempfile.mkdtemp(prefix="schema-gate-gitonly-"))
41
+ found = shutil.which(GIT_BIN)
42
+ if found:
43
+ (d / GIT_BIN).symlink_to(found)
44
+ return str(d)
45
+
46
+
47
+ _GIT_ONLY_PATH = _git_only_path()
22
48
 
23
49
 
24
50
  def run_gate(op: str, args: dict, root: Path, tz: str | None = None):
25
51
  env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
26
52
  env["UAP_REPO_ROOT"] = str(root)
27
53
  env["UAP_WORKTREE_ROOT"] = str(root)
54
+ env["PATH"] = _GIT_ONLY_PATH
28
55
  if tz:
29
56
  env["TZ"] = tz
30
57
  r = subprocess.run(
31
- ["python3", str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
58
+ [sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
32
59
  cwd=root,
33
60
  env=env,
34
61
  capture_output=True,
@@ -152,6 +179,171 @@ class SchemaDiffGateTest(unittest.TestCase):
152
179
  _, allowed, _ = run_gate("git-commit", {}, self.root)
153
180
  self.assertTrue(allowed, "a marker from the previous CLI release must still clear the gate")
154
181
 
182
+ def _blob_sha(self, rel: str) -> str:
183
+ """FULL 40-char sha. The enforcer compares exactly: a 7-hex prefix
184
+ matched with startswith is a 28-bit binding, grindable in minutes
185
+ against fully attacker-chosen SQL."""
186
+ out = subprocess.run(
187
+ ["git", "hash-object", "--", rel], cwd=self.root, capture_output=True, text=True
188
+ )
189
+ return out.stdout.strip()
190
+
191
+ def test_a_content_scoped_marker_covering_the_staged_bytes_clears_it(self):
192
+ rel = "migrations/001_add_table.sql"
193
+ self.write_marker(
194
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
195
+ content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
196
+ )
197
+ _, allowed, reason = run_gate("git-commit", {}, self.root)
198
+ self.assertTrue(allowed, reason)
199
+ self.assertIn("covers the committed content", reason)
200
+
201
+ def test_a_pass_stops_covering_content_once_it_changes(self):
202
+ """THE REDESIGN. A marker vouches for BYTES, not for a time window.
203
+
204
+ Before this, one pass cleared every watched path for an hour no matter
205
+ what happened next — so a reviewed migration could be swapped for an
206
+ unreviewed one and committed inside the window.
207
+ """
208
+ rel = "migrations/001_add_table.sql"
209
+ self.write_marker(
210
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
211
+ content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
212
+ )
213
+ # STAGE the change: what a plain `git commit` will store is what must
214
+ # be covered. (A worktree-only edit is deliberately NOT a block for a
215
+ # plain commit — see the companion test — because those bytes are not
216
+ # what gets stored, and blocking on them made partial staging
217
+ # unclearable.)
218
+ (self.root / rel).write_text("CREATE TABLE t (id int);\nDROP TABLE t;\n")
219
+ subprocess.run(["git", "add", rel], cwd=self.root, check=True)
220
+ _, allowed, reason = run_gate("git-commit", {}, self.root)
221
+ self.assertFalse(allowed, "staged content changed after the pass must re-arm the gate")
222
+ self.assertIn("no recent pass covers the CURRENT content", reason)
223
+
224
+ def test_a_diverging_worktree_must_be_covered_too(self):
225
+ """When the index and the worktree disagree, BOTH have to be covered.
226
+
227
+ This used to assert that a plain `git commit` was allowed here, on the
228
+ grounds that it stores the index and only `-a` stores the worktree.
229
+ That reasoning was right about those two forms and wrong about git:
230
+ `git commit -- <path>`, `--only`, `--include`, `-o` and `-i` also store
231
+ the worktree copy, and none of them contains `-a`. Reading the source
232
+ off the command string was a demonstrated bypass -- stage something
233
+ benign, edit the file, `git commit -m x -- <path>`, and the dropped
234
+ column landed while the gate reported the staged content clean.
235
+
236
+ The command-form space is bigger than it looks and this gate had
237
+ already got it wrong twice, so the inference is gone: there are only
238
+ two candidate byte-strings, and when they differ both must be clear.
239
+
240
+ Note what this is NOT. An earlier over-correction demanded that a
241
+ single MARKER cover both versions, which was unclearable -- no
242
+ sequence of commands produced such a marker. Here each version is
243
+ checked on its own, so ordinary partial staging passes; only an
244
+ actually-breaking copy refuses, and the reason names which one.
245
+ """
246
+ rel = "migrations/001_add_table.sql"
247
+ self.write_marker(
248
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
249
+ content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
250
+ )
251
+ (self.root / rel).write_text("CREATE TABLE t (id int);\nDROP TABLE t;\n") # unstaged
252
+ for command in ("git commit -m x", "git commit -am x", "git commit -m x -- " + rel):
253
+ with self.subTest(command=command):
254
+ _, allowed, _ = run_gate("git-commit", {"command": command}, self.root)
255
+ self.assertFalse(
256
+ allowed,
257
+ "the worktree copy is breaking and several commit forms store it",
258
+ )
259
+
260
+ def test_a_watched_path_the_CLI_cannot_parse_is_still_coverable(self):
261
+ """P0: the gate watched a SUPERSET of what the CLI examined.
262
+
263
+ `infra/helm_charts/**` and `infra/postgres-spock/**` are watched but are
264
+ YAML, which the CLI's filter skipped — so they could never enter a
265
+ marker, coverage always failed, and the refusal told the operator to
266
+ re-run a command that produced the identical marker. Verified as a
267
+ permanent block on the policy's headline file class before the fix.
268
+ Here the marker CAN name it, which is only true because the CLI now
269
+ examines everything the gate watches.
270
+ """
271
+ helm = self.root / "infra" / "helm_charts" / "pgdog"
272
+ helm.mkdir(parents=True)
273
+ rel = "infra/helm_charts/pgdog/values.yaml"
274
+ (self.root / rel).write_text("replicas: 2\n")
275
+ subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
276
+ sql = "migrations/001_add_table.sql"
277
+ self.write_marker(
278
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
279
+ content=(
280
+ f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)},"
281
+ f"{sql}@{self._blob_sha(sql)}"
282
+ ),
283
+ )
284
+ _, allowed, reason = run_gate("git-commit", {}, self.root)
285
+ self.assertTrue(allowed, reason)
286
+
287
+ def test_staged_bytes_are_judged_not_only_the_worktree(self):
288
+ """P0: the marker vouched for the WORKTREE while `git commit` takes the
289
+ INDEX.
290
+
291
+ Stage malicious, restore benign on disk, run the remedy against the
292
+ benign bytes — the gate announced "covers the staged content" and
293
+ allowed, while the commit would have taken the drop. Verified.
294
+ """
295
+ rel = "migrations/001_add_table.sql"
296
+ (self.root / rel).write_text("DROP TABLE t;") # index = malicious
297
+ subprocess.run(["git", "add", rel], cwd=self.root, check=True)
298
+ (self.root / rel).write_text("CREATE TABLE t (id int);") # worktree = benign
299
+ self.write_marker(
300
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
301
+ content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
302
+ )
303
+ _, allowed, reason = run_gate("git-commit", {}, self.root)
304
+ self.assertFalse(allowed, "staged bytes the pass never saw must not be committable")
305
+
306
+ def test_a_second_worktrees_marker_does_not_invalidate_this_ones(self):
307
+ """Every worktree writes to the SAME main-checkout database. Consulting
308
+ only the newest marker made two concurrent worktrees invalidate each
309
+ other forever — a livelock this repo's .worktrees workflow would hit,
310
+ and a regression versus the old time-only rule."""
311
+ rel = "migrations/001_add_table.sql"
312
+ now = datetime.now(timezone.utc)
313
+ # ours first...
314
+ self.write_marker(
315
+ (now - timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
316
+ content=f"schema-diff pass: base HEAD~1 | files: {rel}@{self._blob_sha(rel)}",
317
+ )
318
+ # ...then a NEWER one from another worktree, covering unrelated files
319
+ self.write_marker(
320
+ now.strftime("%Y-%m-%dT%H:%M:%SZ"),
321
+ content="schema-diff pass: base HEAD~1 | files: other/999.sql@" + "0" * 40,
322
+ )
323
+ _, allowed, reason = run_gate("git-commit", {}, self.root)
324
+ self.assertTrue(allowed, "any in-window marker covering these bytes must clear: " + reason)
325
+
326
+ def test_PRESERVE_a_legacy_marker_without_shas_still_clears_it(self):
327
+ """Markers from the installed older CLI carry bare paths. Judging them
328
+ for coverage would block every operator who has not upgraded, with no
329
+ way to produce a covering marker — so they keep the time-only rule."""
330
+ self.write_marker(
331
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
332
+ content="schema-diff pass: base HEAD~1 | files: migrations/001_add_table.sql",
333
+ )
334
+ _, allowed, reason = run_gate("git-commit", {}, self.root)
335
+ self.assertTrue(allowed, reason)
336
+
337
+ def test_PRESERVE_a_truncated_marker_does_not_deadlock(self):
338
+ """A change too large to enumerate records "(truncated)". Coverage is
339
+ unknowable, so it falls back rather than blocking unclearably."""
340
+ self.write_marker(
341
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
342
+ content="schema-diff pass: base HEAD~1 | files: (truncated: 412 files)",
343
+ )
344
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
345
+ self.assertTrue(allowed)
346
+
155
347
  def test_a_note_saying_the_diff_FAILED_does_not_clear_it(self):
156
348
  self.write_marker(
157
349
  datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -0,0 +1,373 @@
1
+ """Tests for the inline-check pivot in the schema-diff gate.
2
+
3
+ The gate used to ask "is there a marker saying somebody ran the checker?" and
4
+ spent three review rounds trying to bind that marker to the bytes being
5
+ committed. It now runs the checker itself, on the exact bytes the command will
6
+ store.
7
+
8
+ Nearly every test here exists because an adversarial review got past an earlier
9
+ version of this code and the transcript is in the docstring. The through-line:
10
+ the gate must believe the checker ONLY when the checker demonstrably read the
11
+ right bytes and understood them. "No breaking changes" from something that
12
+ could not open the file, or had no analyser for it, or answered about a
13
+ different blob, is not an all-clear -- and every one of those was, at some
14
+ point, reported to the operator as one.
15
+
16
+ The checker is stubbed at the path the gate invokes, so these also pin the
17
+ invocation contract: --paths-from, --source, and the verdict shape.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+ import unittest
28
+ from pathlib import Path
29
+
30
+ REPO = Path(__file__).resolve().parents[3]
31
+ ENFORCER = REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py"
32
+
33
+ WATCHED = "migrations/001_add_table.sql"
34
+ BASE_SQL = "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);\n"
35
+ BREAKING_SQL = "CREATE TABLE t (id INTEGER PRIMARY KEY);\n"
36
+
37
+
38
+ def run_gate(op: str, args: dict, root: Path, env_extra: dict | None = None):
39
+ env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
40
+ env["UAP_REPO_ROOT"] = str(root)
41
+ env["UAP_WORKTREE_ROOT"] = str(root)
42
+ env.update(env_extra or {})
43
+ r = subprocess.run(
44
+ ["python3", str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
45
+ cwd=root,
46
+ env=env,
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=120,
50
+ )
51
+ try:
52
+ payload = json.loads(r.stdout or "{}")
53
+ except json.JSONDecodeError:
54
+ payload = {}
55
+ return payload.get("allowed", False), payload.get("reason", "")
56
+
57
+
58
+ @unittest.skipIf(shutil.which("node") is None, "node is required to stub the checker")
59
+ class InlineSchemaDiffTest(unittest.TestCase):
60
+ def setUp(self):
61
+ self._tmp = tempfile.TemporaryDirectory(prefix="schema-inline-")
62
+ self.root = Path(self._tmp.name)
63
+ git = ["git", "-c", "user.email=t@t", "-c", "user.name=t"]
64
+ subprocess.run(["git", "init", "-q"], cwd=self.root, check=True)
65
+ # The gate only prefers a tree-local dist/ when the tree IS the UAP
66
+ # checkout; see test_a_local_build_in_someone_elses_repo_is_not_run.
67
+ (self.root / "package.json").write_text('{"name":"@miller-tech/uap"}\n')
68
+ (self.root / "migrations").mkdir()
69
+ (self.root / WATCHED).write_text(BASE_SQL)
70
+ subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
71
+ subprocess.run(git + ["commit", "-q", "-m", "base"], cwd=self.root, check=True)
72
+ (self.root / WATCHED).write_text(BREAKING_SQL)
73
+ subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
74
+
75
+ def tearDown(self):
76
+ self._tmp.cleanup()
77
+
78
+ # -- helpers ------------------------------------------------------------
79
+
80
+ def staged_sha(self, path: str = WATCHED) -> str:
81
+ return subprocess.run(
82
+ ["git", "rev-parse", f":{path}"],
83
+ cwd=self.root, capture_output=True, text=True, check=True,
84
+ ).stdout.strip()
85
+
86
+ def stub_checker(self, payload, *, log_argv: bool = False):
87
+ """Install a fake checker where the gate looks for it.
88
+
89
+ `payload` is emitted verbatim as the last stdout line: a dict for a
90
+ well-formed verdict, a string to test how the gate handles noise.
91
+ """
92
+ bin_dir = self.root / "dist" / "bin"
93
+ bin_dir.mkdir(parents=True, exist_ok=True)
94
+ body = payload if isinstance(payload, str) else json.dumps(payload)
95
+ # Logged relative to cwd, not via an env var: the gate allowlists the
96
+ # child's environment (it must not hand an agent's credentials to a
97
+ # subprocess chosen partly by filesystem contents), so ARGV_LOG would
98
+ # never arrive.
99
+ prelude = (
100
+ "require('fs').appendFileSync('argv.log', "
101
+ "JSON.stringify(process.argv.slice(2)) + '\\n');"
102
+ if log_argv
103
+ else ""
104
+ )
105
+ (bin_dir / "cli.js").write_text(f"{prelude}\nconsole.log({json.dumps(body)});\n")
106
+
107
+ def verdict(self, *, breaking=(), sha=None, analysed=True, contract=1, path=WATCHED):
108
+ return {
109
+ "contract": contract,
110
+ "ran": True,
111
+ "base": "HEAD",
112
+ "files": [
113
+ {
114
+ "path": path,
115
+ "sha": self.staged_sha() if sha is None else sha,
116
+ "analysed": analysed,
117
+ "breaking": list(breaking),
118
+ }
119
+ ],
120
+ }
121
+
122
+ BREAK = ['Field "t.name" (TEXT) was removed']
123
+
124
+ def commit(self, command="git commit -m x", **env):
125
+ return run_gate("Bash", {"command": command}, self.root, env_extra=env or None)
126
+
127
+ # -- the pivot ----------------------------------------------------------
128
+
129
+ def test_a_clean_inline_run_clears_the_gate_with_no_marker_at_all(self):
130
+ """The point of the change: evidence is produced, not looked up."""
131
+ self.stub_checker(self.verdict())
132
+ allowed, reason = self.commit()
133
+ self.assertTrue(allowed, reason)
134
+ self.assertIn("no breaking changes", reason)
135
+
136
+ def test_a_breaking_change_is_refused_and_named(self):
137
+ self.stub_checker(self.verdict(breaking=self.BREAK))
138
+ allowed, reason = self.commit()
139
+ self.assertFalse(allowed, "a breaking schema change must not commit")
140
+ self.assertIn("BREAKING", reason)
141
+ self.assertIn("t.name", reason, "the refusal must say what broke")
142
+
143
+ # -- believing the checker ----------------------------------------------
144
+
145
+ def test_a_verdict_about_different_bytes_is_not_an_answer(self):
146
+ """The sha is re-derived, not taken on trust.
147
+
148
+ The first version compared the paths it sent against the paths echoed
149
+ back -- two copies of its own argument. That can fail on a whitespace
150
+ artefact and on nothing else, so it could not detect the case it was
151
+ written for: a checker reporting clean for bytes it never read.
152
+ """
153
+ self.stub_checker(self.verdict(sha="0" * 40))
154
+ allowed, reason = self.commit()
155
+ self.assertFalse(allowed, f"sha mismatch must not clear the gate: {reason}")
156
+ self.assertIn("uap schema-diff", reason, "must fall back to the shipped remedy")
157
+
158
+ def test_a_file_no_analyser_understood_is_not_clean(self):
159
+ """`analysed: false` is the helm/spock case, and it is the big one.
160
+
161
+ WATCHED_RE covers infra/helm_charts/** and infra/postgres-spock/**,
162
+ which are YAML; the differ has branches for .ts/.js, .sql and .json
163
+ only. Those paths produced an empty change list, and the gate published
164
+ it as "schema-diff ran ... no breaking changes" -- an affirmative
165
+ safety claim about a file nothing could parse, replacing master's
166
+ requirement that a human run the checker first.
167
+ """
168
+ self.stub_checker(self.verdict(analysed=False))
169
+ allowed, reason = self.commit()
170
+ self.assertFalse(allowed, f"unanalysed must not read as clean: {reason}")
171
+
172
+ def test_a_verdict_in_an_unknown_contract_is_not_an_answer(self):
173
+ self.stub_checker(self.verdict(contract=99))
174
+ allowed, _ = self.commit()
175
+ self.assertFalse(allowed, "an unrecognised verdict shape must not clear the gate")
176
+
177
+ def test_a_missing_entry_is_not_an_answer(self):
178
+ """Silence about a watched path is not a clean bill of health."""
179
+ self.stub_checker({"contract": 1, "ran": True, "base": "HEAD", "files": []})
180
+ allowed, _ = self.commit()
181
+ self.assertFalse(allowed)
182
+
183
+ def test_ran_false_is_not_an_answer(self):
184
+ self.stub_checker({"contract": 1, "ran": False, "base": "HEAD", "files": []})
185
+ allowed, _ = self.commit()
186
+ self.assertFalse(allowed)
187
+
188
+ def test_noise_on_stdout_is_not_an_answer(self):
189
+ self.stub_checker("not json at all")
190
+ allowed, _ = self.commit()
191
+ self.assertFalse(allowed)
192
+
193
+ def test_a_malformed_verdict_cannot_crash_the_gate_open(self):
194
+ """A crash IS an allow.
195
+
196
+ .claude/hooks/uap-policy-gate.sh maps a non-zero exit or unparseable
197
+ output to allowed for every enforcer except self-protect. Two shapes
198
+ reachable from a future CLI raised TypeError out of main(): a
199
+ `breaking` that is a bool rather than a list, and `files` entries that
200
+ are objects where strings were expected.
201
+ """
202
+ for payload in (
203
+ {"contract": 1, "ran": True, "files": [{"path": WATCHED, "sha": "x", "analysed": True, "breaking": True}]},
204
+ {"contract": 1, "ran": True, "files": [{"path": WATCHED, "sha": "x", "analysed": True, "breaking": {"a": 1}}]},
205
+ {"contract": 1, "ran": True, "files": ["not-an-object"]},
206
+ {"contract": 1, "ran": True, "files": {"not": "a list"}},
207
+ ):
208
+ with self.subTest(payload=payload):
209
+ self.stub_checker(payload)
210
+ allowed, reason = self.commit()
211
+ self.assertFalse(allowed, f"malformed verdict cleared the gate: {reason}")
212
+ self.assertNotEqual(reason, "", "the gate must answer, not die silently")
213
+
214
+ def test_a_missing_checker_leaves_the_shipped_behaviour_untouched(self):
215
+ allowed, reason = self.commit()
216
+ self.assertFalse(allowed, f"missing checker must not open the gate: {reason}")
217
+ self.assertIn("uap schema-diff", reason)
218
+
219
+ # -- what gets examined --------------------------------------------------
220
+
221
+ def test_the_gate_names_the_paths_and_the_source(self):
222
+ log = self.root / "argv.log"
223
+ self.stub_checker(self.verdict(), log_argv=True)
224
+ self.commit()
225
+ argv = json.loads(log.read_text().splitlines()[0])
226
+ self.assertIn("--json", argv)
227
+ self.assertIn("--source", argv)
228
+ self.assertEqual(argv[argv.index("--source") + 1], "index")
229
+ # Paths travel in a NUL-separated file, never on the command line: a
230
+ # comma split one path into two, and git C-quotes newlines and unicode
231
+ # into strings that name no file at all -- which the checker then
232
+ # reported clean.
233
+ self.assertIn("--paths-from", argv)
234
+ self.assertEqual(
235
+ Path(argv[argv.index("--paths-from") + 1]).name.startswith("uap-schema-paths-"),
236
+ True,
237
+ )
238
+
239
+ def test_a_diverging_worktree_is_checked_as_well_as_the_index(self):
240
+ """Both candidate byte-strings, because the command form cannot be read.
241
+
242
+ `git commit -- <path>`, `--only`, `--include`, `-o` and `-i` all store
243
+ the WORKTREE copy and contain no `-a`; meanwhile the `-A` in
244
+ `git add -A && git commit` matched the old short-flag test and pushed
245
+ the gate to the worktree for a commit that stores the index. Both
246
+ directions were demonstrated bypasses, so the inference is gone.
247
+ """
248
+ (self.root / WATCHED).write_text(BASE_SQL + "-- diverged\n") # unstaged
249
+ log = self.root / "argv.log"
250
+ self.stub_checker(self.verdict(), log_argv=True)
251
+ self.commit()
252
+ sources = [
253
+ json.loads(line)[json.loads(line).index("--source") + 1]
254
+ for line in log.read_text().splitlines()
255
+ if line.strip()
256
+ ]
257
+ self.assertEqual(sorted(sources), ["index", "worktree"])
258
+
259
+ def test_only_one_check_when_index_and_worktree_agree(self):
260
+ """The common case must not pay for the rare one."""
261
+ log = self.root / "argv.log"
262
+ self.stub_checker(self.verdict(), log_argv=True)
263
+ self.commit()
264
+ self.assertEqual(len([x for x in log.read_text().splitlines() if x.strip()]), 1)
265
+
266
+ # -- the escape hatch ----------------------------------------------------
267
+
268
+ def _write_waiver(self, body: str, *, commit: bool) -> None:
269
+ waivers = self.root / "policies" / "waivers"
270
+ waivers.mkdir(parents=True, exist_ok=True)
271
+ (waivers / "deliberate-schema-diff.md").write_text(body)
272
+ if commit:
273
+ subprocess.run(["git", "add", "policies"], cwd=self.root, check=True)
274
+ subprocess.run(
275
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q",
276
+ "-m", "waiver", "--only", "policies"],
277
+ cwd=self.root, check=True,
278
+ )
279
+
280
+ def test_a_committed_waiver_naming_the_path_clears_a_breaking_change(self):
281
+ self.stub_checker(self.verdict(breaking=self.BREAK))
282
+ self._write_waiver(f"Deliberate: dropping name from {WATCHED}\n", commit=True)
283
+ allowed, reason = self.commit()
284
+ self.assertTrue(allowed, reason)
285
+ self.assertIn("waived", reason)
286
+
287
+ def test_an_uncommitted_waiver_does_not(self):
288
+ """`touch policies/waivers/x-schema-diff.md` was a total off-switch.
289
+
290
+ The check was Path.is_file() on the working tree, and
291
+ enforcement_self_protect deliberately exempts policies/waivers/ so that
292
+ agents CAN write there -- a carve-out justified when a waiver only
293
+ satisfied expert-review. An empty, never-committed file cleared every
294
+ breaking change. Verified.
295
+ """
296
+ self.stub_checker(self.verdict(breaking=self.BREAK))
297
+ self._write_waiver("", commit=False)
298
+ allowed, reason = self.commit()
299
+ self.assertFalse(allowed, f"an uncommitted waiver must not clear it: {reason}")
300
+
301
+ def test_a_waiver_for_a_different_path_does_not(self):
302
+ self.stub_checker(self.verdict(breaking=self.BREAK))
303
+ self._write_waiver("Deliberate: migrations/999_unrelated.sql\n", commit=True)
304
+ allowed, reason = self.commit()
305
+ self.assertFalse(allowed, f"a waiver must name what it excuses: {reason}")
306
+
307
+ # -- the gate's own subprocess ------------------------------------------
308
+
309
+ def test_the_inline_guard_stands_down_but_does_not_allow(self):
310
+ """The 2026-07-08 self-deadlock mechanism, without a new off-switch.
311
+
312
+ The guard has to exist so a nested gate does not call the checker
313
+ recursively. It must not ALLOW, though: the variable travels with the
314
+ shell, and the first version emitted allowed:true before looking at
315
+ anything, which made `UAP_SCHEMA_DIFF_INLINE=1` a complete disable for
316
+ a security control -- and unlike every comparable switch it was not in
317
+ self-protect's BYPASS_PATTERNS. Skipping to the fallback costs a forger
318
+ exactly what they already had.
319
+ """
320
+ self.stub_checker(self.verdict(breaking=self.BREAK))
321
+ allowed, reason = self.commit(UAP_SCHEMA_DIFF_INLINE="1")
322
+ self.assertFalse(allowed, f"the guard must not be an off-switch: {reason}")
323
+ self.assertIn("uap schema-diff", reason, "it should land in the fallback")
324
+
325
+
326
+ @unittest.skipIf(shutil.which("node") is None, "node is required to stub the checker")
327
+ class CheckerProvenanceTest(unittest.TestCase):
328
+ """Where the verdict comes from is part of the trust boundary."""
329
+
330
+ def setUp(self):
331
+ self._tmp = tempfile.TemporaryDirectory(prefix="schema-prov-")
332
+ self.root = Path(self._tmp.name)
333
+ subprocess.run(["git", "init", "-q"], cwd=self.root, check=True)
334
+ (self.root / "migrations").mkdir()
335
+ (self.root / WATCHED).write_text(BASE_SQL)
336
+ subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
337
+ subprocess.run(
338
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "base"],
339
+ cwd=self.root, check=True,
340
+ )
341
+ (self.root / WATCHED).write_text(BREAKING_SQL)
342
+ subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
343
+ bin_dir = self.root / "dist" / "bin"
344
+ bin_dir.mkdir(parents=True)
345
+ # A rubber stamp: three lines, and it attests to anything.
346
+ (bin_dir / "cli.js").write_text(
347
+ "console.log(JSON.stringify({contract:1,ran:true,base:'HEAD',files:"
348
+ "[{path:'" + WATCHED + "',sha:'x',analysed:true,breaking:[]}]}));\n"
349
+ )
350
+
351
+ def tearDown(self):
352
+ self._tmp.cleanup()
353
+
354
+ def test_a_local_build_in_someone_elses_repo_is_not_run(self):
355
+ """dist/bin/cli.js is gitignored build output in any Node project.
356
+
357
+ Preferring it handed the verdict to a file inside the tree being gated
358
+ -- an easier forgery than the marker row this design replaced, and in a
359
+ consumer repo it means committing a migration executes that project's
360
+ unrelated build output. It is only trusted when the tree IS the UAP
361
+ checkout, where it is the same artefact `npm i -g .` installs.
362
+ """
363
+ (self.root / "package.json").write_text('{"name":"totally-normal-app"}\n')
364
+ allowed, reason = run_gate("Bash", {"command": "git commit -m x"}, self.root)
365
+ self.assertFalse(allowed, f"a foreign dist/ must not be the oracle: {reason}")
366
+
367
+ def test_no_package_json_is_also_not_the_uap_checkout(self):
368
+ allowed, reason = run_gate("Bash", {"command": "git commit -m x"}, self.root)
369
+ self.assertFalse(allowed, f"unidentified tree must not be trusted: {reason}")
370
+
371
+
372
+ if __name__ == "__main__":
373
+ unittest.main()