@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
|
@@ -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).
|
|
@@ -66,11 +83,21 @@ def merge_verbatim(root: Path, path: str) -> bool:
|
|
|
66
83
|
|
|
67
84
|
|
|
68
85
|
def touched_watched_paths(root: Path) -> list[str]:
|
|
69
|
-
|
|
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)
|
|
70
97
|
if rc != 0:
|
|
71
98
|
return []
|
|
72
|
-
rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
|
|
73
|
-
all_files = (out + "\
|
|
99
|
+
rc2, staged, _ = run(["git", "diff", "--name-only", "-z", "--cached"], cwd=root)
|
|
100
|
+
all_files = (out + "\0" + (staged if rc2 == 0 else "")).split("\0")
|
|
74
101
|
# dict.fromkeys dedupes while preserving order: a file that is both
|
|
75
102
|
# unstaged and staged appeared twice, and the gate's reason line listed it
|
|
76
103
|
# twice ("covers: x, x"), which reads like two files were covered.
|
|
@@ -98,16 +125,99 @@ def _parse_marker_ts(raw) -> float | None:
|
|
|
98
125
|
return None
|
|
99
126
|
|
|
100
127
|
|
|
101
|
-
|
|
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
|
+
"""
|
|
102
211
|
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
103
212
|
if not db.exists():
|
|
104
|
-
return
|
|
213
|
+
return None
|
|
105
214
|
try:
|
|
106
215
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
|
|
107
216
|
# `uap memory store` writes to `memories` (type 'action'), while older
|
|
108
217
|
# UAP wrote session rows to `session_memories` — accept the marker from
|
|
109
218
|
# either table so the documented remedy actually clears the gate.
|
|
110
219
|
newest = None
|
|
220
|
+
candidates: list = []
|
|
111
221
|
for table in ("memories", "session_memories"):
|
|
112
222
|
try:
|
|
113
223
|
# ANCHORED to the recorder's fixed prefix. The old
|
|
@@ -116,12 +226,18 @@ def schema_diff_ok(root: Path) -> bool:
|
|
|
116
226
|
# `uap schema-diff` to pass"), so an agent storing the blocker
|
|
117
227
|
# as a lesson unblocked itself, and a note saying the diff
|
|
118
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.
|
|
119
235
|
cur = con.execute(
|
|
120
|
-
f"SELECT timestamp FROM {table} "
|
|
236
|
+
f"SELECT timestamp, content FROM {table} "
|
|
121
237
|
"WHERE content LIKE 'schema-diff pass: base %' "
|
|
122
|
-
"ORDER BY id DESC LIMIT
|
|
238
|
+
"ORDER BY id DESC LIMIT 20"
|
|
123
239
|
)
|
|
124
|
-
|
|
240
|
+
_rows = cur.fetchall()
|
|
125
241
|
# Compare PARSED epochs, not raw strings. The two tables need
|
|
126
242
|
# not share a timestamp format, and a lexicographic winner that
|
|
127
243
|
# then fails to parse returned False without ever considering
|
|
@@ -130,23 +246,315 @@ def schema_diff_ok(root: Path) -> bool:
|
|
|
130
246
|
# forever, and re-running the remedy could not help because it
|
|
131
247
|
# writes to the other table. Gate shut permanently, no waiver.
|
|
132
248
|
# A malformed row is ignored, never authoritative.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
136
259
|
except sqlite3.Error:
|
|
137
260
|
continue
|
|
138
261
|
con.close()
|
|
139
|
-
if
|
|
140
|
-
return
|
|
141
|
-
|
|
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
|
|
262
|
+
if not candidates:
|
|
263
|
+
return None
|
|
264
|
+
return candidates
|
|
148
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:
|
|
149
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"):
|
|
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
|
|
150
558
|
|
|
151
559
|
|
|
152
560
|
def main() -> None:
|
|
@@ -161,11 +569,117 @@ def main() -> None:
|
|
|
161
569
|
if not watched:
|
|
162
570
|
emit(True, "no watched schema/pool paths in diff")
|
|
163
571
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
)
|
|
169
683
|
|
|
170
684
|
emit(
|
|
171
685
|
False,
|
|
@@ -176,4 +690,20 @@ def main() -> None:
|
|
|
176
690
|
|
|
177
691
|
|
|
178
692
|
if __name__ == "__main__":
|
|
179
|
-
|
|
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
|
+
)
|
|
Binary file
|