@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.
- 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/package.json +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +558 -28
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_schema_diff_gate.py +193 -1
- package/tools/agents/tests/test_schema_diff_inline.py +373 -0
|
@@ -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
|
-
[
|
|
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()
|