@miller-tech/uap 1.148.2 → 1.148.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver.d.ts +10 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +89 -1
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/hooks.d.ts +1 -0
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +74 -1
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +28 -7
- package/dist/cli/verify.js.map +1 -1
- package/dist/cli/worktree.js +2 -2
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +25 -8
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/user-validation.d.ts.map +1 -1
- package/dist/delivery/user-validation.js +13 -1
- package/dist/delivery/user-validation.js.map +1 -1
- package/dist/delivery/vision-judge.d.ts +11 -0
- package/dist/delivery/vision-judge.d.ts.map +1 -1
- package/dist/delivery/vision-judge.js +60 -2
- package/dist/delivery/vision-judge.js.map +1 -1
- package/dist/delivery/visual-gate.d.ts +0 -7
- package/dist/delivery/visual-gate.d.ts.map +1 -1
- package/dist/delivery/visual-gate.js +51 -2
- package/dist/delivery/visual-gate.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/_common.py +6 -0
- package/src/policies/enforcers/delivery_enforcement.py +100 -4
- package/src/policies/enforcers/enforcement_self_protect.py +21 -4
- package/src/policies/enforcers/expert_review_required.py +13 -4
- package/templates/hooks/fastpath_gate.py +123 -0
- package/templates/hooks/uap-policy-gate.sh +98 -6
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +217 -0
- package/tools/agents/tests/test_empty_maxtokens_recovery.py +94 -0
- package/tools/agents/tests/test_error_loop_break.py +88 -0
|
@@ -50,8 +50,74 @@ EXEMPT_PREFIXES = (
|
|
|
50
50
|
"src/policies/", "scripts/", "docs/", "policies/", "test/", "tests/",
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
-
# Test files are protected by deliver itself; never gate them here.
|
|
54
|
-
|
|
53
|
+
# Test files are protected by deliver itself; never gate them here. Covers the
|
|
54
|
+
# path markers AND a file whose basename IS a test (e.g. a root-level `test.js`,
|
|
55
|
+
# `tests.py`, `spec.ts`, `test_foo.py`, `foo.test.tsx`) — those are the fast
|
|
56
|
+
# feedback loop and must not each trigger a full deliver cycle.
|
|
57
|
+
TEST_MARKERS = (".test.", ".spec.", "_test.", "test_", "/test/", "/tests/", "/__tests__/", "/spec/", "/specs/")
|
|
58
|
+
_TEST_BASENAMES = ("test", "tests", "spec", "specs", "conftest")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_test_file(rel_posix: str) -> bool:
|
|
62
|
+
low = rel_posix.lower()
|
|
63
|
+
if any(m in "/" + low for m in TEST_MARKERS):
|
|
64
|
+
return True
|
|
65
|
+
stem = low.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
66
|
+
return stem in _TEST_BASENAMES or stem.startswith("test") or stem.endswith("test") or stem.endswith("spec")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Trivial-edit fast-path: a tiny change (a one-line tweak, a constant, a typo)
|
|
70
|
+
# does not warrant a full deliver decompose→epics→gates cycle — on a slow local
|
|
71
|
+
# executor that is ~10+ min per edit. Below this many changed characters the
|
|
72
|
+
# edit is allowed directly (advisory nudge only). UAP_DELIVER_FASTPATH=off
|
|
73
|
+
# disables; UAP_DELIVER_TRIVIAL_EDIT_CHARS tunes the threshold.
|
|
74
|
+
def _fastpath_on() -> bool:
|
|
75
|
+
# The policy gate's cumulative fast-path (fastpath_gate.py) sets
|
|
76
|
+
# UAP_FASTPATH_ROUTED=1 when a trivial edit has crossed the per-file
|
|
77
|
+
# cumulative budget and must route through deliver. Honor it — otherwise the
|
|
78
|
+
# enforcer's own trivial allowance would independently re-approve the edit
|
|
79
|
+
# and defeat the budget (death-by-a-thousand-small-edits escape).
|
|
80
|
+
if os.environ.get("UAP_FASTPATH_ROUTED") == "1":
|
|
81
|
+
return False
|
|
82
|
+
return os.environ.get("UAP_DELIVER_FASTPATH", "on").lower() not in {"0", "off", "false", "no"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _trivial_edit_chars() -> int:
|
|
86
|
+
try:
|
|
87
|
+
return max(0, int(os.environ.get("UAP_DELIVER_TRIVIAL_EDIT_CHARS", "240")))
|
|
88
|
+
except ValueError:
|
|
89
|
+
return 240
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _changed_chars(args: dict) -> int | None:
|
|
93
|
+
"""Total changed characters for an Edit/MultiEdit, or None when it can't be
|
|
94
|
+
measured (e.g. a whole-file Write) — None means 'not trivially small'."""
|
|
95
|
+
def pair(o: object, n: object) -> int:
|
|
96
|
+
return len(str(o or "")) + len(str(n or ""))
|
|
97
|
+
if "edits" in args and isinstance(args["edits"], list):
|
|
98
|
+
return sum(
|
|
99
|
+
pair(e.get("old_string") or e.get("oldString"), e.get("new_string") or e.get("newString"))
|
|
100
|
+
for e in args["edits"] if isinstance(e, dict)
|
|
101
|
+
)
|
|
102
|
+
old = args.get("old_string") or args.get("oldString")
|
|
103
|
+
new = args.get("new_string") or args.get("newString")
|
|
104
|
+
if old is not None or new is not None:
|
|
105
|
+
return pair(old, new)
|
|
106
|
+
return None # Write (full content) — not a trivial edit
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _deliver_lock_holder(root: Path) -> str | None:
|
|
110
|
+
"""PID string of a LIVE deliver run holding the project lock, else None."""
|
|
111
|
+
lock = root / ".uap" / "deliver.lock"
|
|
112
|
+
try:
|
|
113
|
+
pid = int((lock.read_text().split("|")[0] or "").strip())
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
os.kill(pid, 0)
|
|
118
|
+
return str(pid)
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
55
121
|
|
|
56
122
|
|
|
57
123
|
def _is_local_model_session() -> bool:
|
|
@@ -115,8 +181,8 @@ def main() -> None:
|
|
|
115
181
|
if any(rel_posix.startswith(p) for p in EXEMPT_PREFIXES):
|
|
116
182
|
emit(True, f"exempt path: {rel_posix}")
|
|
117
183
|
return
|
|
118
|
-
if
|
|
119
|
-
emit(True, "test file (
|
|
184
|
+
if _is_test_file(rel_posix):
|
|
185
|
+
emit(True, "test file (fast feedback loop; deliver protects it via gates)")
|
|
120
186
|
return
|
|
121
187
|
|
|
122
188
|
# Escape hatches.
|
|
@@ -127,6 +193,36 @@ def main() -> None:
|
|
|
127
193
|
emit(True, "UAP_DELIVER_BYPASS override set")
|
|
128
194
|
return
|
|
129
195
|
|
|
196
|
+
# A — trivial-edit fast-path: allow a tiny change directly (advisory) rather
|
|
197
|
+
# than paying a full deliver cycle for a one-liner.
|
|
198
|
+
if _fastpath_on():
|
|
199
|
+
changed = _changed_chars(args)
|
|
200
|
+
if changed is not None and changed <= _trivial_edit_chars():
|
|
201
|
+
print(
|
|
202
|
+
f"[delivery-enforcement] trivial edit to '{rel_posix}' "
|
|
203
|
+
f"({changed} chars) — allowed directly; route substantive changes through deliver.",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
emit(True, f"trivial edit fast-path ({changed} <= {_trivial_edit_chars()} chars)")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# B — a deliver run is ALREADY working this project. Do NOT launch another
|
|
210
|
+
# (the lock would reject it anyway) and do NOT let the model keep editing/
|
|
211
|
+
# testing the same code underneath it — tell it to WAIT. route:wait so the
|
|
212
|
+
# autoroute stands down instead of enqueuing a duplicate.
|
|
213
|
+
holder = _deliver_lock_holder(root)
|
|
214
|
+
if holder is not None and os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower() == "block":
|
|
215
|
+
emit(
|
|
216
|
+
False,
|
|
217
|
+
f"WAIT: a `uap deliver` run (pid {holder}) is ALREADY in progress for this project and is "
|
|
218
|
+
f"changing the code — do NOT edit '{rel_posix}', and do NOT re-run tests on it yet. "
|
|
219
|
+
"Let that run finish (check: uap deliver --resume latest / its output), THEN re-verify. "
|
|
220
|
+
"Launching or editing now only conflicts with the in-flight run.",
|
|
221
|
+
route="wait",
|
|
222
|
+
deliverHint="",
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
130
226
|
# #3-F: terse, imperative, model-parseable. Weak local models otherwise
|
|
131
227
|
# retry the blocked edit or hallucinate completion ("the files exist") when
|
|
132
228
|
# the message is a passive explanation. State the exact next action.
|
|
@@ -24,7 +24,9 @@ import sys
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
27
|
-
from _common import
|
|
27
|
+
from _common import ( # noqa: E402
|
|
28
|
+
emit, parse_cli, repo_root, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
|
|
29
|
+
)
|
|
28
30
|
|
|
29
31
|
EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
30
32
|
|
|
@@ -66,6 +68,17 @@ def _is_protected_path(rel_posix: str) -> bool:
|
|
|
66
68
|
# "/src/policies/", …) match only a genuine path SEGMENT. Filename markers
|
|
67
69
|
# ("uap-policy-gate.sh", "anthropic-proxy.env") still match as substrings.
|
|
68
70
|
low = "/" + rel_posix.lower().lstrip("/")
|
|
71
|
+
# Designed-writable escape hatches that live UNDER a protected marker but are
|
|
72
|
+
# NOT part of the delivery-enforcement control surface: expert-review
|
|
73
|
+
# artifacts (.uap/reviews/) and the committable review waiver
|
|
74
|
+
# (policies/waivers/). Writing these satisfies the SEPARATE
|
|
75
|
+
# expert-review-required gate and cannot weaken delivery enforcement — the
|
|
76
|
+
# markers above still guard .uap.json, proxy env, enforcer code and hooks.
|
|
77
|
+
# Without this carve-out the two gates deadlock: expert-review demands an
|
|
78
|
+
# artifact that self-protect forbids writing.
|
|
79
|
+
allow = ("/" + REVIEW_ARTIFACT_DIR + "/", "/" + REVIEW_WAIVER_DIR + "/")
|
|
80
|
+
if any(a in low for a in allow):
|
|
81
|
+
return False
|
|
69
82
|
return any(m in low for m in PROTECTED_MARKERS)
|
|
70
83
|
|
|
71
84
|
|
|
@@ -79,11 +92,15 @@ def main() -> None:
|
|
|
79
92
|
target = args.get("file_path") or args.get("path") or args.get("target") or ""
|
|
80
93
|
if not target:
|
|
81
94
|
emit(True, "no file path in args")
|
|
95
|
+
rp = Path(target).resolve()
|
|
82
96
|
try:
|
|
83
|
-
rel = str(
|
|
97
|
+
rel = str(rp.relative_to(repo_root()))
|
|
84
98
|
except ValueError:
|
|
85
|
-
# Out-of-repo writes (e.g. the proxy env in ~/.config) — match by name
|
|
86
|
-
|
|
99
|
+
# Out-of-repo writes (e.g. the proxy env in ~/.config) — match by name
|
|
100
|
+
# against the RESOLVED path, never the raw arg: a raw
|
|
101
|
+
# ".uap/reviews/../../anthropic-proxy.env" would otherwise smuggle the
|
|
102
|
+
# allow-list carve-out to reach an out-of-repo control file.
|
|
103
|
+
rel = str(rp)
|
|
87
104
|
rel_posix = rel.replace(os.sep, "/")
|
|
88
105
|
if _is_protected_path(rel_posix):
|
|
89
106
|
emit(
|
|
@@ -24,7 +24,9 @@ import sys
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
27
|
-
from _common import
|
|
27
|
+
from _common import ( # noqa: E402
|
|
28
|
+
emit, parse_cli, worktree_root, run, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
|
|
29
|
+
)
|
|
28
30
|
|
|
29
31
|
# Ship verbs are anchored to their tool prefix so that the bare tokens "merge"
|
|
30
32
|
# or "signoff" inside read-only commands (git diff --merge-base, rg merge,
|
|
@@ -81,12 +83,12 @@ def _is_low_risk(f: str) -> bool:
|
|
|
81
83
|
def _active_waiver(root: Path) -> bool:
|
|
82
84
|
"""A committable, env-free bypass: an active waiver file. Works in harnesses
|
|
83
85
|
that strip env vars (where UAP_NO_REVIEW=1 cannot be set)."""
|
|
84
|
-
wdir = root /
|
|
86
|
+
wdir = root / REVIEW_WAIVER_DIR
|
|
85
87
|
if wdir.exists():
|
|
86
88
|
for w in wdir.glob("*expert-review*.md"):
|
|
87
89
|
if w.is_file():
|
|
88
90
|
return True
|
|
89
|
-
return (root /
|
|
91
|
+
return (root / REVIEW_ARTIFACT_DIR / "WAIVER").exists()
|
|
90
92
|
|
|
91
93
|
|
|
92
94
|
def current_branch(root: Path) -> str | None:
|
|
@@ -129,6 +131,13 @@ def main() -> None:
|
|
|
129
131
|
emit(True, "not a ship operation")
|
|
130
132
|
|
|
131
133
|
cmd = args.get("command") or args.get("cmd") or ""
|
|
134
|
+
# The policy-gate hook runs in the harness env, not the inline command env,
|
|
135
|
+
# so `UAP_NO_REVIEW=1 git commit ...` never reaches os.environ above. Honor
|
|
136
|
+
# an inline assignment parsed from the command string too.
|
|
137
|
+
# Anchored to a LEADING env-assignment run so an incidental mention in a
|
|
138
|
+
# quoted arg / commit message does NOT silently skip review.
|
|
139
|
+
if re.search(r"^\s*(?:[A-Za-z_]\w*=\S*\s+)*UAP_NO_REVIEW=['\"]?1\b", cmd):
|
|
140
|
+
emit(True, "UAP_NO_REVIEW inline override set")
|
|
132
141
|
if not any(p.search(cmd) for p in SHIP_PATTERNS):
|
|
133
142
|
emit(True, "not a ship action")
|
|
134
143
|
|
|
@@ -159,7 +168,7 @@ def main() -> None:
|
|
|
159
168
|
"— parallel expert review not required",
|
|
160
169
|
)
|
|
161
170
|
|
|
162
|
-
review = root /
|
|
171
|
+
review = root / REVIEW_ARTIFACT_DIR / f"{slug}.json"
|
|
163
172
|
if not review.exists():
|
|
164
173
|
emit(
|
|
165
174
|
False,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Edit fast-path decider with CUMULATIVE accounting.
|
|
3
|
+
|
|
4
|
+
The policy gate lets test-file edits and TRIVIAL source edits (<= TRIVIAL chars
|
|
5
|
+
changed) through directly, so iteration on a slow local executor stays fast
|
|
6
|
+
instead of paying a full deliver cycle per keystroke. But an unbounded per-edit
|
|
7
|
+
threshold is an escape hatch: a weak model can assemble a whole (broken) feature
|
|
8
|
+
out of dozens of sub-threshold edits, none of which ever routes to deliver or
|
|
9
|
+
gets validated ("death by a thousand small edits").
|
|
10
|
+
|
|
11
|
+
This decider keeps the fast-path for genuinely small changes but BOUNDS how much
|
|
12
|
+
un-routed change can accumulate per file. It tallies trivial-edit chars/count per
|
|
13
|
+
file in .uap/fastpath-accum.json; once a file crosses CUM_CHARS total chars OR
|
|
14
|
+
CUM_EDITS edits since it last routed, the NEXT edit is NOT fast-pathed — it falls
|
|
15
|
+
through to the deliver-routing gate (which writes + validates), and the file's
|
|
16
|
+
tally resets. So un-validated drift per file is bounded to ~CUM_CHARS.
|
|
17
|
+
|
|
18
|
+
stdin: the tool_input JSON. exit 0 = fast-path (allow directly); exit 1 = route
|
|
19
|
+
through deliver. On any parse/IO trouble it fails toward ROUTING (safe).
|
|
20
|
+
|
|
21
|
+
env: TRIVIAL (per-edit trivial threshold, default 240), CUM_CHARS (cumulative
|
|
22
|
+
budget, default 800), CUM_EDITS (cumulative edit-count budget, default 6),
|
|
23
|
+
UAP_MAIN_ROOT (project root holding .uap/, default cwd). Test files always
|
|
24
|
+
fast-path and never accumulate.
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
TEST_MARKERS = (
|
|
32
|
+
".test.", ".spec.", "_test.", "test_", "/test/", "/tests/",
|
|
33
|
+
"/__tests__/", "/spec/", "/specs/",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_test(target: str) -> bool:
|
|
38
|
+
base = target.rsplit("/", 1)[-1]
|
|
39
|
+
stem = base.rsplit(".", 1)[0]
|
|
40
|
+
return (
|
|
41
|
+
any(m in "/" + target for m in TEST_MARKERS)
|
|
42
|
+
or stem in ("test", "tests", "spec", "specs", "conftest")
|
|
43
|
+
or stem.startswith("test")
|
|
44
|
+
or stem.endswith("test")
|
|
45
|
+
or stem.endswith("spec")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _changed_chars(a: dict):
|
|
50
|
+
"""Total chars touched by this edit, or None when the shape carries no diff
|
|
51
|
+
(e.g. a whole-file Write) — None => never trivial => route."""
|
|
52
|
+
edits = a.get("edits")
|
|
53
|
+
if isinstance(edits, list):
|
|
54
|
+
return sum(
|
|
55
|
+
len(str(e.get("old_string") or e.get("oldString") or ""))
|
|
56
|
+
+ len(str(e.get("new_string") or e.get("newString") or ""))
|
|
57
|
+
for e in edits
|
|
58
|
+
if isinstance(e, dict)
|
|
59
|
+
)
|
|
60
|
+
o = a.get("old_string") or a.get("oldString")
|
|
61
|
+
n = a.get("new_string") or a.get("newString")
|
|
62
|
+
if o is None and n is None:
|
|
63
|
+
return None
|
|
64
|
+
return len(str(o or "")) + len(str(n or ""))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main() -> int:
|
|
68
|
+
try:
|
|
69
|
+
a = json.loads(sys.stdin.read() or "{}")
|
|
70
|
+
except Exception:
|
|
71
|
+
return 1 # unparseable → route (safe)
|
|
72
|
+
|
|
73
|
+
target = (
|
|
74
|
+
a.get("file_path") or a.get("filePath") or a.get("path")
|
|
75
|
+
or a.get("target") or a.get("filename") or a.get("file") or ""
|
|
76
|
+
).lower()
|
|
77
|
+
if not target:
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
if _is_test(target):
|
|
81
|
+
return 0 # test files always fast-path, no accounting
|
|
82
|
+
|
|
83
|
+
c = _changed_chars(a)
|
|
84
|
+
trivial_threshold = int(os.environ.get("TRIVIAL", "240"))
|
|
85
|
+
if c is None or c > trivial_threshold:
|
|
86
|
+
return 1 # non-trivial (or no diff info) → route
|
|
87
|
+
|
|
88
|
+
# Trivial source edit: allow only while cumulative drift stays under budget.
|
|
89
|
+
cum_chars = int(os.environ.get("CUM_CHARS", "800"))
|
|
90
|
+
cum_edits = int(os.environ.get("CUM_EDITS", "6"))
|
|
91
|
+
root = os.environ.get("UAP_MAIN_ROOT") or "."
|
|
92
|
+
accum_file = Path(root) / ".uap" / "fastpath-accum.json"
|
|
93
|
+
try:
|
|
94
|
+
accum = json.loads(accum_file.read_text())
|
|
95
|
+
if not isinstance(accum, dict):
|
|
96
|
+
accum = {}
|
|
97
|
+
except Exception:
|
|
98
|
+
accum = {}
|
|
99
|
+
|
|
100
|
+
rec = accum.get(target) or {}
|
|
101
|
+
new_chars = int(rec.get("chars", 0)) + c
|
|
102
|
+
new_edits = int(rec.get("edits", 0)) + 1
|
|
103
|
+
|
|
104
|
+
if new_chars > cum_chars or new_edits > cum_edits:
|
|
105
|
+
# Budget crossed: this edit routes through deliver (which validates the
|
|
106
|
+
# accumulated change), and the file's tally resets for the next batch.
|
|
107
|
+
accum[target] = {"chars": 0, "edits": 0}
|
|
108
|
+
decision = 1
|
|
109
|
+
else:
|
|
110
|
+
accum[target] = {"chars": new_chars, "edits": new_edits}
|
|
111
|
+
decision = 0
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
accum_file.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
accum_file.write_text(json.dumps(accum))
|
|
116
|
+
except Exception:
|
|
117
|
+
pass # best-effort; a write failure must not change the verdict
|
|
118
|
+
|
|
119
|
+
return decision
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
|
@@ -63,11 +63,37 @@ export UAP_REPO_ROOT="$MAIN_ROOT"
|
|
|
63
63
|
# actual WORKING TREE, not the (possibly bare) MAIN_ROOT. Expose the current checkout
|
|
64
64
|
# so _common.worktree_root() targets the worktree when an op runs from inside one.
|
|
65
65
|
export UAP_WORKTREE_ROOT="$CHECKOUT_ROOT"
|
|
66
|
-
# Delivery enforcement
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
export
|
|
66
|
+
# Delivery enforcement posture. Precedence (highest first):
|
|
67
|
+
# 1. .uap.json `delivery.enforcement` — the project's DECLARED posture is
|
|
68
|
+
# AUTHORITATIVE (deliberate, reproducible IaC), so a stale ambient
|
|
69
|
+
# UAP_ENFORCE_DELIVERY=advisory (leaked into a launching shell/session env,
|
|
70
|
+
# e.g. an opencode session started months ago when an env export was still
|
|
71
|
+
# in .bashrc) can NOT silently downgrade a project configured to `block`
|
|
72
|
+
# and let the agent bypass deliver. `delivery.localMode` is read the same
|
|
73
|
+
# authoritative way and selects how a local-model session resolves block
|
|
74
|
+
# (advisory|deliver|block — see delivery_enforcement.py).
|
|
75
|
+
# 2. ambient UAP_ENFORCE_DELIVERY (operator/CI per-run override) when the
|
|
76
|
+
# project declares nothing.
|
|
77
|
+
# 3. default: block.
|
|
78
|
+
# Escape hatches always apply and are honored by the enforcer BEFORE mode:
|
|
79
|
+
# UAP_DELIVER_ACTIVE=1 (inside a deliver run) / UAP_DELIVER_BYPASS=1 (sanctioned
|
|
80
|
+
# manual edit) — so config-authoritative block never re-routes deliver's own edits.
|
|
81
|
+
_DLV_CFG="$(python3 -c '
|
|
82
|
+
import json, sys
|
|
83
|
+
try:
|
|
84
|
+
d = (json.load(open(sys.argv[1])).get("delivery") or {})
|
|
85
|
+
e = str(d.get("enforcement", "")).lower()
|
|
86
|
+
print("%s|%s" % (e if e in ("block", "advisory", "off") else "", str(d.get("localMode", "")).lower()))
|
|
87
|
+
except Exception:
|
|
88
|
+
print("|")
|
|
89
|
+
' "$MAIN_ROOT/.uap.json" 2>/dev/null || echo "|")"
|
|
90
|
+
_DLV_MODE="${_DLV_CFG%%|*}"; _DLV_LOCAL="${_DLV_CFG##*|}"
|
|
91
|
+
if [[ -n "$_DLV_MODE" ]]; then
|
|
92
|
+
export UAP_ENFORCE_DELIVERY="$_DLV_MODE" # config-authoritative: overrides stale ambient
|
|
93
|
+
else
|
|
94
|
+
export UAP_ENFORCE_DELIVERY="${UAP_ENFORCE_DELIVERY:-block}"
|
|
95
|
+
fi
|
|
96
|
+
[[ -n "$_DLV_LOCAL" ]] && export UAP_DELIVER_LOCAL_MODE="$_DLV_LOCAL"
|
|
71
97
|
cd "$MAIN_ROOT"
|
|
72
98
|
|
|
73
99
|
TOOL="$(printf '%s' "$PAYLOAD" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("tool_name") or d.get("tool") or "")' 2>/dev/null || true)"
|
|
@@ -103,6 +129,65 @@ print("1" if (hit or bypass) else "0")
|
|
|
103
129
|
# Operator out-of-band override disables the fail-closed guard too.
|
|
104
130
|
[[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && SEC_SENSITIVE=0
|
|
105
131
|
|
|
132
|
+
# ── Edit fast-path (A) ───────────────────────────────────────────────────────
|
|
133
|
+
# Test files and TRIVIAL edits (a one-liner, a constant, a typo) are the fast
|
|
134
|
+
# feedback loop; routing each through the full worktree/task/deliver workflow
|
|
135
|
+
# gates costs a ~10+ min deliver cycle on a slow local executor. Allow them
|
|
136
|
+
# directly — BEFORE the workflow-gate loop — so iteration stays fast. This never
|
|
137
|
+
# bypasses SAFETY: a sensitive control-surface path (SEC_SENSITIVE) is excluded,
|
|
138
|
+
# and self/infra-protect gate bash, not these edits. UAP_DELIVER_FASTPATH=off
|
|
139
|
+
# disables; UAP_DELIVER_TRIVIAL_EDIT_CHARS tunes the trivial threshold.
|
|
140
|
+
if [[ "${UAP_DELIVER_FASTPATH:-on}" != "off" && "$SEC_SENSITIVE" != "1" ]]; then
|
|
141
|
+
case " Edit Write MultiEdit edit write multiedit " in
|
|
142
|
+
*" $TOOL "*)
|
|
143
|
+
# Prefer the cumulative-aware decider (fastpath_gate.py): it bounds how
|
|
144
|
+
# much un-routed trivial change can accumulate per file (CUM_CHARS/
|
|
145
|
+
# CUM_EDITS) so a weak model can't assemble a whole broken feature out of
|
|
146
|
+
# sub-threshold edits that never route to deliver. Fall back to the inline
|
|
147
|
+
# per-edit trivial check when the helper isn't deployed.
|
|
148
|
+
_fp_ok=0
|
|
149
|
+
if [[ -f "$HOOK_DIR/fastpath_gate.py" ]]; then
|
|
150
|
+
# `if <pipeline>; then` (not `<pipeline> && x=1`): a route verdict (exit 1)
|
|
151
|
+
# is a CONDITION here, so `set -e` doesn't abort — it just falls through
|
|
152
|
+
# to the enforcer loop, which blocks+routes.
|
|
153
|
+
if printf '%s' "$ARGS" | TRIVIAL="${UAP_DELIVER_TRIVIAL_EDIT_CHARS:-240}" \
|
|
154
|
+
CUM_CHARS="${UAP_DELIVER_CUMULATIVE_CHARS:-800}" \
|
|
155
|
+
CUM_EDITS="${UAP_DELIVER_CUMULATIVE_EDITS:-6}" \
|
|
156
|
+
UAP_MAIN_ROOT="$MAIN_ROOT" \
|
|
157
|
+
python3 "$HOOK_DIR/fastpath_gate.py" 2>/dev/null; then
|
|
158
|
+
_fp_ok=1
|
|
159
|
+
fi
|
|
160
|
+
elif printf '%s' "$ARGS" | TRIVIAL="${UAP_DELIVER_TRIVIAL_EDIT_CHARS:-240}" python3 -c '
|
|
161
|
+
import json, os, sys
|
|
162
|
+
try: a = json.loads(sys.stdin.read() or "{}")
|
|
163
|
+
except Exception: sys.exit(1)
|
|
164
|
+
target = (a.get("file_path") or a.get("filePath") or a.get("path") or a.get("target") or a.get("filename") or a.get("file") or "").lower()
|
|
165
|
+
base = target.rsplit("/", 1)[-1]; stem = base.rsplit(".", 1)[0]
|
|
166
|
+
markers = (".test.", ".spec.", "_test.", "test_", "/test/", "/tests/", "/__tests__/", "/spec/", "/specs/")
|
|
167
|
+
is_test = any(m in "/" + target for m in markers) or stem in ("test","tests","spec","specs","conftest") or stem.startswith("test") or stem.endswith("test") or stem.endswith("spec")
|
|
168
|
+
def changed(a):
|
|
169
|
+
if isinstance(a.get("edits"), list):
|
|
170
|
+
return sum(len(str(e.get("old_string") or e.get("oldString") or "")) + len(str(e.get("new_string") or e.get("newString") or "")) for e in a["edits"] if isinstance(e, dict))
|
|
171
|
+
o=a.get("old_string") or a.get("oldString"); n=a.get("new_string") or a.get("newString")
|
|
172
|
+
return (len(str(o or ""))+len(str(n or ""))) if (o is not None or n is not None) else None
|
|
173
|
+
c = changed(a)
|
|
174
|
+
trivial = c is not None and c <= int(os.environ.get("TRIVIAL","240"))
|
|
175
|
+
sys.exit(0 if (is_test or trivial) else 1)
|
|
176
|
+
' 2>/dev/null; then
|
|
177
|
+
_fp_ok=1
|
|
178
|
+
fi
|
|
179
|
+
if [[ "$_fp_ok" == "1" ]]; then
|
|
180
|
+
echo "[UAP policy gate] fast-path: test-file or trivial edit (within cumulative budget) — allowed directly (accumulated/substantive source changes route through deliver)." >&2
|
|
181
|
+
exit 0
|
|
182
|
+
fi
|
|
183
|
+
# The cumulative decider chose to ROUTE this edit (budget crossed, or
|
|
184
|
+
# non-trivial). Signal the delivery-enforcement enforcer to skip its OWN
|
|
185
|
+
# trivial allowance so it actually blocks+routes instead of re-approving.
|
|
186
|
+
export UAP_FASTPATH_ROUTED=1
|
|
187
|
+
;;
|
|
188
|
+
esac
|
|
189
|
+
fi
|
|
190
|
+
|
|
106
191
|
fail_closed() {
|
|
107
192
|
echo "[UAP policy gate] FAIL-CLOSED: this operation touches the enforcement control surface but the self-protect enforcer could not run (${1:-machinery unavailable}). Blocked so a broken/absent gate can't become a bypass. (Operator override: UAP_SELF_PROTECT_OFF=1.)" >&2
|
|
108
193
|
exit 2
|
|
@@ -182,7 +267,14 @@ except: print("")' 2>/dev/null || echo "")"
|
|
|
182
267
|
echo "$msg" >&2
|
|
183
268
|
exit 2
|
|
184
269
|
fi
|
|
185
|
-
|
|
270
|
+
# ORDER BY priority DESC: the policies table has a `priority` column but the
|
|
271
|
+
# gate previously ignored it, iterating in rowid order — so a high-priority
|
|
272
|
+
# routing/safety policy (e.g. delivery-enforcement, infra-protect) could be
|
|
273
|
+
# pre-empted by a lower-priority edit-gate that happened to be registered
|
|
274
|
+
# first, and the agent saw the wrong routing message. Honor priority now so
|
|
275
|
+
# the intended policy fires first. (Blocking short-circuits at exit 2, so the
|
|
276
|
+
# highest-priority applicable block wins.) Name is the stable tiebreak.
|
|
277
|
+
done < <(sqlite3 "$DB" "SELECT p.id, p.name, t.toolName FROM policies p JOIN executable_tools t ON t.policyId=p.id WHERE p.isActive=1 ORDER BY p.priority DESC, p.name ASC;")
|
|
186
278
|
|
|
187
279
|
# A sensitive op that no self-protect enforcer ever evaluated = the control
|
|
188
280
|
# surface is unguarded (self-protect not registered/active). Fail closed.
|