@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.
- 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 +118 -2
- package/dist/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +692 -41
- 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 +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +629 -41
- 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 +292 -7
- package/tools/agents/tests/test_schema_diff_inline.py +373 -0
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""schema-diff-gate enforcer: schema/pool changes must pass uap schema-diff."""
|
|
3
3
|
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
4
6
|
import re
|
|
7
|
+
import shutil
|
|
5
8
|
import sqlite3
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
6
11
|
import sys
|
|
7
12
|
import time
|
|
8
13
|
from pathlib import Path
|
|
9
14
|
|
|
10
15
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
11
|
-
from _common import
|
|
16
|
+
from _common import ( # noqa: E402
|
|
17
|
+
_clean_env,
|
|
18
|
+
emit,
|
|
19
|
+
parse_cli,
|
|
20
|
+
repo_root,
|
|
21
|
+
run,
|
|
22
|
+
worktree_root,
|
|
23
|
+
)
|
|
12
24
|
|
|
25
|
+
# DOTALL because `.` otherwise stops at a newline, and a newline is a legal
|
|
26
|
+
# character in a path. `migrations/a\nb.sql` matched nothing, so the gate did
|
|
27
|
+
# not consider it watched and never examined the column drop inside it. Git
|
|
28
|
+
# hides this by C-quoting such names in its default output; the -z enumeration
|
|
29
|
+
# above hands us the real bytes, which is where the gap became visible.
|
|
13
30
|
WATCHED_RE = re.compile(
|
|
14
31
|
r"(migrations/.*\.sql|infra/postgres-spock/|infra/helm_charts/[^/]*pgdog|"
|
|
15
32
|
r"infra/helm_charts/[^/]*cnpg|infra/helm_charts/[^/]*redis|"
|
|
16
33
|
r"infra/helm_charts/[^/]*envoy|infra/helm_charts/[^/]*sentinel)",
|
|
17
|
-
re.I,
|
|
34
|
+
re.I | re.S,
|
|
18
35
|
)
|
|
19
36
|
# NOTE: bare "Bash" used to be in this set, which made EVERY shell command a
|
|
20
37
|
# gate point — including the `uap schema-diff` remedy itself (self-deadlock).
|
|
@@ -23,6 +40,19 @@ COMMIT_OPS = {"git-commit", "git commit"}
|
|
|
23
40
|
RECENT_SEC = 3600
|
|
24
41
|
|
|
25
42
|
|
|
43
|
+
def merge_in_progress(root: Path) -> bool:
|
|
44
|
+
"""Whether a merge is underway — invocation-constant, so resolved ONCE.
|
|
45
|
+
|
|
46
|
+
This used to be re-derived per watched file, spawning a `git rev-parse`
|
|
47
|
+
for each one on the hot path of every commit and push, purely to learn
|
|
48
|
+
"not a merge". A merge touching 30 migrations paid 30 spawns.
|
|
49
|
+
"""
|
|
50
|
+
rc, git_dir_out, _ = run(["git", "rev-parse", "--absolute-git-dir"], cwd=root)
|
|
51
|
+
if rc != 0:
|
|
52
|
+
return False
|
|
53
|
+
return (Path(git_dir_out.strip()) / "MERGE_HEAD").exists()
|
|
54
|
+
|
|
55
|
+
|
|
26
56
|
def merge_verbatim(root: Path, path: str) -> bool:
|
|
27
57
|
"""During a merge, a staged watched file that is byte-identical to the
|
|
28
58
|
incoming MERGE_HEAD version was not authored on this branch — it was
|
|
@@ -30,68 +60,501 @@ def merge_verbatim(root: Path, path: str) -> bool:
|
|
|
30
60
|
forced a schema-diff re-pass for content the merge cannot change (hit on
|
|
31
61
|
the 2026-08-16 pay2u #3153 conflict-resolution merge, where migrations
|
|
32
62
|
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
63
|
rc, staged_sha, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
|
|
45
64
|
rc2, theirs_sha, _ = run(["git", "rev-parse", f"MERGE_HEAD:{path}"], cwd=root)
|
|
46
|
-
|
|
65
|
+
if rc != 0 or rc2 != 0 or staged_sha.strip() != theirs_sha.strip():
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
# The INDEX matching theirs is not enough: `git commit -a` and
|
|
69
|
+
# `git commit -- <path>` commit the WORKING TREE. Exempting on the index
|
|
70
|
+
# alone let an unstaged edit ride along ungated -- reproduced: an appended
|
|
71
|
+
# `DROP TABLE users;` yielded {"allowed": true, "reason": "no watched
|
|
72
|
+
# schema/pool paths in diff"} while `commit -am` would have committed the
|
|
73
|
+
# drop.
|
|
74
|
+
#
|
|
75
|
+
# `git diff --quiet` rather than hashing the file ourselves: it exits 0
|
|
76
|
+
# exactly when the worktree copy matches the index, using git's own
|
|
77
|
+
# comparison. Hashing followed symlinks (so a verbatim merged symlink never
|
|
78
|
+
# matched), failed outright on a dangling link or a sparse/skip-worktree
|
|
79
|
+
# path, and re-ran clean filters on large files against a 5s timeout --
|
|
80
|
+
# every one of those a FALSE BLOCK on a legitimate merge.
|
|
81
|
+
rc3, _, _ = run(["git", "diff", "--quiet", "--", path], cwd=root)
|
|
82
|
+
return rc3 == 0
|
|
47
83
|
|
|
48
84
|
|
|
49
85
|
def touched_watched_paths(root: Path) -> list[str]:
|
|
50
|
-
|
|
86
|
+
"""Watched paths in the pending change, as their real bytes.
|
|
87
|
+
|
|
88
|
+
`-z` rather than plain --name-only: git C-quotes any path containing a
|
|
89
|
+
newline, tab, quote, backslash or non-ASCII byte, emitting the literal
|
|
90
|
+
string `"migrations/a\\nb.sql"` -- quotes and escapes included. That string
|
|
91
|
+
was forwarded to the checker as a filename, no such file could be opened,
|
|
92
|
+
the checker reported it clean, and the gate allowed a column drop.
|
|
93
|
+
Reproduced with newline, tab, quote, backslash and unicode names. `-z`
|
|
94
|
+
emits NUL-terminated raw paths and never quotes.
|
|
95
|
+
"""
|
|
96
|
+
rc, out, _ = run(["git", "diff", "--name-only", "-z", "HEAD"], cwd=root)
|
|
51
97
|
if rc != 0:
|
|
52
98
|
return []
|
|
53
|
-
rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
|
|
54
|
-
all_files = (out + "\
|
|
55
|
-
|
|
99
|
+
rc2, staged, _ = run(["git", "diff", "--name-only", "-z", "--cached"], cwd=root)
|
|
100
|
+
all_files = (out + "\0" + (staged if rc2 == 0 else "")).split("\0")
|
|
101
|
+
# dict.fromkeys dedupes while preserving order: a file that is both
|
|
102
|
+
# unstaged and staged appeared twice, and the gate's reason line listed it
|
|
103
|
+
# twice ("covers: x, x"), which reads like two files were covered.
|
|
104
|
+
watched = list(dict.fromkeys(f for f in all_files if f and WATCHED_RE.search(f)))
|
|
105
|
+
# Nothing watched is the overwhelmingly common case; probing git there
|
|
106
|
+
# would make the hoist a net cost rather than a saving.
|
|
107
|
+
if not watched or not merge_in_progress(root):
|
|
108
|
+
return watched
|
|
56
109
|
return [f for f in watched if not merge_verbatim(root, f)]
|
|
57
110
|
|
|
58
111
|
|
|
59
|
-
def
|
|
112
|
+
def _parse_marker_ts(raw) -> float | None:
|
|
113
|
+
"""Epoch seconds for a marker timestamp, or None when it cannot be read.
|
|
114
|
+
|
|
115
|
+
Stored as UTC ISO-8601 with a trailing Z; parsed as UTC because
|
|
116
|
+
time.mktime would read them as local time and expire the marker hours
|
|
117
|
+
early or late depending on the host timezone.
|
|
118
|
+
"""
|
|
119
|
+
if not isinstance(raw, str):
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
import calendar
|
|
123
|
+
return calendar.timegm(time.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S"))
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_HEX_RE = re.compile(r"^[0-9a-f]{40}$|^[0-9a-f]{64}$")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def marker_files(content: str) -> dict | None:
|
|
132
|
+
"""{path: sha7} from a content-scoped marker, or None if it is not one.
|
|
133
|
+
|
|
134
|
+
None means "cannot judge coverage": a legacy marker (written before this
|
|
135
|
+
change, or by an older installed CLI), or one whose file list was truncated
|
|
136
|
+
because the change was enormous. Callers must fall back to the time window
|
|
137
|
+
rather than block — an operator running the documented remedy has no way to
|
|
138
|
+
produce a covering marker for either case, and this gate has already
|
|
139
|
+
self-deadlocked three times.
|
|
140
|
+
"""
|
|
141
|
+
_, sep, tail = content.partition("| files: ")
|
|
142
|
+
if not sep or tail.startswith("("): # "(none changed)" / "(truncated: N files)"
|
|
143
|
+
return None
|
|
144
|
+
entries = [e.strip() for e in tail.split(",") if e.strip()]
|
|
145
|
+
scoped = {}
|
|
146
|
+
identified = 0
|
|
147
|
+
for e in entries:
|
|
148
|
+
path, at, sha = e.rpartition("@")
|
|
149
|
+
if at and _HEX_RE.match(sha):
|
|
150
|
+
scoped[path] = sha
|
|
151
|
+
identified += 1
|
|
152
|
+
else:
|
|
153
|
+
# A bare or unparsable entry means THIS PATH has no verified
|
|
154
|
+
# identity -- not that the whole marker is legacy. Returning None
|
|
155
|
+
# here let one `git rm` (its path cannot be hashed, so it renders
|
|
156
|
+
# bare) disable content scoping for the entire commit, and let a
|
|
157
|
+
# filename containing the delimiters inject a forged entry that
|
|
158
|
+
# overwrote a real one. Record the path with no sha so
|
|
159
|
+
# uncovered_paths treats it as uncovered.
|
|
160
|
+
scoped.setdefault(e.rpartition("@")[0] or e, "")
|
|
161
|
+
# Only a marker with NO identified entry at all is a legacy marker.
|
|
162
|
+
return scoped if identified else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def uncovered_paths(root: Path, watched: list, scoped: dict, use_worktree: bool = False) -> list:
|
|
166
|
+
"""Watched paths whose CURRENT bytes the marker does not vouch for.
|
|
167
|
+
|
|
168
|
+
Only paths we can positively hash are judged. One that cannot be hashed
|
|
169
|
+
(deleted, unreadable, sparse) is left out: the CLI could not have recorded
|
|
170
|
+
it either, so blocking on it would be unclearable.
|
|
171
|
+
|
|
172
|
+
One `hash-object` per watched path. The watched set is what the commit
|
|
173
|
+
actually touches -- a handful of files -- and _common.run() has no stdin
|
|
174
|
+
channel to batch through.
|
|
175
|
+
"""
|
|
176
|
+
bad = []
|
|
177
|
+
for path in watched:
|
|
178
|
+
# THE version this commit will store -- not every version that exists.
|
|
179
|
+
# `git commit` stores the INDEX; `git commit -a` stores the WORKTREE.
|
|
180
|
+
# Requiring one recorded sha to equal BOTH made ordinary partial
|
|
181
|
+
# staging (`git add -p`, or staging then continuing to edit) an
|
|
182
|
+
# unclearable block: one value cannot equal two, and re-running the
|
|
183
|
+
# remedy reproduced the same marker. Verified before this fix.
|
|
184
|
+
actual = ""
|
|
185
|
+
if not use_worktree:
|
|
186
|
+
rc, staged, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
|
|
187
|
+
if rc == 0:
|
|
188
|
+
actual = staged.strip()
|
|
189
|
+
if not actual and (root / path).is_file():
|
|
190
|
+
rc2, wt, _ = run(["git", "hash-object", "--", path], cwd=root)
|
|
191
|
+
if rc2 == 0:
|
|
192
|
+
actual = wt.strip()
|
|
193
|
+
if not actual:
|
|
194
|
+
continue # nothing hashable: unknown, never a block
|
|
195
|
+
if scoped.get(path) != actual:
|
|
196
|
+
bad.append(path)
|
|
197
|
+
return bad
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def schema_diff_ok(root: Path) -> list | None:
|
|
201
|
+
"""Contents of every in-window pass marker, or None if there are none.
|
|
202
|
+
|
|
203
|
+
A LIST, because concurrent worktrees share one database and any one of
|
|
204
|
+
their markers may be the one that covers the bytes in front of us. The
|
|
205
|
+
freshness window is applied here, per candidate.
|
|
206
|
+
|
|
207
|
+
Every miss must return None, not False: main() consumes this value, and a
|
|
208
|
+
stray bool crashed the enforcer -- which the policy hook turns into ALLOW,
|
|
209
|
+
so a wrong type here opens the gate rather than closing it.
|
|
210
|
+
"""
|
|
60
211
|
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
61
212
|
if not db.exists():
|
|
62
|
-
return
|
|
213
|
+
return None
|
|
63
214
|
try:
|
|
64
215
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
|
|
65
216
|
# `uap memory store` writes to `memories` (type 'action'), while older
|
|
66
217
|
# UAP wrote session rows to `session_memories` — accept the marker from
|
|
67
218
|
# either table so the documented remedy actually clears the gate.
|
|
68
|
-
|
|
219
|
+
newest = None
|
|
220
|
+
candidates: list = []
|
|
69
221
|
for table in ("memories", "session_memories"):
|
|
70
222
|
try:
|
|
223
|
+
# ANCHORED to the recorder's fixed prefix. The old
|
|
224
|
+
# '%schema-diff%pass%' matched those substrings anywhere in any
|
|
225
|
+
# memory -- including this gate's OWN refusal text ("...require
|
|
226
|
+
# `uap schema-diff` to pass"), so an agent storing the blocker
|
|
227
|
+
# as a lesson unblocked itself, and a note saying the diff
|
|
228
|
+
# FAILED cleared it just as well. Verified both.
|
|
229
|
+
# ALL recent markers, not just the newest. Every worktree
|
|
230
|
+
# writes to the same main-checkout database, so with LIMIT 1 two
|
|
231
|
+
# concurrent worktrees invalidated each other's markers forever:
|
|
232
|
+
# A records, B records, A is blocked by B's marker, A re-records,
|
|
233
|
+
# B is blocked. A livelock this repo's whole .worktrees workflow
|
|
234
|
+
# would hit, and a regression versus the old time-only rule.
|
|
71
235
|
cur = con.execute(
|
|
72
|
-
f"SELECT timestamp FROM {table} "
|
|
73
|
-
"WHERE content LIKE '
|
|
74
|
-
"ORDER BY id DESC LIMIT
|
|
236
|
+
f"SELECT timestamp, content FROM {table} "
|
|
237
|
+
"WHERE content LIKE 'schema-diff pass: base %' "
|
|
238
|
+
"ORDER BY id DESC LIMIT 20"
|
|
75
239
|
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
240
|
+
_rows = cur.fetchall()
|
|
241
|
+
# Compare PARSED epochs, not raw strings. The two tables need
|
|
242
|
+
# not share a timestamp format, and a lexicographic winner that
|
|
243
|
+
# then fails to parse returned False without ever considering
|
|
244
|
+
# the runner-up: one legacy row sorting above ISO-8601 (say
|
|
245
|
+
# "2026/08/17", '/' > '-') would out-rank every correct marker
|
|
246
|
+
# forever, and re-running the remedy could not help because it
|
|
247
|
+
# writes to the other table. Gate shut permanently, no waiver.
|
|
248
|
+
# A malformed row is ignored, never authoritative.
|
|
249
|
+
for r in _rows:
|
|
250
|
+
ts_val = _parse_marker_ts(r[0])
|
|
251
|
+
if ts_val is None:
|
|
252
|
+
continue
|
|
253
|
+
age = time.time() - ts_val
|
|
254
|
+
if not (-5 <= age < RECENT_SEC):
|
|
255
|
+
continue
|
|
256
|
+
candidates.append(r[1] if len(r) > 1 else "")
|
|
257
|
+
if newest is None or ts_val > newest:
|
|
258
|
+
newest = ts_val
|
|
79
259
|
except sqlite3.Error:
|
|
80
260
|
continue
|
|
81
261
|
con.close()
|
|
82
|
-
if not
|
|
83
|
-
return
|
|
84
|
-
|
|
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
|
|
91
|
-
return False
|
|
92
|
-
return (time.time() - ts) < RECENT_SEC
|
|
262
|
+
if not candidates:
|
|
263
|
+
return None
|
|
264
|
+
return candidates
|
|
93
265
|
except sqlite3.Error:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
INLINE_GUARD = "UAP_SCHEMA_DIFF_INLINE"
|
|
270
|
+
# Comfortably inside policy-tools' 30s enforcer budget. At 60s a slow checker
|
|
271
|
+
# meant the ENFORCER was killed first, and a killed enforcer is an ALLOW -- so
|
|
272
|
+
# the timeout meant to produce a safe fallback produced a bypass instead. 400
|
|
273
|
+
# watched files measured at 6.3s, so this leaves ample headroom.
|
|
274
|
+
INLINE_TIMEOUT = 15.0
|
|
275
|
+
# Verdict shapes this gate knows how to read (SCHEMA_DIFF_CONTRACT in the CLI).
|
|
276
|
+
KNOWN_CONTRACTS = (1,)
|
|
277
|
+
# Passed to the checker. Everything else -- API keys, tokens, proxy secrets --
|
|
278
|
+
# is withheld: the child is a subprocess chosen partly by filesystem contents,
|
|
279
|
+
# and it has no business holding the agent's credentials.
|
|
280
|
+
CHILD_ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "NODE_OPTIONS", "NODE_PATH")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def divergent_paths(root: Path, watched: list) -> list:
|
|
284
|
+
"""Watched paths whose worktree copy differs from the index.
|
|
285
|
+
|
|
286
|
+
`git diff --quiet -- <path>` exits 0 exactly when the two match, using
|
|
287
|
+
git's own comparison rather than a hash (which followed symlinks, failed on
|
|
288
|
+
dangling links and sparse paths, and re-ran clean filters).
|
|
289
|
+
"""
|
|
290
|
+
out = []
|
|
291
|
+
for path in watched:
|
|
292
|
+
rc, _, _ = run(["git", "diff", "--quiet", "--", path], cwd=root)
|
|
293
|
+
if rc != 0:
|
|
294
|
+
out.append(path)
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def sources_to_check(root: Path, watched: list) -> list:
|
|
299
|
+
"""Which byte-sources this commit could possibly store.
|
|
300
|
+
|
|
301
|
+
Previously this was inferred from the command string, and the inference was
|
|
302
|
+
wrong in both directions -- each direction a demonstrated bypass:
|
|
303
|
+
|
|
304
|
+
* MISSED worktree forms. `git commit -- <path>`, `--only`, `--include`,
|
|
305
|
+
`-o` and `-i` all store the WORKTREE copy of the named paths. None
|
|
306
|
+
contains `-a` or `--all`, so the gate checked the index while git
|
|
307
|
+
committed the worktree. Confirmed by committing: HEAD afterwards held
|
|
308
|
+
the dropped column the gate had just cleared.
|
|
309
|
+
* FALSE worktree matches. The command is lowercased whole, so the `-A` in
|
|
310
|
+
`git add -A && git commit -m x` matched the short-flag pattern and the
|
|
311
|
+
gate read the WORKTREE for a commit that stores the INDEX. Stage the
|
|
312
|
+
break, restore the file on disk, commit: allowed.
|
|
313
|
+
|
|
314
|
+
Enumerating git's commit forms correctly is the trap, not the fix -- the
|
|
315
|
+
space is larger than it looks and the file had already litigated it twice.
|
|
316
|
+
There are only ever two candidate byte-strings per path. When they are
|
|
317
|
+
identical the question is moot; when they differ, check BOTH and refuse if
|
|
318
|
+
either is breaking. That removes the inference, and with it the whole class.
|
|
319
|
+
|
|
320
|
+
The cost is a second checker run only when a watched schema file is staged
|
|
321
|
+
and then edited again -- and in that state a breaking worktree copy is worth
|
|
322
|
+
surfacing regardless of which form the operator eventually types.
|
|
323
|
+
"""
|
|
324
|
+
return ["index", "worktree"] if divergent_paths(root, watched) else ["index"]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def is_uap_checkout(root: Path) -> bool:
|
|
328
|
+
"""Is `root` the UAP repo itself, rather than a project that uses UAP?"""
|
|
329
|
+
try:
|
|
330
|
+
pkg = json.loads((root / "package.json").read_text())
|
|
331
|
+
except Exception: # noqa: BLE001 - missing, unreadable, not JSON
|
|
332
|
+
return False
|
|
333
|
+
return isinstance(pkg, dict) and pkg.get("name") == "@miller-tech/uap"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def cli_argv(root: Path) -> list | None:
|
|
337
|
+
"""How to invoke the checker.
|
|
338
|
+
|
|
339
|
+
The INSTALLED cli first, deliberately. Preferring `<root>/dist/bin/cli.js`
|
|
340
|
+
handed the verdict to a file inside the tree being gated: `dist/` is
|
|
341
|
+
gitignored build output, is on self-protect's "reconstructible, do not
|
|
342
|
+
guard" list, and is a conventional path in any Node project. Three lines
|
|
343
|
+
written there and the gate reports whatever they print -- an easier forgery
|
|
344
|
+
than the marker row this design replaced, and in a consumer repo it means
|
|
345
|
+
committing a migration executes that project's unrelated build output.
|
|
346
|
+
|
|
347
|
+
The local build is still preferred when `root` is the UAP checkout itself,
|
|
348
|
+
because there developing the checker and running the gate are the same act
|
|
349
|
+
and an installed release would be the stale copy. That is a deliberate,
|
|
350
|
+
narrow exception: in the UAP repo, `dist/` is already what `npm i -g .`
|
|
351
|
+
installs.
|
|
352
|
+
"""
|
|
353
|
+
uap = shutil.which("uap")
|
|
354
|
+
node = shutil.which("node")
|
|
355
|
+
local = root / "dist" / "bin" / "cli.js"
|
|
356
|
+
if is_uap_checkout(root) and local.is_file() and node:
|
|
357
|
+
return [node, str(local)]
|
|
358
|
+
if uap:
|
|
359
|
+
return [uap]
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def blob_sha(root: Path, path: str, source: str) -> str:
|
|
364
|
+
"""The blob the given source holds for `path`, or "" if there is none."""
|
|
365
|
+
if source == "index":
|
|
366
|
+
rc, out, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
|
|
367
|
+
else:
|
|
368
|
+
rc, out, _ = run(["git", "hash-object", "--", path], cwd=root)
|
|
369
|
+
out = (out or "").strip()
|
|
370
|
+
return out if rc == 0 and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", out) else ""
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def inline_schema_diff(root: Path, watched: list, source: str) -> list | None:
|
|
374
|
+
"""Run the checker over `watched` and return the breaking findings.
|
|
375
|
+
|
|
376
|
+
Returns a list (empty means "checked, nothing breaking"), or None meaning
|
|
377
|
+
"no answer" -- and the caller must then fall back rather than read silence
|
|
378
|
+
as a pass.
|
|
379
|
+
|
|
380
|
+
None covers every kind of not-answered: no CLI, a crash, a timeout,
|
|
381
|
+
unparseable output, an unknown contract, ran=false, a missing entry, a
|
|
382
|
+
file the checker could not read, or one no analyser understood. That last
|
|
383
|
+
pair is what the first version got wrong. It asked only whether the paths
|
|
384
|
+
it sent came back -- a comparison between two copies of its own argument,
|
|
385
|
+
which could fail on a whitespace artefact and nothing else. Meanwhile a
|
|
386
|
+
helm chart (no analyser exists), a file git C-quoted (could not be opened)
|
|
387
|
+
and a brand-new migration all returned an empty change list, and the gate
|
|
388
|
+
published that as "no breaking changes".
|
|
389
|
+
"""
|
|
390
|
+
argv = cli_argv(root)
|
|
391
|
+
if argv is None:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
env = {k: v for k, v in _clean_env().items() if k in CHILD_ENV_KEEP}
|
|
395
|
+
# Tells a nested gate to skip its own inline run. NOT an allow: forging
|
|
396
|
+
# this must cost an attacker nothing more than the fallback they could
|
|
397
|
+
# have had anyway.
|
|
398
|
+
env[INLINE_GUARD] = "1"
|
|
399
|
+
|
|
400
|
+
tmp = None
|
|
401
|
+
try:
|
|
402
|
+
# NUL-separated via a file, so no filename can corrupt the list --
|
|
403
|
+
# a comma split one path into two, and git's C-quoting of newlines and
|
|
404
|
+
# unicode produced paths that do not exist.
|
|
405
|
+
fd, tmp = tempfile.mkstemp(prefix="uap-schema-paths-")
|
|
406
|
+
with os.fdopen(fd, "w") as fh:
|
|
407
|
+
fh.write("\0".join(watched))
|
|
408
|
+
proc = subprocess.run(
|
|
409
|
+
argv
|
|
410
|
+
+ [
|
|
411
|
+
"schema-diff",
|
|
412
|
+
"--json",
|
|
413
|
+
"--base",
|
|
414
|
+
"HEAD",
|
|
415
|
+
"--paths-from",
|
|
416
|
+
tmp,
|
|
417
|
+
"--source",
|
|
418
|
+
source,
|
|
419
|
+
],
|
|
420
|
+
cwd=str(root),
|
|
421
|
+
env=env,
|
|
422
|
+
capture_output=True,
|
|
423
|
+
text=True,
|
|
424
|
+
timeout=INLINE_TIMEOUT,
|
|
425
|
+
)
|
|
426
|
+
except Exception: # noqa: BLE001 - timeout, ENOENT, anything
|
|
427
|
+
return None
|
|
428
|
+
finally:
|
|
429
|
+
if tmp:
|
|
430
|
+
try:
|
|
431
|
+
os.unlink(tmp)
|
|
432
|
+
except OSError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
lines = [ln for ln in (proc.stdout or "").splitlines() if ln.strip()]
|
|
436
|
+
if not lines:
|
|
437
|
+
return None
|
|
438
|
+
try:
|
|
439
|
+
verdict = json.loads(lines[-1])
|
|
440
|
+
except Exception: # noqa: BLE001
|
|
441
|
+
return None
|
|
442
|
+
if not isinstance(verdict, dict):
|
|
443
|
+
return None
|
|
444
|
+
if verdict.get("contract") not in KNOWN_CONTRACTS:
|
|
445
|
+
return None
|
|
446
|
+
if verdict.get("ran") is not True:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
entries = verdict.get("files")
|
|
450
|
+
if not isinstance(entries, list):
|
|
451
|
+
return None
|
|
452
|
+
by_path = {}
|
|
453
|
+
for entry in entries:
|
|
454
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
455
|
+
return None
|
|
456
|
+
by_path[entry["path"]] = entry
|
|
457
|
+
|
|
458
|
+
findings = []
|
|
459
|
+
for path in watched:
|
|
460
|
+
entry = by_path.get(path)
|
|
461
|
+
if entry is None:
|
|
462
|
+
return None
|
|
463
|
+
# It must have read the same bytes this commit will store. The sha is
|
|
464
|
+
# re-derived here rather than taken on trust.
|
|
465
|
+
expected = blob_sha(root, path, source)
|
|
466
|
+
if entry.get("sha") != expected:
|
|
467
|
+
return None
|
|
468
|
+
# "Nothing breaking" is only meaningful from an analyser that
|
|
469
|
+
# understood the file.
|
|
470
|
+
if entry.get("analysed") is not True:
|
|
471
|
+
return None
|
|
472
|
+
if not expected:
|
|
473
|
+
# This source holds no blob for the path -- the commit REMOVES it.
|
|
474
|
+
# An empty sha is therefore not "could not read"; it is the
|
|
475
|
+
# deletion itself, and the checker must have reported it as
|
|
476
|
+
# breaking. If it did not, it was not looking at a deletion and we
|
|
477
|
+
# have no idea what it was looking at.
|
|
478
|
+
if not entry.get("breaking"):
|
|
479
|
+
return None
|
|
480
|
+
breaking = entry.get("breaking")
|
|
481
|
+
if not isinstance(breaking, list):
|
|
482
|
+
return None
|
|
483
|
+
if breaking:
|
|
484
|
+
findings.append(f"{path}: " + "; ".join(str(c) for c in breaking[:3]))
|
|
485
|
+
return findings
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def breaking_waived(root: Path, paths: list) -> bool:
|
|
489
|
+
"""A committed waiver that NAMES the paths whose break is intended.
|
|
490
|
+
|
|
491
|
+
Three properties, each of which the first version lacked and each of which
|
|
492
|
+
was demonstrated to matter:
|
|
493
|
+
|
|
494
|
+
committed -- `git cat-file -e HEAD:<path>`. The previous check was
|
|
495
|
+
`Path.is_file()` on the working tree, and policies/waivers/ is on
|
|
496
|
+
self-protect's PROTECTED_EXEMPT list precisely so agents CAN write
|
|
497
|
+
there (the carve-out was justified when a waiver only satisfied
|
|
498
|
+
expert-review). `touch policies/waivers/x-schema-diff.md` -- an empty
|
|
499
|
+
file, never committed -- cleared every breaking change. Verified.
|
|
500
|
+
scoped -- the waiver must name each path it excuses, so one file cannot
|
|
501
|
+
silently disarm the gate repo-wide and permanently.
|
|
502
|
+
resolved against the WORKTREE -- the refusal tells the operator to write
|
|
503
|
+
this file, this project mandates that all edits happen in a worktree,
|
|
504
|
+
and the previous version read the main checkout, where the file they
|
|
505
|
+
just wrote does not exist. That is the repo_root()/worktree_root()
|
|
506
|
+
substitution already fixed twice, in expert_review_required and
|
|
507
|
+
local_build_before_push.
|
|
508
|
+
"""
|
|
509
|
+
waivers = root / "policies" / "waivers"
|
|
510
|
+
if not waivers.is_dir() or not paths:
|
|
511
|
+
return False
|
|
512
|
+
for waiver in sorted(waivers.glob("*schema-diff*.md")):
|
|
513
|
+
rel = waiver.relative_to(root).as_posix()
|
|
514
|
+
rc, _, _ = run(["git", "cat-file", "-e", f"HEAD:{rel}"], cwd=root)
|
|
515
|
+
if rc != 0:
|
|
516
|
+
continue # uncommitted: not reviewable, not a waiver
|
|
517
|
+
try:
|
|
518
|
+
body = waiver.read_text(errors="replace")
|
|
519
|
+
except OSError:
|
|
520
|
+
continue
|
|
521
|
+
if all(p in body for p in paths):
|
|
522
|
+
return True
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def legacy_marker_covers(marker: str, named: str, watched: list) -> bool:
|
|
527
|
+
"""Does a marker with no per-file SHAs excuse these paths?
|
|
528
|
+
|
|
529
|
+
Three shapes, three answers -- the previous version treated the first two
|
|
530
|
+
as one and got both wrong in turn:
|
|
531
|
+
|
|
532
|
+
`(none changed)` The run examined ZERO files. It is evidence of the
|
|
533
|
+
opposite of coverage, and accepting it made
|
|
534
|
+
`git stash && uap schema-diff && git stash pop` into an hour-long
|
|
535
|
+
skeleton key needing no database tampering at all.
|
|
536
|
+
`(truncated: N files)` The run examined N files but the list was too
|
|
537
|
+
long to record. Coverage is unknowable, not absent; refusing it would
|
|
538
|
+
block a large legitimate change with no way to make the list shorter.
|
|
539
|
+
no `| files: ` section The previous CLI release's format. Refusing it
|
|
540
|
+
deadlocks every operator still on that release: the remedy the gate
|
|
541
|
+
prints runs their CLI, which writes exactly this format again. Where
|
|
542
|
+
that format states a count, honour it -- "0 schema file(s) checked" is
|
|
543
|
+
`(none changed)` wearing an older coat.
|
|
544
|
+
"""
|
|
545
|
+
if named.startswith("(none"):
|
|
94
546
|
return False
|
|
547
|
+
if named.startswith("("):
|
|
548
|
+
return True # (truncated: N files)
|
|
549
|
+
if named:
|
|
550
|
+
listed = {e.strip() for e in named.split(",") if e.strip()}
|
|
551
|
+
# Exact membership, not substring: `migrations/1.sql` was "covered" by
|
|
552
|
+
# a marker naming `migrations/1.sql.bak`.
|
|
553
|
+
return set(watched) <= listed
|
|
554
|
+
count = re.search(r"(\d+)\s+schema file\(s\) checked", marker)
|
|
555
|
+
if count:
|
|
556
|
+
return int(count.group(1)) > 0
|
|
557
|
+
return True # genuinely unknown legacy shape: PRESERVE over deadlock
|
|
95
558
|
|
|
96
559
|
|
|
97
560
|
def main() -> None:
|
|
@@ -106,8 +569,117 @@ def main() -> None:
|
|
|
106
569
|
if not watched:
|
|
107
570
|
emit(True, "no watched schema/pool paths in diff")
|
|
108
571
|
|
|
109
|
-
|
|
110
|
-
|
|
572
|
+
# Run the checker rather than look for evidence that someone ran it.
|
|
573
|
+
#
|
|
574
|
+
# The stored-marker design could never close the gap between what a past
|
|
575
|
+
# run examined and what this commit contains: three review rounds went into
|
|
576
|
+
# binding a marker to content, commit form, and freshness, and each fix
|
|
577
|
+
# exposed the next seam. Running the check here removes the gap instead of
|
|
578
|
+
# narrowing it -- there is no interval in which the recorded bytes and the
|
|
579
|
+
# committed bytes can diverge, because they are the same read.
|
|
580
|
+
# Inside the gate's own checker run, skip the inline layer -- otherwise a
|
|
581
|
+
# git hook that re-entered the policy layer would call the gate, which
|
|
582
|
+
# would call the checker again. Deliberately NOT an allow: this env var
|
|
583
|
+
# travels with the shell, and the first version emitted allowed:true here
|
|
584
|
+
# before looking at anything, which made `UAP_SCHEMA_DIFF_INLINE=1` a
|
|
585
|
+
# complete off-switch for a security control. Skipping to the fallback
|
|
586
|
+
# costs a forger exactly what they already had.
|
|
587
|
+
inline_ok = os.environ.get(INLINE_GUARD) != "1"
|
|
588
|
+
|
|
589
|
+
findings = []
|
|
590
|
+
answered = False
|
|
591
|
+
checked = []
|
|
592
|
+
if inline_ok:
|
|
593
|
+
for source in sources_to_check(worktree_root(), watched):
|
|
594
|
+
result = inline_schema_diff(worktree_root(), watched, source)
|
|
595
|
+
if result is None:
|
|
596
|
+
answered = False
|
|
597
|
+
break
|
|
598
|
+
answered = True
|
|
599
|
+
checked.append(source)
|
|
600
|
+
# Attribute the finding. When the index is clean and an unstaged
|
|
601
|
+
# edit is the breaking one, the operator needs to be told that --
|
|
602
|
+
# otherwise the refusal looks like it is about the change they
|
|
603
|
+
# just staged, and the obvious remedies (stash it, finish it,
|
|
604
|
+
# stage it) are not obvious at all.
|
|
605
|
+
findings.extend(f"[{source}] {f}" for f in result)
|
|
606
|
+
|
|
607
|
+
if answered:
|
|
608
|
+
where = " and ".join(checked)
|
|
609
|
+
if not findings:
|
|
610
|
+
emit(
|
|
611
|
+
True,
|
|
612
|
+
f"schema-diff ran on the {where} content of "
|
|
613
|
+
+ ", ".join(watched[:5])
|
|
614
|
+
+ ": no breaking changes",
|
|
615
|
+
)
|
|
616
|
+
broken = sorted({f.split("] ", 1)[1].split(":", 1)[0] for f in findings})
|
|
617
|
+
if breaking_waived(worktree_root(), broken):
|
|
618
|
+
emit(
|
|
619
|
+
True,
|
|
620
|
+
"breaking schema change waived by a committed "
|
|
621
|
+
"policies/waivers/*schema-diff*.md naming "
|
|
622
|
+
+ ", ".join(broken[:5])
|
|
623
|
+
+ ": "
|
|
624
|
+
+ " | ".join(findings[:5]),
|
|
625
|
+
)
|
|
626
|
+
emit(
|
|
627
|
+
False,
|
|
628
|
+
f"schema-diff-gate: BREAKING schema change in the {where} content -- "
|
|
629
|
+
+ " | ".join(findings[:5])
|
|
630
|
+
+ ". Make it additive (nullable, or NOT NULL with a DEFAULT), or commit a"
|
|
631
|
+
+ " policies/waivers/<name>-schema-diff.md naming "
|
|
632
|
+
+ ", ".join(broken[:3])
|
|
633
|
+
+ ".",
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# The checker could not answer -- no CLI, a crash, a timeout, or an
|
|
637
|
+
# examined set that did not cover everything watched. Fall through to the
|
|
638
|
+
# behaviour that shipped: a recorded pass, or a refusal naming the remedy.
|
|
639
|
+
# The inline check only ever ADDS precision on top of this; it never
|
|
640
|
+
# subtracts a refusal, because a checker that cannot run is not evidence
|
|
641
|
+
# that the change is safe.
|
|
642
|
+
markers = schema_diff_ok(repo_root())
|
|
643
|
+
if markers:
|
|
644
|
+
stale = None
|
|
645
|
+
for marker in markers:
|
|
646
|
+
scoped = marker_files(marker)
|
|
647
|
+
if scoped is None:
|
|
648
|
+
# LEGACY or truncated marker (older CLI, or a change too large
|
|
649
|
+
# to enumerate): time-window only, as before this change
|
|
650
|
+
# (PRESERVE). It excuses only what it NAMES -- a blanket allow
|
|
651
|
+
# meant one un-upgraded CLI anywhere in the fleet switched
|
|
652
|
+
# content scoping off repo-wide for an hour.
|
|
653
|
+
named = marker.partition("| files: ")[2]
|
|
654
|
+
if legacy_marker_covers(marker, named, watched):
|
|
655
|
+
emit(
|
|
656
|
+
True,
|
|
657
|
+
"recent schema-diff pass on record; watched paths: "
|
|
658
|
+
+ ", ".join(watched[:5]),
|
|
659
|
+
)
|
|
660
|
+
continue
|
|
661
|
+
# Same reasoning as sources_to_check: when the index and the
|
|
662
|
+
# worktree disagree, a marker must vouch for BOTH, because the
|
|
663
|
+
# command form that decides between them cannot be read reliably
|
|
664
|
+
# off the command string.
|
|
665
|
+
missed = []
|
|
666
|
+
for src in sources_to_check(worktree_root(), watched):
|
|
667
|
+
missed.extend(
|
|
668
|
+
uncovered_paths(worktree_root(), watched, scoped, src == "worktree")
|
|
669
|
+
)
|
|
670
|
+
if not missed:
|
|
671
|
+
emit(
|
|
672
|
+
True,
|
|
673
|
+
f"schema-diff pass covers the committed content of: {', '.join(watched[:5])}",
|
|
674
|
+
)
|
|
675
|
+
if stale is None:
|
|
676
|
+
stale = missed
|
|
677
|
+
emit(
|
|
678
|
+
False,
|
|
679
|
+
"schema-diff-gate: no recent pass covers the CURRENT content of "
|
|
680
|
+
+ ", ".join((stale or watched)[:5])
|
|
681
|
+
+ " (changed since it ran). Re-run `uap schema-diff` and re-commit.",
|
|
682
|
+
)
|
|
111
683
|
|
|
112
684
|
emit(
|
|
113
685
|
False,
|
|
@@ -118,4 +690,20 @@ def main() -> None:
|
|
|
118
690
|
|
|
119
691
|
|
|
120
692
|
if __name__ == "__main__":
|
|
121
|
-
|
|
693
|
+
# A crash must not read as consent. The policy hook turns a non-zero exit
|
|
694
|
+
# or unparseable output into ALLOW for every enforcer except self-protect
|
|
695
|
+
# (.claude/hooks/uap-policy-gate.sh), so an unhandled exception anywhere
|
|
696
|
+
# above is a silent bypass -- and the review found two reachable ones, both
|
|
697
|
+
# TypeErrors on a malformed verdict. The guards are still in place; this is
|
|
698
|
+
# the backstop for the ones nobody thought of.
|
|
699
|
+
try:
|
|
700
|
+
main()
|
|
701
|
+
except SystemExit:
|
|
702
|
+
raise # emit() exits through here
|
|
703
|
+
except Exception as exc: # noqa: BLE001
|
|
704
|
+
emit(
|
|
705
|
+
False,
|
|
706
|
+
"schema-diff-gate: the gate itself failed "
|
|
707
|
+
f"({type(exc).__name__}: {exc}) -- refusing rather than allowing "
|
|
708
|
+
"unverified. Re-run, and report this if it persists.",
|
|
709
|
+
)
|