@miller-tech/uap 1.210.7 → 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.
@@ -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()