@miller-tech/uap 1.210.7 → 1.210.8
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/cli/schema-diff.d.ts +39 -4
- package/dist/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +178 -22
- package/dist/cli/schema-diff.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +7 -4
- package/dist/cli/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +86 -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 +99 -6
|
@@ -23,6 +23,19 @@ COMMIT_OPS = {"git-commit", "git commit"}
|
|
|
23
23
|
RECENT_SEC = 3600
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def merge_in_progress(root: Path) -> bool:
|
|
27
|
+
"""Whether a merge is underway — invocation-constant, so resolved ONCE.
|
|
28
|
+
|
|
29
|
+
This used to be re-derived per watched file, spawning a `git rev-parse`
|
|
30
|
+
for each one on the hot path of every commit and push, purely to learn
|
|
31
|
+
"not a merge". A merge touching 30 migrations paid 30 spawns.
|
|
32
|
+
"""
|
|
33
|
+
rc, git_dir_out, _ = run(["git", "rev-parse", "--absolute-git-dir"], cwd=root)
|
|
34
|
+
if rc != 0:
|
|
35
|
+
return False
|
|
36
|
+
return (Path(git_dir_out.strip()) / "MERGE_HEAD").exists()
|
|
37
|
+
|
|
38
|
+
|
|
26
39
|
def merge_verbatim(root: Path, path: str) -> bool:
|
|
27
40
|
"""During a merge, a staged watched file that is byte-identical to the
|
|
28
41
|
incoming MERGE_HEAD version was not authored on this branch — it was
|
|
@@ -30,20 +43,26 @@ def merge_verbatim(root: Path, path: str) -> bool:
|
|
|
30
43
|
forced a schema-diff re-pass for content the merge cannot change (hit on
|
|
31
44
|
the 2026-08-16 pay2u #3153 conflict-resolution merge, where migrations
|
|
32
45
|
from already-merged main blocked the merge commit)."""
|
|
33
|
-
git_dir = root / ".git"
|
|
34
|
-
# Worktrees use a .git FILE pointing at the real gitdir.
|
|
35
|
-
if git_dir.is_file():
|
|
36
|
-
try:
|
|
37
|
-
ref = git_dir.read_text().strip()
|
|
38
|
-
if ref.startswith("gitdir:"):
|
|
39
|
-
git_dir = Path(ref.split(":", 1)[1].strip())
|
|
40
|
-
except OSError:
|
|
41
|
-
return False
|
|
42
|
-
if not (git_dir / "MERGE_HEAD").exists():
|
|
43
|
-
return False
|
|
44
46
|
rc, staged_sha, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
|
|
45
47
|
rc2, theirs_sha, _ = run(["git", "rev-parse", f"MERGE_HEAD:{path}"], cwd=root)
|
|
46
|
-
|
|
48
|
+
if rc != 0 or rc2 != 0 or staged_sha.strip() != theirs_sha.strip():
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
# The INDEX matching theirs is not enough: `git commit -a` and
|
|
52
|
+
# `git commit -- <path>` commit the WORKING TREE. Exempting on the index
|
|
53
|
+
# alone let an unstaged edit ride along ungated -- reproduced: an appended
|
|
54
|
+
# `DROP TABLE users;` yielded {"allowed": true, "reason": "no watched
|
|
55
|
+
# schema/pool paths in diff"} while `commit -am` would have committed the
|
|
56
|
+
# drop.
|
|
57
|
+
#
|
|
58
|
+
# `git diff --quiet` rather than hashing the file ourselves: it exits 0
|
|
59
|
+
# exactly when the worktree copy matches the index, using git's own
|
|
60
|
+
# comparison. Hashing followed symlinks (so a verbatim merged symlink never
|
|
61
|
+
# matched), failed outright on a dangling link or a sparse/skip-worktree
|
|
62
|
+
# path, and re-ran clean filters on large files against a 5s timeout --
|
|
63
|
+
# every one of those a FALSE BLOCK on a legitimate merge.
|
|
64
|
+
rc3, _, _ = run(["git", "diff", "--quiet", "--", path], cwd=root)
|
|
65
|
+
return rc3 == 0
|
|
47
66
|
|
|
48
67
|
|
|
49
68
|
def touched_watched_paths(root: Path) -> list[str]:
|
|
@@ -52,10 +71,33 @@ def touched_watched_paths(root: Path) -> list[str]:
|
|
|
52
71
|
return []
|
|
53
72
|
rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
|
|
54
73
|
all_files = (out + "\n" + (staged if rc2 == 0 else "")).splitlines()
|
|
55
|
-
|
|
74
|
+
# dict.fromkeys dedupes while preserving order: a file that is both
|
|
75
|
+
# unstaged and staged appeared twice, and the gate's reason line listed it
|
|
76
|
+
# twice ("covers: x, x"), which reads like two files were covered.
|
|
77
|
+
watched = list(dict.fromkeys(f for f in all_files if f and WATCHED_RE.search(f)))
|
|
78
|
+
# Nothing watched is the overwhelmingly common case; probing git there
|
|
79
|
+
# would make the hoist a net cost rather than a saving.
|
|
80
|
+
if not watched or not merge_in_progress(root):
|
|
81
|
+
return watched
|
|
56
82
|
return [f for f in watched if not merge_verbatim(root, f)]
|
|
57
83
|
|
|
58
84
|
|
|
85
|
+
def _parse_marker_ts(raw) -> float | None:
|
|
86
|
+
"""Epoch seconds for a marker timestamp, or None when it cannot be read.
|
|
87
|
+
|
|
88
|
+
Stored as UTC ISO-8601 with a trailing Z; parsed as UTC because
|
|
89
|
+
time.mktime would read them as local time and expire the marker hours
|
|
90
|
+
early or late depending on the host timezone.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(raw, str):
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
import calendar
|
|
96
|
+
return calendar.timegm(time.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S"))
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
59
101
|
def schema_diff_ok(root: Path) -> bool:
|
|
60
102
|
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
61
103
|
if not db.exists():
|
|
@@ -65,31 +107,44 @@ def schema_diff_ok(root: Path) -> bool:
|
|
|
65
107
|
# `uap memory store` writes to `memories` (type 'action'), while older
|
|
66
108
|
# UAP wrote session rows to `session_memories` — accept the marker from
|
|
67
109
|
# either table so the documented remedy actually clears the gate.
|
|
68
|
-
|
|
110
|
+
newest = None
|
|
69
111
|
for table in ("memories", "session_memories"):
|
|
70
112
|
try:
|
|
113
|
+
# ANCHORED to the recorder's fixed prefix. The old
|
|
114
|
+
# '%schema-diff%pass%' matched those substrings anywhere in any
|
|
115
|
+
# memory -- including this gate's OWN refusal text ("...require
|
|
116
|
+
# `uap schema-diff` to pass"), so an agent storing the blocker
|
|
117
|
+
# as a lesson unblocked itself, and a note saying the diff
|
|
118
|
+
# FAILED cleared it just as well. Verified both.
|
|
71
119
|
cur = con.execute(
|
|
72
120
|
f"SELECT timestamp FROM {table} "
|
|
73
|
-
"WHERE content LIKE '
|
|
121
|
+
"WHERE content LIKE 'schema-diff pass: base %' "
|
|
74
122
|
"ORDER BY id DESC LIMIT 1"
|
|
75
123
|
)
|
|
76
124
|
r = cur.fetchone()
|
|
77
|
-
|
|
78
|
-
|
|
125
|
+
# Compare PARSED epochs, not raw strings. The two tables need
|
|
126
|
+
# not share a timestamp format, and a lexicographic winner that
|
|
127
|
+
# then fails to parse returned False without ever considering
|
|
128
|
+
# the runner-up: one legacy row sorting above ISO-8601 (say
|
|
129
|
+
# "2026/08/17", '/' > '-') would out-rank every correct marker
|
|
130
|
+
# forever, and re-running the remedy could not help because it
|
|
131
|
+
# writes to the other table. Gate shut permanently, no waiver.
|
|
132
|
+
# A malformed row is ignored, never authoritative.
|
|
133
|
+
ts_val = _parse_marker_ts(r[0]) if r else None
|
|
134
|
+
if ts_val is not None and (newest is None or ts_val > newest):
|
|
135
|
+
newest = ts_val
|
|
79
136
|
except sqlite3.Error:
|
|
80
137
|
continue
|
|
81
138
|
con.close()
|
|
82
|
-
if
|
|
83
|
-
return False
|
|
84
|
-
try:
|
|
85
|
-
# Timestamps are stored as UTC ISO-8601 (trailing 'Z'); parse them
|
|
86
|
-
# as UTC — time.mktime would misread them as local time and expire
|
|
87
|
-
# the marker hours early (or late) depending on the host TZ.
|
|
88
|
-
import calendar
|
|
89
|
-
ts = calendar.timegm(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
|
|
90
|
-
except Exception: # noqa: BLE001
|
|
139
|
+
if newest is None:
|
|
91
140
|
return False
|
|
92
|
-
|
|
141
|
+
ts = newest
|
|
142
|
+
# Two-sided. The upper bound alone let a FUTURE timestamp clear the
|
|
143
|
+
# gate until wall-clock caught up -- reachable via an importing writer
|
|
144
|
+
# that supplies its own timestamp, or plain clock skew. A small
|
|
145
|
+
# tolerance absorbs sub-second skew between writer and reader.
|
|
146
|
+
age = time.time() - ts
|
|
147
|
+
return -5 <= age < RECENT_SEC
|
|
93
148
|
except sqlite3.Error:
|
|
94
149
|
return False
|
|
95
150
|
|
|
@@ -107,7 +162,10 @@ def main() -> None:
|
|
|
107
162
|
emit(True, "no watched schema/pool paths in diff")
|
|
108
163
|
|
|
109
164
|
if schema_diff_ok(repo_root()):
|
|
110
|
-
|
|
165
|
+
# Says what was checked, not what it "covers". The marker is not scoped
|
|
166
|
+
# to files, so claiming coverage of these specific paths asserted
|
|
167
|
+
# something the gate never computed.
|
|
168
|
+
emit(True, f"recent schema-diff pass on record; watched paths: {', '.join(watched[:5])}")
|
|
111
169
|
|
|
112
170
|
emit(
|
|
113
171
|
False,
|
|
Binary file
|
|
Binary file
|
|
@@ -59,14 +59,21 @@ class SchemaDiffGateTest(unittest.TestCase):
|
|
|
59
59
|
def tearDown(self):
|
|
60
60
|
self._tmp.cleanup()
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# The literal the CLI records (src/cli/schema-diff.ts). The gate matches it
|
|
63
|
+
# ANCHORED, so this string is now a contract between the two.
|
|
64
|
+
MARKER = "schema-diff pass: base HEAD~1 | files: migrations/001_add_table.sql"
|
|
65
|
+
|
|
66
|
+
def write_marker(self, iso_timestamp: str, table: str = "memories", content: str | None = None):
|
|
63
67
|
mem = self.root / "agents" / "data" / "memory"
|
|
64
|
-
mem.mkdir(parents=True)
|
|
68
|
+
mem.mkdir(parents=True, exist_ok=True)
|
|
65
69
|
con = sqlite3.connect(mem / "short_term.db")
|
|
66
|
-
con.execute(
|
|
70
|
+
con.execute(
|
|
71
|
+
f"CREATE TABLE IF NOT EXISTS {table} "
|
|
72
|
+
"(id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)"
|
|
73
|
+
)
|
|
67
74
|
con.execute(
|
|
68
75
|
f"INSERT INTO {table} (content, timestamp) VALUES (?, ?)",
|
|
69
|
-
(
|
|
76
|
+
(self.MARKER if content is None else content, iso_timestamp),
|
|
70
77
|
)
|
|
71
78
|
con.commit()
|
|
72
79
|
con.close()
|
|
@@ -110,9 +117,64 @@ class SchemaDiffGateTest(unittest.TestCase):
|
|
|
110
117
|
self.assertEqual(code, 2)
|
|
111
118
|
self.assertFalse(allowed)
|
|
112
119
|
|
|
120
|
+
def test_the_gates_own_refusal_message_does_not_clear_it(self):
|
|
121
|
+
"""A block message that is itself a valid unblock token is self-defeating.
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
The old matcher was LIKE '%schema-diff%pass%', which the gate's own
|
|
124
|
+
refusal text satisfies ("...require `uap schema-diff` to pass"). Agents
|
|
125
|
+
here are instructed to store lessons, and blockers are exactly what they
|
|
126
|
+
store — so recording the refusal unblocked the next commit.
|
|
127
|
+
"""
|
|
128
|
+
self.write_marker(
|
|
129
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
130
|
+
content=(
|
|
131
|
+
"Lesson: schema-diff-gate: changes to migrations/001_add_table.sql "
|
|
132
|
+
"require `uap schema-diff` to pass (within 1h). Run it and re-commit."
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
code, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
136
|
+
self.assertFalse(allowed, "the gate's own refusal text must not clear the gate")
|
|
137
|
+
self.assertEqual(code, 2)
|
|
138
|
+
|
|
139
|
+
def test_a_marker_from_the_PREVIOUS_cli_release_still_clears_it(self):
|
|
140
|
+
"""Anchoring must not deadlock operators running an older global `uap`.
|
|
141
|
+
|
|
142
|
+
The previously-released recorder wrote
|
|
143
|
+
"schema-diff pass: base <b>, N schema file(s) checked, no breaking
|
|
144
|
+
changes" — same prefix — so it still matches. If a future format change
|
|
145
|
+
drops that prefix, the gate must be updated in the same commit or every
|
|
146
|
+
operator on the old CLI is blocked with no reachable remedy.
|
|
147
|
+
"""
|
|
148
|
+
self.write_marker(
|
|
149
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
150
|
+
content="schema-diff pass: base HEAD~1, 3 schema file(s) checked, no breaking changes",
|
|
151
|
+
)
|
|
152
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
153
|
+
self.assertTrue(allowed, "a marker from the previous CLI release must still clear the gate")
|
|
154
|
+
|
|
155
|
+
def test_a_note_saying_the_diff_FAILED_does_not_clear_it(self):
|
|
156
|
+
self.write_marker(
|
|
157
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
158
|
+
content="schema-diff FAILED - breaking change, do not pass this to review",
|
|
159
|
+
)
|
|
160
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
161
|
+
self.assertFalse(allowed, "a failure note must not read as a pass")
|
|
162
|
+
|
|
163
|
+
def test_a_future_dated_marker_does_not_clear_it(self):
|
|
164
|
+
"""The window was upper-bounded only, so a future timestamp cleared the
|
|
165
|
+
gate until wall-clock caught up — reachable via an importing writer that
|
|
166
|
+
supplies its own timestamp, or plain clock skew."""
|
|
167
|
+
ahead = (datetime.now(timezone.utc) + timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
168
|
+
self.write_marker(ahead)
|
|
169
|
+
_, allowed, _ = run_gate("git-commit", {}, self.root)
|
|
170
|
+
self.assertFalse(allowed, "a future-dated marker must not clear the gate")
|
|
171
|
+
|
|
172
|
+
def test_watched_path_is_listed_once(self):
|
|
173
|
+
"""A file both unstaged and staged appeared twice in the reason line,
|
|
174
|
+
which reads as two covered files."""
|
|
175
|
+
(self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
|
|
176
|
+
_, _, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
|
|
177
|
+
self.assertEqual(reason.count("001_add_table.sql"), 1, reason)
|
|
116
178
|
|
|
117
179
|
|
|
118
180
|
class MergeVerbatimTest(unittest.TestCase):
|
|
@@ -161,9 +223,40 @@ class MergeVerbatimTest(unittest.TestCase):
|
|
|
161
223
|
|
|
162
224
|
def test_merge_edited_migration_still_gates(self):
|
|
163
225
|
# Editing the migration during the merge makes it THIS branch's change.
|
|
226
|
+
# NB this stages the edit; the UNSTAGED variant below is the one that
|
|
227
|
+
# was actually exploitable.
|
|
164
228
|
(self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
|
|
165
229
|
subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
|
|
166
230
|
_, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
|
|
167
231
|
self.assertFalse(allowed, "a migration edited during the merge must still gate")
|
|
168
232
|
self.assertIn("schema-diff", reason)
|
|
169
233
|
|
|
234
|
+
def test_UNSTAGED_merge_edit_still_gates(self):
|
|
235
|
+
"""The exemption compared the INDEX blob, but `git commit -a` commits the
|
|
236
|
+
WORKING TREE.
|
|
237
|
+
|
|
238
|
+
Reproduced against the shipped gate: with the index still matching
|
|
239
|
+
MERGE_HEAD and an unstaged `DROP TABLE users;` appended, it answered
|
|
240
|
+
{"allowed": true, "reason": "no watched schema/pool paths in diff"} —
|
|
241
|
+
not even naming the exemption — while `git commit -am` would have
|
|
242
|
+
committed the drop. This is also the likely ACCIDENTAL path: a human
|
|
243
|
+
resolving a conflict, tweaking a migration, running `git commit -am`.
|
|
244
|
+
"""
|
|
245
|
+
p = self.root / "migrations" / "001_add_table.sql"
|
|
246
|
+
p.write_text(p.read_text() + "\nDROP TABLE t;\n") # deliberately NOT staged
|
|
247
|
+
_, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
|
|
248
|
+
self.assertFalse(
|
|
249
|
+
allowed,
|
|
250
|
+
"an unstaged edit during a merge is committed by `commit -a` and must gate: " + reason,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def test_a_genuinely_verbatim_merge_is_still_exempt_after_the_fix(self):
|
|
254
|
+
"""The deadlock fix must survive the hardening: with no local edit at
|
|
255
|
+
all, the incoming migration stays exempt."""
|
|
256
|
+
_, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
|
|
257
|
+
self.assertTrue(allowed, f"verbatim merge must remain exempt: {reason}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
unittest.main()
|
|
262
|
+
|