@miller-tech/uap 2.1.7 → 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.
- package/config/capacity-policy.json +32 -0
- package/config/classify-thresholds.json +9 -0
- package/config/supervise-policy.json +13 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +87 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +67 -0
- package/dist/capacity/policy.d.ts.map +1 -0
- package/dist/capacity/policy.js +133 -0
- package/dist/capacity/policy.js.map +1 -0
- package/dist/capacity/probe.d.ts +23 -0
- package/dist/capacity/probe.d.ts.map +1 -0
- package/dist/capacity/probe.js +93 -0
- package/dist/capacity/probe.js.map +1 -0
- package/dist/classify/baseline.d.ts +24 -0
- package/dist/classify/baseline.d.ts.map +1 -0
- package/dist/classify/baseline.js +140 -0
- package/dist/classify/baseline.js.map +1 -0
- package/dist/classify/index.d.ts +30 -0
- package/dist/classify/index.d.ts.map +1 -0
- package/dist/classify/index.js +84 -0
- package/dist/classify/index.js.map +1 -0
- package/dist/classify/shadow.d.ts +34 -0
- package/dist/classify/shadow.d.ts.map +1 -0
- package/dist/classify/shadow.js +33 -0
- package/dist/classify/shadow.js.map +1 -0
- package/dist/classify/types.d.ts +64 -0
- package/dist/classify/types.d.ts.map +1 -0
- package/dist/classify/types.js +37 -0
- package/dist/classify/types.js.map +1 -0
- package/dist/cli/classify.d.ts +24 -0
- package/dist/cli/classify.d.ts.map +1 -0
- package/dist/cli/classify.js +94 -0
- package/dist/cli/classify.js.map +1 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +26 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/doctor.d.ts +9 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +49 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/review.d.ts +12 -0
- package/dist/cli/review.d.ts.map +1 -0
- package/dist/cli/review.js +118 -0
- package/dist/cli/review.js.map +1 -0
- package/dist/cli/supervise.d.ts +8 -0
- package/dist/cli/supervise.d.ts.map +1 -0
- package/dist/cli/supervise.js +76 -0
- package/dist/cli/supervise.js.map +1 -0
- package/dist/delivery/convergence-loop.d.ts +8 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +1 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/delivery-result.d.ts.map +1 -1
- package/dist/delivery/delivery-result.js +5 -0
- package/dist/delivery/delivery-result.js.map +1 -1
- package/dist/delivery/gate-evidence.d.ts +143 -0
- package/dist/delivery/gate-evidence.d.ts.map +1 -0
- package/dist/delivery/gate-evidence.js +239 -0
- package/dist/delivery/gate-evidence.js.map +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/index.js.map +1 -1
- package/dist/evals/routing-corpus.d.ts +35 -0
- package/dist/evals/routing-corpus.d.ts.map +1 -0
- package/dist/evals/routing-corpus.js +142 -0
- package/dist/evals/routing-corpus.js.map +1 -0
- package/dist/evals/routing-eval.d.ts +104 -0
- package/dist/evals/routing-eval.d.ts.map +1 -0
- package/dist/evals/routing-eval.js +231 -0
- package/dist/evals/routing-eval.js.map +1 -0
- package/dist/evals/tfidf.d.ts +35 -0
- package/dist/evals/tfidf.d.ts.map +1 -0
- package/dist/evals/tfidf.js +132 -0
- package/dist/evals/tfidf.js.map +1 -0
- package/dist/review/prepass.d.ts +88 -0
- package/dist/review/prepass.d.ts.map +1 -0
- package/dist/review/prepass.js +428 -0
- package/dist/review/prepass.js.map +1 -0
- package/dist/review/visual-captures.d.ts +60 -0
- package/dist/review/visual-captures.d.ts.map +1 -0
- package/dist/review/visual-captures.js +222 -0
- package/dist/review/visual-captures.js.map +1 -0
- package/dist/supervise/assess.d.ts +73 -0
- package/dist/supervise/assess.d.ts.map +1 -0
- package/dist/supervise/assess.js +215 -0
- package/dist/supervise/assess.js.map +1 -0
- package/dist/supervise/config.d.ts +13 -0
- package/dist/supervise/config.d.ts.map +1 -0
- package/dist/supervise/config.js +81 -0
- package/dist/supervise/config.js.map +1 -0
- package/dist/supervise/loop.d.ts +78 -0
- package/dist/supervise/loop.d.ts.map +1 -0
- package/dist/supervise/loop.js +311 -0
- package/dist/supervise/loop.js.map +1 -0
- package/dist/supervise/observe.d.ts +31 -0
- package/dist/supervise/observe.d.ts.map +1 -0
- package/dist/supervise/observe.js +172 -0
- package/dist/supervise/observe.js.map +1 -0
- package/dist/supervise/policy.d.ts +16 -0
- package/dist/supervise/policy.d.ts.map +1 -0
- package/dist/supervise/policy.js +147 -0
- package/dist/supervise/policy.js.map +1 -0
- package/dist/supervise/types.d.ts +109 -0
- package/dist/supervise/types.d.ts.map +1 -0
- package/dist/supervise/types.js +11 -0
- package/dist/supervise/types.js.map +1 -0
- package/docs/INDEX.md +9 -3
- package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
- package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
- package/docs/guides/CAPACITY_POLICY.md +98 -0
- package/docs/performance/automode-risk-calibration.md +72 -0
- package/docs/performance/reliability-ladder.md +78 -0
- package/docs/plans/system1-uplift-2026-09-18.md +191 -0
- package/docs/reference/automode-risk.md +134 -0
- package/docs/reference/evidence-bound-ship.md +145 -0
- package/docs/reference/review-prepass.md +77 -0
- package/docs/reference/routing-evals.md +71 -0
- package/docs/reference/semantic-supervisor.md +140 -0
- package/docs/reference/system1-classifier.md +103 -0
- package/docs/reference/visual-captures.md +113 -0
- package/package.json +5 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +25 -2
- package/src/policies/enforcers/evidence_bound_ship.py +257 -0
- package/src/policies/enforcers/expert_review_required.py +120 -0
- package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
- package/src/policies/schemas/policies/visual-verification.md +8 -0
- 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/scripts/anthropic_proxy.py +782 -19
- package/tools/agents/scripts/project_telemetry.py +52 -0
- package/tools/agents/scripts/tool_risk.py +789 -0
- package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
- package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
- package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
- package/tools/agents/tests/test_gate_evidence.py +56 -0
- package/tools/agents/tests/test_tool_risk.py +521 -0
- 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>`,
|
|
Binary file
|