@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.
@@ -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,
@@ -59,14 +86,21 @@ class SchemaDiffGateTest(unittest.TestCase):
59
86
  def tearDown(self):
60
87
  self._tmp.cleanup()
61
88
 
62
- def write_marker(self, iso_timestamp: str, table: str = "memories"):
89
+ # The literal the CLI records (src/cli/schema-diff.ts). The gate matches it
90
+ # ANCHORED, so this string is now a contract between the two.
91
+ MARKER = "schema-diff pass: base HEAD~1 | files: migrations/001_add_table.sql"
92
+
93
+ def write_marker(self, iso_timestamp: str, table: str = "memories", content: str | None = None):
63
94
  mem = self.root / "agents" / "data" / "memory"
64
- mem.mkdir(parents=True)
95
+ mem.mkdir(parents=True, exist_ok=True)
65
96
  con = sqlite3.connect(mem / "short_term.db")
66
- con.execute(f"CREATE TABLE {table} (id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)")
97
+ con.execute(
98
+ f"CREATE TABLE IF NOT EXISTS {table} "
99
+ "(id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)"
100
+ )
67
101
  con.execute(
68
102
  f"INSERT INTO {table} (content, timestamp) VALUES (?, ?)",
69
- ("schema-diff pass: verified", iso_timestamp),
103
+ (self.MARKER if content is None else content, iso_timestamp),
70
104
  )
71
105
  con.commit()
72
106
  con.close()
@@ -110,9 +144,229 @@ class SchemaDiffGateTest(unittest.TestCase):
110
144
  self.assertEqual(code, 2)
111
145
  self.assertFalse(allowed)
112
146
 
147
+ def test_the_gates_own_refusal_message_does_not_clear_it(self):
148
+ """A block message that is itself a valid unblock token is self-defeating.
113
149
 
114
- if __name__ == "__main__":
115
- unittest.main()
150
+ The old matcher was LIKE '%schema-diff%pass%', which the gate's own
151
+ refusal text satisfies ("...require `uap schema-diff` to pass"). Agents
152
+ here are instructed to store lessons, and blockers are exactly what they
153
+ store — so recording the refusal unblocked the next commit.
154
+ """
155
+ self.write_marker(
156
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
157
+ content=(
158
+ "Lesson: schema-diff-gate: changes to migrations/001_add_table.sql "
159
+ "require `uap schema-diff` to pass (within 1h). Run it and re-commit."
160
+ ),
161
+ )
162
+ code, allowed, _ = run_gate("git-commit", {}, self.root)
163
+ self.assertFalse(allowed, "the gate's own refusal text must not clear the gate")
164
+ self.assertEqual(code, 2)
165
+
166
+ def test_a_marker_from_the_PREVIOUS_cli_release_still_clears_it(self):
167
+ """Anchoring must not deadlock operators running an older global `uap`.
168
+
169
+ The previously-released recorder wrote
170
+ "schema-diff pass: base <b>, N schema file(s) checked, no breaking
171
+ changes" — same prefix — so it still matches. If a future format change
172
+ drops that prefix, the gate must be updated in the same commit or every
173
+ operator on the old CLI is blocked with no reachable remedy.
174
+ """
175
+ self.write_marker(
176
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
177
+ content="schema-diff pass: base HEAD~1, 3 schema file(s) checked, no breaking changes",
178
+ )
179
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
180
+ self.assertTrue(allowed, "a marker from the previous CLI release must still clear the gate")
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
+
347
+ def test_a_note_saying_the_diff_FAILED_does_not_clear_it(self):
348
+ self.write_marker(
349
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
350
+ content="schema-diff FAILED - breaking change, do not pass this to review",
351
+ )
352
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
353
+ self.assertFalse(allowed, "a failure note must not read as a pass")
354
+
355
+ def test_a_future_dated_marker_does_not_clear_it(self):
356
+ """The window was upper-bounded only, so a future timestamp cleared the
357
+ gate until wall-clock caught up — reachable via an importing writer that
358
+ supplies its own timestamp, or plain clock skew."""
359
+ ahead = (datetime.now(timezone.utc) + timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ")
360
+ self.write_marker(ahead)
361
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
362
+ self.assertFalse(allowed, "a future-dated marker must not clear the gate")
363
+
364
+ def test_watched_path_is_listed_once(self):
365
+ """A file both unstaged and staged appeared twice in the reason line,
366
+ which reads as two covered files."""
367
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
368
+ _, _, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
369
+ self.assertEqual(reason.count("001_add_table.sql"), 1, reason)
116
370
 
117
371
 
118
372
  class MergeVerbatimTest(unittest.TestCase):
@@ -161,9 +415,40 @@ class MergeVerbatimTest(unittest.TestCase):
161
415
 
162
416
  def test_merge_edited_migration_still_gates(self):
163
417
  # Editing the migration during the merge makes it THIS branch's change.
418
+ # NB this stages the edit; the UNSTAGED variant below is the one that
419
+ # was actually exploitable.
164
420
  (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
165
421
  subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
166
422
  _, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
167
423
  self.assertFalse(allowed, "a migration edited during the merge must still gate")
168
424
  self.assertIn("schema-diff", reason)
169
425
 
426
+ def test_UNSTAGED_merge_edit_still_gates(self):
427
+ """The exemption compared the INDEX blob, but `git commit -a` commits the
428
+ WORKING TREE.
429
+
430
+ Reproduced against the shipped gate: with the index still matching
431
+ MERGE_HEAD and an unstaged `DROP TABLE users;` appended, it answered
432
+ {"allowed": true, "reason": "no watched schema/pool paths in diff"} —
433
+ not even naming the exemption — while `git commit -am` would have
434
+ committed the drop. This is also the likely ACCIDENTAL path: a human
435
+ resolving a conflict, tweaking a migration, running `git commit -am`.
436
+ """
437
+ p = self.root / "migrations" / "001_add_table.sql"
438
+ p.write_text(p.read_text() + "\nDROP TABLE t;\n") # deliberately NOT staged
439
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
440
+ self.assertFalse(
441
+ allowed,
442
+ "an unstaged edit during a merge is committed by `commit -a` and must gate: " + reason,
443
+ )
444
+
445
+ def test_a_genuinely_verbatim_merge_is_still_exempt_after_the_fix(self):
446
+ """The deadlock fix must survive the hardening: with no local edit at
447
+ all, the incoming migration stays exempt."""
448
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
449
+ self.assertTrue(allowed, f"verbatim merge must remain exempt: {reason}")
450
+
451
+
452
+ if __name__ == "__main__":
453
+ unittest.main()
454
+