@miller-tech/uap 1.81.0 → 1.83.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/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/delivery_enforcement.py +27 -0
- package/src/policies/enforcers/worktree_required.py +16 -2
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +67 -0
- package/tools/agents/tests/test_coordination_early_ban.py +43 -0
- package/tools/agents/tests/test_delivery_enforcement_worktree.py +28 -0
- package/tools/agents/tests/test_recon_deliver_gate.py +45 -0
- package/tools/agents/tests/test_worktree_required.py +63 -0
package/package.json
CHANGED
|
Binary file
|
|
@@ -54,6 +54,31 @@ EXEMPT_PREFIXES = (
|
|
|
54
54
|
TEST_MARKERS = (".test.", ".spec.", "_test.", "/test/", "/tests/", "/__tests__/")
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _is_local_model_session() -> bool:
|
|
58
|
+
"""True when this session targets a self-hosted/local model endpoint."""
|
|
59
|
+
base = (
|
|
60
|
+
os.environ.get("ANTHROPIC_BASE_URL")
|
|
61
|
+
or os.environ.get("OPENAI_BASE_URL")
|
|
62
|
+
or os.environ.get("UAP_INFERENCE_ENDPOINT")
|
|
63
|
+
or ""
|
|
64
|
+
).lower()
|
|
65
|
+
return any(h in base for h in ("127.0.0.1", "localhost", "0.0.0.0", "::1"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# #2: a plain local-model session cannot effectively route through deliver and
|
|
69
|
+
# deadlocks under block mode (it can read but never write -> recon loop, observed
|
|
70
|
+
# live). Downgrade block->advisory for local sessions so the model can write
|
|
71
|
+
# directly; the local proxy already guards it. Set UAP_DELIVER_LOCAL_ADVISORY=0
|
|
72
|
+
# to keep strict block for local sessions too.
|
|
73
|
+
_LOCAL_ADVISORY = os.environ.get("UAP_DELIVER_LOCAL_ADVISORY", "on").lower() not in {
|
|
74
|
+
"0",
|
|
75
|
+
"off",
|
|
76
|
+
"false",
|
|
77
|
+
"no",
|
|
78
|
+
"",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
57
82
|
def main() -> None:
|
|
58
83
|
op, args = parse_cli()
|
|
59
84
|
if op not in EDIT_OPS:
|
|
@@ -99,6 +124,8 @@ def main() -> None:
|
|
|
99
124
|
)
|
|
100
125
|
|
|
101
126
|
mode = os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower()
|
|
127
|
+
if mode == "block" and _LOCAL_ADVISORY and _is_local_model_session():
|
|
128
|
+
mode = "advisory" # #2: unblock plain local-model sessions
|
|
102
129
|
if mode == "block":
|
|
103
130
|
# R1: emit a machine-actionable routing signal so a capable harness can
|
|
104
131
|
# auto-route the blocked change INTO `uap deliver` instead of leaving the
|
|
@@ -46,10 +46,24 @@ def main() -> None:
|
|
|
46
46
|
if os.environ.get("UAP_NO_WORKTREE") == "1":
|
|
47
47
|
emit(True, "UAP_NO_WORKTREE override set")
|
|
48
48
|
|
|
49
|
+
# R1: a worktree cannot exist without git, so the requirement is
|
|
50
|
+
# UNSATISFIABLE on a non-git project -- blocking there is a guaranteed
|
|
51
|
+
# deadlock (the model loops creating tasks about a worktree it can never
|
|
52
|
+
# create). Fail open when there is no git metadata.
|
|
53
|
+
if not os.path.exists(os.path.join(str(root), ".git")):
|
|
54
|
+
emit(True, "not a git repo -- worktrees are not applicable")
|
|
55
|
+
|
|
56
|
+
# R3: imperative message with a working fallback command + a machine-
|
|
57
|
+
# actionable route hint. The old message pointed only at uap worktree
|
|
58
|
+
# create, which fails on hybrid/bare repos.
|
|
49
59
|
emit(
|
|
50
60
|
False,
|
|
51
|
-
f"
|
|
52
|
-
"
|
|
61
|
+
f"BLOCKED: '{rel}' must be edited inside a worktree (.worktrees/NNN-<slug>/). "
|
|
62
|
+
"Create one FIRST, then edit there: `uap worktree create <slug>` "
|
|
63
|
+
"(or if that fails: `git worktree add .worktrees/001-<slug> -b feature/<slug>`). "
|
|
64
|
+
"Do NOT keep planning or creating tasks about the worktree -- run the command now.",
|
|
65
|
+
route="worktree",
|
|
66
|
+
worktreeHint="uap worktree create <slug>",
|
|
53
67
|
)
|
|
54
68
|
|
|
55
69
|
|
|
Binary file
|
|
@@ -206,6 +206,16 @@ PROXY_COORDINATION_TOOLS = {
|
|
|
206
206
|
PROXY_COORDINATION_BAN_THRESHOLD = int(
|
|
207
207
|
os.environ.get("PROXY_COORDINATION_BAN_THRESHOLD", "2")
|
|
208
208
|
)
|
|
209
|
+
# R4: early coordination-loop ban. The per-tool cycle ban above engages only
|
|
210
|
+
# inside an active agentic loop (tool_results present). But a model can loop on a
|
|
211
|
+
# coordination tool from the very FIRST moves -- e.g. repeatedly creating a task
|
|
212
|
+
# ABOUT doing X instead of doing X (observed: TaskCreate x4 identical args at
|
|
213
|
+
# session start) -- where the cycle detector never engages. This bans a
|
|
214
|
+
# coordination tool after N consecutive IDENTICAL calls regardless of loop state.
|
|
215
|
+
# 0 disables.
|
|
216
|
+
PROXY_COORDINATION_EARLY_BAN = int(
|
|
217
|
+
os.environ.get("PROXY_COORDINATION_EARLY_BAN", "3")
|
|
218
|
+
)
|
|
209
219
|
# Force finalize after N consecutive forced_budget_exhausted events where
|
|
210
220
|
# neither cycling nor stagnation was detected — catches "distinct but
|
|
211
221
|
# unproductive" tool spam that defeats per-tool cycle detection.
|
|
@@ -1036,6 +1046,8 @@ class SessionMonitor:
|
|
|
1036
1046
|
tool_state_unproductive_exhaustion_streak: int = 0
|
|
1037
1047
|
last_tool_fingerprint: str = ""
|
|
1038
1048
|
cycling_tool_names: list = field(default_factory=list)
|
|
1049
|
+
coordination_repeat_streak: int = 0 # R4: consecutive identical coordination-tool calls
|
|
1050
|
+
last_coordination_fp: str = "" # R4: fingerprint of the last coordination call
|
|
1039
1051
|
session_banned_tools: set = field(default_factory=set) # tools banned for entire session after repeated cycling
|
|
1040
1052
|
tool_cycle_counts: dict = field(default_factory=dict) # {tool_name: cycle_count} across resets
|
|
1041
1053
|
last_response_garbled: bool = False # previous turn had garbled/malformed output
|
|
@@ -1201,6 +1213,33 @@ class SessionMonitor:
|
|
|
1201
1213
|
if len(self.tool_call_history) > 30:
|
|
1202
1214
|
self.tool_call_history = self.tool_call_history[-30:]
|
|
1203
1215
|
|
|
1216
|
+
# R4: early coordination-loop ban (independent of the active-loop cycle
|
|
1217
|
+
# detector). A coordination/bookkeeping tool repeated with IDENTICAL args
|
|
1218
|
+
# is never productive; ban it directly so narrowing drops it next turn,
|
|
1219
|
+
# breaking the "create a task ABOUT X instead of doing X" loop.
|
|
1220
|
+
coord_names = [n for n in (tool_names or []) if n in PROXY_COORDINATION_TOOLS]
|
|
1221
|
+
if fp and coord_names and fp == self.last_coordination_fp:
|
|
1222
|
+
self.coordination_repeat_streak += 1
|
|
1223
|
+
elif coord_names:
|
|
1224
|
+
self.coordination_repeat_streak = 1
|
|
1225
|
+
self.last_coordination_fp = fp
|
|
1226
|
+
else:
|
|
1227
|
+
self.coordination_repeat_streak = 0
|
|
1228
|
+
self.last_coordination_fp = ""
|
|
1229
|
+
if (
|
|
1230
|
+
PROXY_COORDINATION_EARLY_BAN > 0
|
|
1231
|
+
and self.coordination_repeat_streak >= PROXY_COORDINATION_EARLY_BAN
|
|
1232
|
+
):
|
|
1233
|
+
for n in coord_names:
|
|
1234
|
+
if n not in self.session_banned_tools:
|
|
1235
|
+
self.session_banned_tools.add(n)
|
|
1236
|
+
logger.warning(
|
|
1237
|
+
"TOOL BAN (R4 early): '%s' banned after %d consecutive "
|
|
1238
|
+
"identical coordination calls",
|
|
1239
|
+
n,
|
|
1240
|
+
self.coordination_repeat_streak,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1204
1243
|
# Recon-convergence (B1): count consecutive turns that use tools but
|
|
1205
1244
|
# produce NO write/deliverable tool call. A turn that uses any write
|
|
1206
1245
|
# tool resets the streak — that's the model converging from
|
|
@@ -3810,6 +3849,22 @@ def _resolve_state_machine_tool_choice(
|
|
|
3810
3849
|
return None, "unknown_phase"
|
|
3811
3850
|
|
|
3812
3851
|
|
|
3852
|
+
def _writes_are_gated(openai_body: dict) -> bool:
|
|
3853
|
+
"""True when recent tool results show the harness is BLOCKING direct writes
|
|
3854
|
+
(delivery-enforcement), so a "write the file" directive is futile and the
|
|
3855
|
+
model must route through the deliver tool instead. #1: aligns the recon
|
|
3856
|
+
directive with the gate to break the read-forever / can't-write deadlock."""
|
|
3857
|
+
for m in (openai_body.get("messages") or [])[-8:]:
|
|
3858
|
+
c = m.get("content")
|
|
3859
|
+
text = c if isinstance(c, str) else (json.dumps(c) if c else "")
|
|
3860
|
+
low = text.lower()
|
|
3861
|
+
if "delivery-enforcement" in low or (
|
|
3862
|
+
"blocked" in low and "deliver" in low and "tool" in low
|
|
3863
|
+
):
|
|
3864
|
+
return True
|
|
3865
|
+
return False
|
|
3866
|
+
|
|
3867
|
+
|
|
3813
3868
|
def _maybe_inject_recon_convergence(
|
|
3814
3869
|
openai_body: dict,
|
|
3815
3870
|
monitor: "SessionMonitor",
|
|
@@ -3881,6 +3936,18 @@ def _maybe_inject_recon_convergence(
|
|
|
3881
3936
|
"if strictly required to write it."
|
|
3882
3937
|
)
|
|
3883
3938
|
tier = "firm"
|
|
3939
|
+
# #1: if the harness is blocking direct writes (delivery-enforcement), a
|
|
3940
|
+
# "write the file" directive is impossible to satisfy and the model loops in
|
|
3941
|
+
# recon. Redirect it to the deliver tool — the only write path under a gate.
|
|
3942
|
+
if not escalate and _writes_are_gated(openai_body):
|
|
3943
|
+
directive += (
|
|
3944
|
+
" IMPORTANT: your direct Edit/Write calls are being BLOCKED by policy. "
|
|
3945
|
+
"Do NOT try to write the file directly. Call the `deliver` tool (or run "
|
|
3946
|
+
"`uap deliver \"<one-line task>\"`) to produce the deliverable — that is "
|
|
3947
|
+
"the only path that can write here."
|
|
3948
|
+
)
|
|
3949
|
+
tier = tier + "+deliver-gated"
|
|
3950
|
+
|
|
3884
3951
|
msgs = openai_body.get("messages", [])
|
|
3885
3952
|
msgs.append({"role": "user", "content": directive})
|
|
3886
3953
|
openai_body["messages"] = msgs
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for R4: early coordination-loop ban (independent of active-loop)."""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
|
|
7
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
8
|
+
ap = importlib.util.module_from_spec(spec)
|
|
9
|
+
spec.loader.exec_module(ap)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EarlyCoordinationBanTest(unittest.TestCase):
|
|
13
|
+
def test_config_default(self):
|
|
14
|
+
self.assertEqual(ap.PROXY_COORDINATION_EARLY_BAN, 3)
|
|
15
|
+
|
|
16
|
+
def test_identical_coordination_repeats_get_banned(self):
|
|
17
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
18
|
+
fp = "TaskCreate:deadbeef"
|
|
19
|
+
for _ in range(3):
|
|
20
|
+
m.record_tool_calls(["TaskCreate"], fingerprint=fp)
|
|
21
|
+
self.assertIn("TaskCreate", m.session_banned_tools)
|
|
22
|
+
|
|
23
|
+
def test_two_repeats_not_yet_banned(self):
|
|
24
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
25
|
+
for _ in range(2):
|
|
26
|
+
m.record_tool_calls(["TaskCreate"], fingerprint="TaskCreate:x")
|
|
27
|
+
self.assertNotIn("TaskCreate", m.session_banned_tools)
|
|
28
|
+
|
|
29
|
+
def test_non_coordination_tool_not_banned(self):
|
|
30
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
31
|
+
for _ in range(5):
|
|
32
|
+
m.record_tool_calls(["Bash"], fingerprint="Bash:same")
|
|
33
|
+
self.assertNotIn("Bash", m.session_banned_tools)
|
|
34
|
+
|
|
35
|
+
def test_varying_args_reset_streak(self):
|
|
36
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
37
|
+
for i in range(5):
|
|
38
|
+
m.record_tool_calls(["TaskCreate"], fingerprint=f"TaskCreate:{i}")
|
|
39
|
+
self.assertNotIn("TaskCreate", m.session_banned_tools)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
unittest.main()
|
|
@@ -75,3 +75,31 @@ class TestDeliveryEnforcementWorktree(unittest.TestCase):
|
|
|
75
75
|
|
|
76
76
|
if __name__ == "__main__":
|
|
77
77
|
unittest.main()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
import os as _os, subprocess as _sp, sys as _sys, json as _json, tempfile as _tf
|
|
81
|
+
from pathlib import Path as _Path
|
|
82
|
+
_ENF = _Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "delivery_enforcement.py"
|
|
83
|
+
|
|
84
|
+
class LocalAdvisoryTest(unittest.TestCase):
|
|
85
|
+
def _run(self, extra_env):
|
|
86
|
+
with _tf.TemporaryDirectory() as td:
|
|
87
|
+
root = _Path(td); (root/".git").mkdir()
|
|
88
|
+
f = root/"src"/"a.ts"; f.parent.mkdir(parents=True); f.write_text("x")
|
|
89
|
+
e = dict(_os.environ); e["UAP_REPO_ROOT"]=str(root)
|
|
90
|
+
for k in ("ANTHROPIC_BASE_URL","UAP_DELIVER_LOCAL_ADVISORY","UAP_DELIVER_ACTIVE"): e.pop(k, None)
|
|
91
|
+
e.update(extra_env)
|
|
92
|
+
p = _sp.run([_sys.executable, str(_ENF), "--operation","Write","--args",_json.dumps({"file_path":str(f)})], capture_output=True, text=True, env=e)
|
|
93
|
+
return p.returncode, _json.loads(p.stdout) if p.stdout.strip() else {}
|
|
94
|
+
|
|
95
|
+
def test_local_session_downgrades_to_advisory(self):
|
|
96
|
+
rc, out = self._run({"ANTHROPIC_BASE_URL":"http://127.0.0.1:4000"})
|
|
97
|
+
self.assertEqual(rc, 0); self.assertTrue(out["allowed"])
|
|
98
|
+
|
|
99
|
+
def test_local_advisory_off_keeps_block(self):
|
|
100
|
+
rc, out = self._run({"ANTHROPIC_BASE_URL":"http://127.0.0.1:4000","UAP_DELIVER_LOCAL_ADVISORY":"0"})
|
|
101
|
+
self.assertEqual(rc, 2)
|
|
102
|
+
|
|
103
|
+
def test_cloud_session_still_blocks(self):
|
|
104
|
+
rc, out = self._run({"ANTHROPIC_BASE_URL":"https://api.anthropic.com"})
|
|
105
|
+
self.assertEqual(rc, 2)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for #1: RECON directive is write-block aware (routes to deliver)."""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
|
|
7
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
8
|
+
ap = importlib.util.module_from_spec(spec)
|
|
9
|
+
spec.loader.exec_module(ap)
|
|
10
|
+
|
|
11
|
+
GATED_MSG = {"role": "user", "content": [
|
|
12
|
+
{"type": "tool_result", "content": "[UAP policy blocked: delivery-enforcement] BLOCKED: call the `deliver` tool"}
|
|
13
|
+
]}
|
|
14
|
+
PLAIN_MSG = {"role": "user", "content": "read the next file"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WritesGatedTest(unittest.TestCase):
|
|
18
|
+
def test_detects_delivery_enforcement_block(self):
|
|
19
|
+
self.assertTrue(ap._writes_are_gated({"messages": [GATED_MSG]}))
|
|
20
|
+
|
|
21
|
+
def test_plain_messages_not_gated(self):
|
|
22
|
+
self.assertFalse(ap._writes_are_gated({"messages": [PLAIN_MSG]}))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReconDirectiveTest(unittest.TestCase):
|
|
26
|
+
def _run(self, gated):
|
|
27
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
28
|
+
m.consecutive_no_write_turns = max(1, ap.PROXY_RECON_CONVERGENCE_THRESHOLD)
|
|
29
|
+
body = {"messages": [GATED_MSG if gated else PLAIN_MSG], "tools": [{"name": "Write"}]}
|
|
30
|
+
ap._maybe_inject_recon_convergence(body, m)
|
|
31
|
+
return body["messages"][-1]["content"]
|
|
32
|
+
|
|
33
|
+
def test_gated_directive_routes_to_deliver(self):
|
|
34
|
+
d = self._run(gated=True)
|
|
35
|
+
self.assertIn("deliver", d.lower())
|
|
36
|
+
self.assertIn("BLOCKED", d)
|
|
37
|
+
|
|
38
|
+
def test_ungated_directive_says_write(self):
|
|
39
|
+
d = self._run(gated=False)
|
|
40
|
+
self.assertIn("write", d.lower())
|
|
41
|
+
self.assertNotIn("being BLOCKED by policy", d)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
unittest.main()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Tests for worktree-required R1 (fail-open on non-git) + R3 (route hint)."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ENF = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "worktree_required.py"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(root, target):
|
|
14
|
+
e = dict(os.environ)
|
|
15
|
+
e["UAP_REPO_ROOT"] = str(root)
|
|
16
|
+
e.pop("UAP_NO_WORKTREE", None)
|
|
17
|
+
p = subprocess.run(
|
|
18
|
+
[sys.executable, str(ENF), "--operation", "Write",
|
|
19
|
+
"--args", json.dumps({"file_path": str(target)})],
|
|
20
|
+
capture_output=True, text=True, env=e,
|
|
21
|
+
)
|
|
22
|
+
out = json.loads(p.stdout) if p.stdout.strip() else {}
|
|
23
|
+
return p.returncode, out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorktreeRequiredTest(unittest.TestCase):
|
|
27
|
+
def test_R1_fail_open_on_non_git(self):
|
|
28
|
+
with tempfile.TemporaryDirectory() as td:
|
|
29
|
+
root = Path(td)
|
|
30
|
+
f = root / "foo.ts"
|
|
31
|
+
f.write_text("x")
|
|
32
|
+
rc, out = run(root, f)
|
|
33
|
+
self.assertEqual(rc, 0)
|
|
34
|
+
self.assertTrue(out["allowed"])
|
|
35
|
+
self.assertIn("not a git repo", out["reason"])
|
|
36
|
+
|
|
37
|
+
def test_R3_blocks_with_route_on_git_repo(self):
|
|
38
|
+
with tempfile.TemporaryDirectory() as td:
|
|
39
|
+
root = Path(td)
|
|
40
|
+
(root / ".git").mkdir() # make it look like a git repo
|
|
41
|
+
f = root / "src" / "app.ts"
|
|
42
|
+
f.parent.mkdir(parents=True)
|
|
43
|
+
f.write_text("x")
|
|
44
|
+
rc, out = run(root, f)
|
|
45
|
+
self.assertEqual(rc, 2)
|
|
46
|
+
self.assertFalse(out["allowed"])
|
|
47
|
+
self.assertEqual(out.get("route"), "worktree")
|
|
48
|
+
self.assertIn("worktreeHint", out)
|
|
49
|
+
self.assertIn("run the command now", out["reason"])
|
|
50
|
+
|
|
51
|
+
def test_worktree_path_allowed(self):
|
|
52
|
+
with tempfile.TemporaryDirectory() as td:
|
|
53
|
+
root = Path(td)
|
|
54
|
+
(root / ".git").mkdir()
|
|
55
|
+
f = root / ".worktrees" / "001-x" / "a.ts"
|
|
56
|
+
f.parent.mkdir(parents=True)
|
|
57
|
+
f.write_text("x")
|
|
58
|
+
rc, out = run(root, f)
|
|
59
|
+
self.assertEqual(rc, 0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
unittest.main()
|