@miller-tech/uap 2.1.6 → 2.11.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.
Files changed (143) hide show
  1. package/config/capacity-policy.json +32 -0
  2. package/config/classify-thresholds.json +9 -0
  3. package/config/supervise-policy.json +13 -0
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/bin/cli.js +87 -2
  6. package/dist/bin/cli.js.map +1 -1
  7. package/dist/capacity/policy.d.ts +67 -0
  8. package/dist/capacity/policy.d.ts.map +1 -0
  9. package/dist/capacity/policy.js +133 -0
  10. package/dist/capacity/policy.js.map +1 -0
  11. package/dist/capacity/probe.d.ts +23 -0
  12. package/dist/capacity/probe.d.ts.map +1 -0
  13. package/dist/capacity/probe.js +93 -0
  14. package/dist/capacity/probe.js.map +1 -0
  15. package/dist/classify/baseline.d.ts +24 -0
  16. package/dist/classify/baseline.d.ts.map +1 -0
  17. package/dist/classify/baseline.js +140 -0
  18. package/dist/classify/baseline.js.map +1 -0
  19. package/dist/classify/index.d.ts +30 -0
  20. package/dist/classify/index.d.ts.map +1 -0
  21. package/dist/classify/index.js +84 -0
  22. package/dist/classify/index.js.map +1 -0
  23. package/dist/classify/shadow.d.ts +34 -0
  24. package/dist/classify/shadow.d.ts.map +1 -0
  25. package/dist/classify/shadow.js +33 -0
  26. package/dist/classify/shadow.js.map +1 -0
  27. package/dist/classify/types.d.ts +64 -0
  28. package/dist/classify/types.d.ts.map +1 -0
  29. package/dist/classify/types.js +37 -0
  30. package/dist/classify/types.js.map +1 -0
  31. package/dist/cli/classify.d.ts +24 -0
  32. package/dist/cli/classify.d.ts.map +1 -0
  33. package/dist/cli/classify.js +94 -0
  34. package/dist/cli/classify.js.map +1 -0
  35. package/dist/cli/deliver.d.ts.map +1 -1
  36. package/dist/cli/deliver.js +26 -0
  37. package/dist/cli/deliver.js.map +1 -1
  38. package/dist/cli/doctor.d.ts +9 -0
  39. package/dist/cli/doctor.d.ts.map +1 -0
  40. package/dist/cli/doctor.js +49 -0
  41. package/dist/cli/doctor.js.map +1 -0
  42. package/dist/cli/review.d.ts +12 -0
  43. package/dist/cli/review.d.ts.map +1 -0
  44. package/dist/cli/review.js +118 -0
  45. package/dist/cli/review.js.map +1 -0
  46. package/dist/cli/supervise.d.ts +8 -0
  47. package/dist/cli/supervise.d.ts.map +1 -0
  48. package/dist/cli/supervise.js +76 -0
  49. package/dist/cli/supervise.js.map +1 -0
  50. package/dist/delivery/convergence-loop.d.ts +8 -0
  51. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  52. package/dist/delivery/convergence-loop.js +1 -0
  53. package/dist/delivery/convergence-loop.js.map +1 -1
  54. package/dist/delivery/delivery-result.d.ts.map +1 -1
  55. package/dist/delivery/delivery-result.js +5 -0
  56. package/dist/delivery/delivery-result.js.map +1 -1
  57. package/dist/delivery/gate-evidence.d.ts +143 -0
  58. package/dist/delivery/gate-evidence.d.ts.map +1 -0
  59. package/dist/delivery/gate-evidence.js +239 -0
  60. package/dist/delivery/gate-evidence.js.map +1 -0
  61. package/dist/delivery/index.d.ts +1 -0
  62. package/dist/delivery/index.d.ts.map +1 -1
  63. package/dist/delivery/index.js +1 -0
  64. package/dist/delivery/index.js.map +1 -1
  65. package/dist/evals/routing-corpus.d.ts +35 -0
  66. package/dist/evals/routing-corpus.d.ts.map +1 -0
  67. package/dist/evals/routing-corpus.js +142 -0
  68. package/dist/evals/routing-corpus.js.map +1 -0
  69. package/dist/evals/routing-eval.d.ts +104 -0
  70. package/dist/evals/routing-eval.d.ts.map +1 -0
  71. package/dist/evals/routing-eval.js +231 -0
  72. package/dist/evals/routing-eval.js.map +1 -0
  73. package/dist/evals/tfidf.d.ts +35 -0
  74. package/dist/evals/tfidf.d.ts.map +1 -0
  75. package/dist/evals/tfidf.js +132 -0
  76. package/dist/evals/tfidf.js.map +1 -0
  77. package/dist/review/prepass.d.ts +88 -0
  78. package/dist/review/prepass.d.ts.map +1 -0
  79. package/dist/review/prepass.js +428 -0
  80. package/dist/review/prepass.js.map +1 -0
  81. package/dist/review/visual-captures.d.ts +60 -0
  82. package/dist/review/visual-captures.d.ts.map +1 -0
  83. package/dist/review/visual-captures.js +222 -0
  84. package/dist/review/visual-captures.js.map +1 -0
  85. package/dist/supervise/assess.d.ts +73 -0
  86. package/dist/supervise/assess.d.ts.map +1 -0
  87. package/dist/supervise/assess.js +215 -0
  88. package/dist/supervise/assess.js.map +1 -0
  89. package/dist/supervise/config.d.ts +13 -0
  90. package/dist/supervise/config.d.ts.map +1 -0
  91. package/dist/supervise/config.js +81 -0
  92. package/dist/supervise/config.js.map +1 -0
  93. package/dist/supervise/loop.d.ts +78 -0
  94. package/dist/supervise/loop.d.ts.map +1 -0
  95. package/dist/supervise/loop.js +311 -0
  96. package/dist/supervise/loop.js.map +1 -0
  97. package/dist/supervise/observe.d.ts +31 -0
  98. package/dist/supervise/observe.d.ts.map +1 -0
  99. package/dist/supervise/observe.js +172 -0
  100. package/dist/supervise/observe.js.map +1 -0
  101. package/dist/supervise/policy.d.ts +16 -0
  102. package/dist/supervise/policy.d.ts.map +1 -0
  103. package/dist/supervise/policy.js +147 -0
  104. package/dist/supervise/policy.js.map +1 -0
  105. package/dist/supervise/types.d.ts +109 -0
  106. package/dist/supervise/types.d.ts.map +1 -0
  107. package/dist/supervise/types.js +11 -0
  108. package/dist/supervise/types.js.map +1 -0
  109. package/docs/INDEX.md +9 -3
  110. package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
  111. package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
  112. package/docs/guides/CAPACITY_POLICY.md +98 -0
  113. package/docs/performance/automode-risk-calibration.md +72 -0
  114. package/docs/performance/reliability-ladder.md +78 -0
  115. package/docs/plans/system1-uplift-2026-09-18.md +191 -0
  116. package/docs/reference/automode-risk.md +134 -0
  117. package/docs/reference/evidence-bound-ship.md +145 -0
  118. package/docs/reference/review-prepass.md +77 -0
  119. package/docs/reference/routing-evals.md +71 -0
  120. package/docs/reference/semantic-supervisor.md +140 -0
  121. package/docs/reference/system1-classifier.md +103 -0
  122. package/docs/reference/visual-captures.md +113 -0
  123. package/package.json +5 -3
  124. package/scripts/run-anthropic-proxy-continuity.sh +36 -0
  125. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  126. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  127. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  128. package/src/policies/enforcers/enforcement_self_protect.py +25 -2
  129. package/src/policies/enforcers/evidence_bound_ship.py +257 -0
  130. package/src/policies/enforcers/expert_review_required.py +120 -0
  131. package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
  132. package/src/policies/schemas/policies/visual-verification.md +8 -0
  133. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  134. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  135. package/tools/agents/scripts/anthropic_proxy.py +782 -19
  136. package/tools/agents/scripts/project_telemetry.py +52 -0
  137. package/tools/agents/scripts/tool_risk.py +789 -0
  138. package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
  139. package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
  140. package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
  141. package/tools/agents/tests/test_gate_evidence.py +56 -0
  142. package/tools/agents/tests/test_tool_risk.py +521 -0
  143. package/tools/agents/tests/test_verbatim_compact.py +629 -0
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env python3
2
+ """evidence-bound-ship enforcer: gate evidence must be bound to the shipped SHA.
3
+
4
+ Blocks ship actions (same detection as expert-review-required: git commit /
5
+ push / merge, gh pr create / merge / ready, pr-ready / signoff) unless
6
+ `.uap/evidence/<head-sha>.json` exists AND proves the mission's gates passed
7
+ for EXACTLY the commit being shipped:
8
+
9
+ - missing artifact -> BLOCK
10
+ - malformed JSON -> BLOCK
11
+ - version != 1 -> BLOCK
12
+ - candidateSha != HEAD -> BLOCK (evidence for a different commit proves
13
+ nothing about this one — the forgery/staleness
14
+ case this gate exists to refuse)
15
+ - recordedAt before the commit's own committer time, or older than the
16
+ staleness window (default 24h) -> BLOCK
17
+ - no gate with exitCode 0 + command + timestamp -> BLOCK (empty/forged)
18
+
19
+ The artifact is written by the deliver CLI (src/delivery/gate-evidence.ts) at
20
+ mission completion. `.uap/evidence/` is a self-protect PROTECTED_TARGET with no
21
+ agent carve-out, so the enforcer's trust root is "only the tooling could have
22
+ written this", same as the review artifacts' waiver dirs.
23
+
24
+ Fail-CLOSED on anything ambiguous — that is the difference between this gate
25
+ and expert-review: review evidence fail-opens on detached/unresolvable HEAD
26
+ because a review is a human act; gate evidence is machine-written at deliver
27
+ time, so a ship with unverifiable evidence is simply refused. The ONLY
28
+ fail-open is when git itself is absent or cwd is not a repo (non-UAP trees are
29
+ unaffected).
30
+
31
+ Local git + fs only: no network, no gh — the enforcer runs in the live policy
32
+ chain and must be fast. `gh pr merge <N>` binds to the LOCAL worktree HEAD (the
33
+ PR head is not resolvable without gh); ship from the branch you deliver on.
34
+
35
+ Escape hatch: UAP_EVIDENCE_GATE_OFF=1 in the LAUNCH environment (operator-only;
36
+ enforcement-self-protect refuses the inline form, same as UAP_NO_REVIEW).
37
+ Staleness window override: UAP_EVIDENCE_MAX_AGE_HOURS (default 24).
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import os
43
+ import sys
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+
47
+ sys.path.insert(0, str(Path(__file__).parent))
48
+ from _common import emit, parse_cli, worktree_root, run # noqa: E402
49
+ # Ship-action detection is SHARED with expert-review-required: one definition,
50
+ # so a verb one gate sees can never slip past the other.
51
+ from expert_review_required import is_ship_action # noqa: E402
52
+
53
+ EVIDENCE_DIR = ".uap/evidence"
54
+ DEFAULT_MAX_AGE_HOURS = 24
55
+ # A deliver-written artifact is a few KB of gate outcomes. Anything near half a
56
+ # megabyte is not evidence, it's a payload — cap the read BEFORE parsing so a
57
+ # hostile or corrupt file cannot make the gate parse unbounded input.
58
+ MAX_ARTIFACT_BYTES = 512 * 1024
59
+ # Clock-skew allowance for the future-dating check: recordedAt may lead the
60
+ # gate host's clock by a little on a machine with sloppy NTP; 5 minutes is
61
+ # generosity, not a loophole (staleness is measured in hours).
62
+ FUTURE_SKEW_SEC = 300
63
+
64
+
65
+ def _max_age_sec() -> int:
66
+ """Staleness window in seconds; a malformed env override falls back to the
67
+ default rather than disabling the check (0) or crashing the gate."""
68
+ try:
69
+ hours = float(os.environ.get("UAP_EVIDENCE_MAX_AGE_HOURS", "") or DEFAULT_MAX_AGE_HOURS)
70
+ return max(0, int(hours * 3600))
71
+ except ValueError:
72
+ return DEFAULT_MAX_AGE_HOURS * 3600
73
+
74
+
75
+ def _parse_iso(value: object) -> float | None:
76
+ """ISO-8601 -> epoch seconds, or None when unparseable. Tolerates a
77
+ trailing 'Z' (the writer emits it; fromisoformat only learned 'Z' in 3.11).
78
+ """
79
+ if not isinstance(value, str) or not value.strip():
80
+ return None
81
+ try:
82
+ dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
83
+ if dt.tzinfo is None:
84
+ dt = dt.replace(tzinfo=timezone.utc)
85
+ return dt.timestamp()
86
+ except ValueError:
87
+ return None
88
+
89
+
90
+ def _head_sha(root: Path) -> str | None:
91
+ rc, out, _ = run(["git", "rev-parse", "--verify", "HEAD^{commit}"], cwd=root)
92
+ sha = out.strip()
93
+ return sha if rc == 0 and sha else None
94
+
95
+
96
+ def _commit_time(root: Path, sha: str) -> float | None:
97
+ rc, out, _ = run(["git", "show", "-s", "--format=%ct", sha], cwd=root)
98
+ try:
99
+ return float(out.strip()) if rc == 0 else None
100
+ except ValueError:
101
+ return None
102
+
103
+
104
+ def _block(reason: str, fix: str) -> None:
105
+ emit(
106
+ False,
107
+ f"evidence-bound-ship: {reason} {fix} "
108
+ "Operator override: UAP_EVIDENCE_GATE_OFF=1 in the launch environment.",
109
+ )
110
+
111
+
112
+ def main() -> None:
113
+ op, args = parse_cli()
114
+
115
+ if os.environ.get("UAP_EVIDENCE_GATE_OFF") == "1":
116
+ emit(True, "UAP_EVIDENCE_GATE_OFF override set")
117
+
118
+ if op.lower() != "bash":
119
+ emit(True, "not a ship operation")
120
+
121
+ cmd = args.get("command") or args.get("cmd") or ""
122
+ if not is_ship_action(cmd, worktree_root()):
123
+ emit(True, "not a ship action")
124
+
125
+ root = worktree_root()
126
+
127
+ # Fail-open ONLY when git cannot answer at all (git missing, or cwd is not
128
+ # a repository) — non-UAP trees must be unaffected. Everything else fails
129
+ # closed below.
130
+ rc, out, _ = run(["git", "rev-parse", "--is-inside-work-tree"], cwd=root)
131
+ if rc != 0 or out.strip() != "true":
132
+ emit(True, "not a git repo (or git unavailable) — fail-open")
133
+
134
+ sha = _head_sha(root)
135
+ if sha is None:
136
+ _block(
137
+ "HEAD is not resolvable to a commit (unborn branch or corrupt repo),"
138
+ " so no candidate-bound evidence can exist.",
139
+ "Fix: deliver the mission with `uap deliver` on a branch with a real HEAD.",
140
+ )
141
+
142
+ artifact = root / EVIDENCE_DIR / f"{sha}.json"
143
+ if not artifact.exists():
144
+ _block(
145
+ f"no gate-evidence artifact at {EVIDENCE_DIR}/{sha[:12]}….json for the current HEAD.",
146
+ "Fix: run `uap deliver` so the mission's gates are recorded against this commit,"
147
+ " then ship.",
148
+ )
149
+
150
+ try:
151
+ size = artifact.stat().st_size
152
+ except OSError:
153
+ size = None # raced away between exists() and stat() — fail closed
154
+ if size is None or size > MAX_ARTIFACT_BYTES:
155
+ _block(
156
+ f"the evidence artifact {EVIDENCE_DIR}/{sha[:12]}….json is oversized or unreadable"
157
+ f" ({size if size is not None else 'stat failed'}; cap {MAX_ARTIFACT_BYTES} bytes)"
158
+ " — a deliver-written artifact is a few KB.",
159
+ "Fix: delete it and re-run `uap deliver` so a well-formed artifact is recorded.",
160
+ )
161
+
162
+ try:
163
+ data = json.loads(artifact.read_text())
164
+ except Exception: # noqa: BLE001
165
+ _block(
166
+ f"the evidence artifact {EVIDENCE_DIR}/{sha[:12]}….json is malformed (not JSON).",
167
+ "Fix: delete it and re-run `uap deliver` so a well-formed artifact is recorded.",
168
+ )
169
+
170
+ if not isinstance(data, dict) or data.get("version") != 1:
171
+ _block(
172
+ f"the evidence artifact for {sha[:12]}… has an unsupported schema version.",
173
+ "Fix: re-run `uap deliver` with the current CLI to re-record it (version 1).",
174
+ )
175
+
176
+ recorded_sha = data.get("candidateSha")
177
+ if recorded_sha != sha:
178
+ _block(
179
+ f"the evidence artifact covers {str(recorded_sha)[:12]}… but HEAD is {sha[:12]}…"
180
+ " — evidence for a different commit proves nothing about this one.",
181
+ "Fix: re-run `uap deliver` so the gates are recorded against the commit you are shipping.",
182
+ )
183
+
184
+ recorded_at = _parse_iso(data.get("recordedAt"))
185
+ if recorded_at is None:
186
+ _block(
187
+ "the evidence artifact has no parseable recordedAt timestamp.",
188
+ "Fix: re-run `uap deliver` to re-record it.",
189
+ )
190
+
191
+ # Backdating guard: evidence claiming to predate the commit it binds to is
192
+ # forged — the commit did not exist to be gated yet. The commit time being
193
+ # UNRESOLVABLE is anomalous (the sha resolved but git show failed): fail
194
+ # closed rather than skip the one check that anchors recordedAt to reality.
195
+ committed_at = _commit_time(root, sha)
196
+ if committed_at is None:
197
+ _block(
198
+ f"the commit time of HEAD ({sha[:12]}…) could not be resolved — the sha"
199
+ " resolved but `git show` failed, which is anomalous.",
200
+ "Fix: investigate the repo (corrupt object?), then re-run `uap deliver`.",
201
+ )
202
+ if recorded_at < committed_at:
203
+ _block(
204
+ "the evidence predates the commit it claims to prove"
205
+ f" (recordedAt {data.get('recordedAt')}, commit time {int(committed_at)}).",
206
+ "Fix: re-run `uap deliver` after the candidate commit exists.",
207
+ )
208
+
209
+ now = datetime.now(timezone.utc).timestamp()
210
+ if recorded_at > now + FUTURE_SKEW_SEC:
211
+ _block(
212
+ f"the evidence is FUTURE-dated (recordedAt {data.get('recordedAt')}"
213
+ f" is more than {FUTURE_SKEW_SEC}s ahead of now) — forged or a"
214
+ " badly skewed clock.",
215
+ "Fix: fix the clock and re-run `uap deliver`.",
216
+ )
217
+
218
+ age = now - recorded_at
219
+ window = _max_age_sec()
220
+ if age > window:
221
+ _block(
222
+ f"the gate evidence is stale ({int(age // 3600)}h old; window"
223
+ f" {window // 3600}h) — the tree may have drifted since the gates ran.",
224
+ "Fix: re-run `uap deliver` to refresh the evidence for this HEAD.",
225
+ )
226
+
227
+ gates = data.get("gates")
228
+ passing = [
229
+ g
230
+ for g in (gates if isinstance(gates, list) else [])
231
+ if isinstance(g, dict)
232
+ and g.get("exitCode") == 0
233
+ and isinstance(g.get("command"), str)
234
+ and g["command"].strip()
235
+ and _parse_iso(g.get("at")) is not None
236
+ ]
237
+ if not passing:
238
+ _block(
239
+ "the evidence artifact records no passing gate with a command and"
240
+ " timestamp — an empty or forged artifact is not evidence.",
241
+ "Fix: re-run `uap deliver` so the real gate outcomes are recorded.",
242
+ )
243
+
244
+ emit(
245
+ True,
246
+ f"evidence-bound-ship satisfied ({EVIDENCE_DIR}/{sha[:12]}….json,"
247
+ f" {len(passing)} passing gate(s), recorded {data.get('recordedAt')})",
248
+ )
249
+
250
+
251
+ if __name__ == "__main__":
252
+ try:
253
+ main()
254
+ except SystemExit:
255
+ raise
256
+ except Exception as e: # noqa: BLE001 — the live policy chain must never see a traceback
257
+ _block(f"enforcer error ({type(e).__name__}: {e}); failing closed.", "Fix: report this.")
@@ -23,6 +23,7 @@ import os
23
23
  import re
24
24
  import shlex
25
25
  import sys
26
+ from datetime import datetime
26
27
  from pathlib import Path
27
28
 
28
29
  sys.path.insert(0, str(Path(__file__).parent))
@@ -275,6 +276,114 @@ def _is_low_risk(f: str) -> bool:
275
276
  return Path(f).suffix.lower() in LOW_RISK_EXT
276
277
 
277
278
 
279
+ # --- Visual-captures gate (uplift 0.3) --------------------------------------
280
+ # A diff touching UI files is not done until before/after captures are bound
281
+ # to the review — code review cannot see what the user sees. The captures live
282
+ # in a SIBLING artifact (<slug>.captures.json), never in <slug>.json itself:
283
+ # this enforcer treats that file's existence as "a review happened", so an
284
+ # advisory recording must not create it. The surface definition mirrors
285
+ # UI_EXT/UI_DIR_PREFIXES in visual_verification.py and
286
+ # src/review/visual-captures.ts — keep all three in sync.
287
+ UI_EXT = {".css", ".scss", ".sass", ".less", ".tsx", ".jsx", ".vue", ".svelte", ".html", ".astro"}
288
+ UI_DIR_PREFIXES = ("web/", "src/dashboard/", "public/")
289
+ CAPTURES_STALENESS_GRACE_S = 1.0 # same grace the commit-time visual enforcer uses
290
+
291
+
292
+ def _is_ui(f: str) -> bool:
293
+ # Case-insensitive like the TS mirror (isUiFile in visual-captures.ts):
294
+ # `Web/` or `Component.TSX` must not slip between two definitions.
295
+ lower = f.lower()
296
+ return Path(lower).suffix in UI_EXT or lower.startswith(UI_DIR_PREFIXES)
297
+
298
+
299
+ def _safe_capture_path(p: object) -> str | None:
300
+ """A capture path the gate may existence-check, or None when it escapes
301
+ the project. The recorder refuses these, but the enforcer is the gate and
302
+ must not trust the artifact: Path(root) / "/etc/passwd" collapses to the
303
+ absolute path, and "root / '../../x'" resolves outside the project."""
304
+ s = str(p)
305
+ if os.path.isabs(s) or s == ".." or s.startswith("../") or "/../" in s:
306
+ return None
307
+ return s
308
+
309
+
310
+ def _check_visual_captures(root: Path, slug: str, ui_files: list[str]) -> None:
311
+ """emit(False, ...) unless fresh before/after captures cover the UI diff."""
312
+ # Operator escape hatch, environment-only (set by whoever launched the
313
+ # session) — never parsed out of the command string; an inline override
314
+ # would be self-grantable, the exact hole removed from UAP_NO_REVIEW.
315
+ if os.environ.get("UAP_VISUAL_GATE_OFF") == "1":
316
+ return
317
+
318
+ artifact = root / REVIEW_ARTIFACT_DIR / f"{slug}.captures.json"
319
+ guidance = (
320
+ "capture before/after evidence with a capture tool (agent-browser for "
321
+ "web surfaces, tuistory/pty-capture for terminal surfaces), review the "
322
+ "pair, and register it: uap review captures add --before <img> --after "
323
+ "<img> --tool <name>. Verify with `uap review captures check`."
324
+ )
325
+ if not artifact.exists():
326
+ emit(
327
+ False,
328
+ f"visual-captures: the diff touches {len(ui_files)} UI file(s) "
329
+ f"({', '.join(sorted(ui_files)[:6])}{' …' if len(ui_files) > 6 else ''}) "
330
+ f"but no captures artifact exists at .uap/reviews/{slug}.captures.json. "
331
+ "A UI change is not reviewable from code alone — completion gate 7 "
332
+ f"(self-review) requires the rendered evidence. {guidance} "
333
+ "Operator override: UAP_VISUAL_GATE_OFF=1 in the launch environment.",
334
+ )
335
+ try:
336
+ data = json.loads(artifact.read_text())
337
+ except Exception: # noqa: BLE001
338
+ data = {}
339
+ captures = data.get("captures") if isinstance(data, dict) else None
340
+ pairs = [c for c in (captures or []) if isinstance(c, dict) and c.get("before") and c.get("after")]
341
+ if not pairs:
342
+ emit(
343
+ False,
344
+ f"visual-captures: .uap/reviews/{slug}.captures.json has no complete "
345
+ f"before/after pair. {guidance}",
346
+ )
347
+ paths = [p for c in pairs for p in (c.get("before"), c.get("after"))]
348
+ if any(_safe_capture_path(p) is None for p in paths):
349
+ emit(
350
+ False,
351
+ f"visual-captures: .uap/reviews/{slug}.captures.json references a path "
352
+ "outside the project (absolute or .. escape). Re-register captures with "
353
+ "`uap review captures add` — project-relative paths only.",
354
+ )
355
+ missing = [str(p) for p in paths if not (root / str(p)).exists()]
356
+ if missing:
357
+ emit(
358
+ False,
359
+ f"visual-captures: capture file(s) referenced by the artifact are missing: "
360
+ f"{', '.join(sorted(set(missing))[:6])}. Re-capture and re-register.",
361
+ )
362
+
363
+ # Freshness: captures predating the newest UI edit say nothing about the
364
+ # current look. The artifact's `at` must postdate every UI file's mtime.
365
+ try:
366
+ captured_at = datetime.fromisoformat(str(data.get("at", "")).replace("Z", "+00:00")).timestamp()
367
+ except Exception: # noqa: BLE001
368
+ captured_at = 0.0
369
+ stale = []
370
+ for f in ui_files:
371
+ p = root / f
372
+ try:
373
+ if p.exists() and p.stat().st_mtime > captured_at + CAPTURES_STALENESS_GRACE_S:
374
+ stale.append(f)
375
+ except OSError:
376
+ continue
377
+ if stale:
378
+ emit(
379
+ False,
380
+ f"visual-captures: UI file(s) changed AFTER the captures were taken — the "
381
+ f"current look is unverified: {', '.join(sorted(stale)[:6])}"
382
+ f"{' …' if len(stale) > 6 else ''}. Re-capture and re-register with "
383
+ "`uap review captures add`.",
384
+ )
385
+
386
+
278
387
  def _active_waiver(root: Path) -> bool:
279
388
  """A committable, env-free bypass: an active waiver file. Works in harnesses
280
389
  that strip env vars (where UAP_NO_REVIEW=1 cannot be set)."""
@@ -438,6 +547,17 @@ def main() -> None:
438
547
  # `gh pr merge` from the main checkout is not the PR's contents at all —
439
548
  # it would grade the wrong change as low-risk.
440
549
  changed = None if pr_branch else _changed_files(root)
550
+
551
+ # Visual-captures gate BEFORE the low-risk early-allow: UI-only diffs are
552
+ # exactly what this gate exists for, and the low-risk path would otherwise
553
+ # wave them through unobserved. Fail-open when the base is unresolvable
554
+ # (same posture as the risk-scope check) and skipped for PR ships, where
555
+ # the local diff is not the PR's contents.
556
+ if changed is not None:
557
+ ui_files = [f for f in changed if _is_ui(f)]
558
+ if ui_files:
559
+ _check_visual_captures(root, slug, ui_files)
560
+
441
561
  if changed and all(_is_low_risk(f) for f in changed):
442
562
  emit(
443
563
  True,
@@ -0,0 +1,106 @@
1
+ # evidence-bound-ship
2
+
3
+ **Category**: quality
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: review
6
+ **Tags**: uap, delivery, evidence, gates, enforcement, ship
7
+
8
+ ## Rule
9
+
10
+ A ship action MUST present machine-recorded gate evidence bound to the exact
11
+ commit being shipped. When no valid evidence artifact exists for the current
12
+ HEAD, the enforcer blocks the ship actions:
13
+
14
+ - `git commit`, `git push`, `git merge`
15
+ - `gh pr create`, `gh pr merge`, `gh pr ready`
16
+ - merge / pr-ready / signoff / ready-for-review operations
17
+
18
+ Evidence artifact: `.uap/evidence/<candidate-sha>.json`, written by the
19
+ **deliver CLI** (`src/delivery/gate-evidence.ts`) when a mission completes with
20
+ its gates green. Recognised shape (version 1):
21
+
22
+ ```json
23
+ {
24
+ "version": 1,
25
+ "candidateSha": "<full HEAD sha the gates prove>",
26
+ "recordedAt": "<ISO-8601>",
27
+ "runId": "<deliver run id, optional>",
28
+ "hatches": ["<gate-affecting env vars set at deliver time, audit only>"],
29
+ "gates": [
30
+ { "name": "test", "command": "npm test", "exitCode": 0,
31
+ "outputTail": "<last ≤2000 chars>", "at": "<ISO-8601>" }
32
+ ]
33
+ }
34
+ ```
35
+
36
+ The writer **refuses to record against a dirty working tree** (`git status
37
+ --porcelain` must be empty): evidence claims the gates passed for `<sha>`, and
38
+ with uncommitted changes they provably ran against HEAD-plus-delta. The
39
+ supported flow is therefore commit → `uap deliver` (a green tree short-circuits
40
+ as `alreadyDelivered`, and the baseline ladder's rung results become the
41
+ evidence) → ship.
42
+
43
+ Per-gate `at` is **reconstructed**, not measured: rungs carry durations but no
44
+ wall-clock finish time, so a gate's stamp is the recording time minus the
45
+ durations of the gates that ran after it. Honest ordering, approximate clock.
46
+
47
+ `hatches` lists which known gate-affecting env vars (the
48
+ enforcement-self-protect BYPASS_PATTERNS set plus `UAP_VISUAL_GATE_OFF` /
49
+ `UAP_DESIGN_GATE_OFF`) were set when the evidence was recorded. Audit value
50
+ only; the enforcer does not act on it.
51
+
52
+ The enforcer rejects (BLOCKS) an artifact that is:
53
+
54
+ - **missing** — no `.uap/evidence/<head>.json` for the current HEAD;
55
+ - **oversized** — larger than 512KB (read capped before parsing);
56
+ - **malformed** — not parseable JSON;
57
+ - **wrong version** — anything but `version: 1`;
58
+ - **SHA-mismatched** — `candidateSha` differs from HEAD (evidence for a
59
+ different commit proves nothing about this one; this is also the forgery
60
+ case, since `.uap/evidence/` is agent-write-protected by
61
+ enforcement-self-protect, including interpreter-mediated writes);
62
+ - **backdated** — `recordedAt` earlier than the commit's own committer time
63
+ (and an UNRESOLVABLE commit time blocks too: the sha resolving while
64
+ `git show` fails is anomalous, not a skip);
65
+ - **future-dated** — `recordedAt` more than 5 minutes ahead of now;
66
+ - **stale** — `recordedAt` older than the staleness window (default **24h**,
67
+ operator-tunable via `UAP_EVIDENCE_MAX_AGE_HOURS` in the launch environment;
68
+ the agent cannot set it — enforcement-self-protect refuses any assignment);
69
+ - **empty** — no gate entry with `exitCode: 0`, a non-empty `command`, and a
70
+ parseable `at` timestamp.
71
+
72
+ ## Why
73
+
74
+ Deliver missions claim "all gates pass", but until this policy nothing bound
75
+ that claim to the commit a ship action actually publishes. An agent could run
76
+ the gates on one tree, amend or swap the commit, and ship code the gates never
77
+ saw — the ship gates trusted the run, not the artifact. Evidence binding makes
78
+ the merge candidate carry its proof: the ship enforcer reads the artifact for
79
+ `HEAD` and fails closed on anything missing, stale, or mismatched.
80
+
81
+ This is the machine-recorded analogue of `expert-review-required`: review
82
+ evidence is a human verdict (fail-open on unresolvable HEAD); gate evidence is
83
+ written by the CLI at deliver time, so the posture is fail-closed — the only
84
+ fail-open is a tree that is not a git repo at all.
85
+
86
+ ## Enforcement
87
+
88
+ Python enforcer `evidence_bound_ship.py` resolves the candidate HEAD via local
89
+ git (`git rev-parse --verify HEAD^{commit}` — no network, no `gh`), then checks
90
+ `.uap/evidence/<sha>.json` as above. Ship-action detection is **shared** with
91
+ `expert-review-required` (imported `is_ship_action`), so the two gates can
92
+ never disagree on what a ship action is.
93
+
94
+ Fail-closed on every ambiguity; fail-open only when git itself is absent or
95
+ the working tree is not a repository (non-UAP trees unaffected).
96
+
97
+ Bypass (operator-only, environment-only — the inline form is refused by
98
+ enforcement-self-protect because the agent composes its own command strings):
99
+
100
+ - `UAP_EVIDENCE_GATE_OFF=1` — set in the launch environment.
101
+
102
+ ```rules
103
+ - title: "A ship action must present gate evidence bound to the shipped commit"
104
+ keywords: [git commit, git push, gh pr create, merge, pr-ready, signoff, gate evidence]
105
+ antiPatterns: [no-evidence, stale-evidence, forged-evidence, evidence-sha-mismatch]
106
+ ```
@@ -44,6 +44,14 @@ an agent that can see the output can improve it.
44
44
  - Fail-open ONLY when no headless browser is available (vm-DOM cannot
45
45
  render pixels); the skip is stated explicitly in the report.
46
46
 
47
+ ## Review binding (uplift 0.3)
48
+
49
+ Distinct from this runtime render gate: when a diff touches UI files, the
50
+ review must also carry before/after **captures** (agent-browser / tuistory /
51
+ pty-capture) registered via `uap review captures add` and validated by the
52
+ ship-time enforcer — see `docs/reference/visual-captures.md`. The render gate
53
+ proves the UI runs; captures prove a reviewer looked at the change.
54
+
47
55
  ## Agent obligations
48
56
 
49
57
  After any delivery with a rendered artifact: run `uap verify --dir <project>`,