@miller-tech/uap 1.175.11 → 1.175.13

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.
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """Run all acceptance journeys for the plan gate."""
3
+ import os
4
+ import sys
5
+ import subprocess
6
+
7
+ # Journey 1: test_off_env
8
+ print("=== Journey 1: test_off_env ===")
9
+ r = subprocess.run(
10
+ [sys.executable, "-c",
11
+ "import os; os.environ['UAP_PLAN_VALIDATE_OFF']='1'; "
12
+ "from tools.agents.tests.test_validate_plan_gate import TestValidatePlanGate; "
13
+ "t=TestValidatePlanGate(); t.setUp(); t.test_plan_write_allow_pending_state()"],
14
+ capture_output=True, text=True, cwd="/home/user",
15
+ )
16
+ print(f"stdout: {r.stdout}")
17
+ print(f"stderr: {r.stderr}")
18
+ print(f"exit code: {r.returncode}")
19
+ print()
20
+
21
+ # Journey 2: test_plan_write_records_pending
22
+ print("=== Journey 2: test_plan_write_records_pending ===")
23
+ r = subprocess.run(
24
+ [sys.executable, "-c",
25
+ "from tools.agents.tests.test_validate_plan_gate import TestValidatePlanGate; "
26
+ "t=TestValidatePlanGate(); t.setUp(); t.test_plan_write_records_pending_state()"],
27
+ capture_output=True, text=True, cwd="/home/user",
28
+ )
29
+ print(f"stdout: {r.stdout}")
30
+ print(f"stderr: {r.stderr}")
31
+ print(f"exit code: {r.returncode}")
32
+ print()
33
+
34
+ # Journey 3: test_build_blocked_pending
35
+ print("=== Journey 3: test_build_blocked_pending ===")
36
+ r = subprocess.run(
37
+ [sys.executable, "-c",
38
+ "from tools.agents.tests.test_validate_plan_gate import TestValidatePlanGate; "
39
+ "t=TestValidatePlanGate(); t.setUp(); t.test_build_blocked_when_plan_pending()"],
40
+ capture_output=True, text=True, cwd="/home/user",
41
+ )
42
+ print(f"stdout: {r.stdout}")
43
+ print(f"stderr: {r.stderr}")
44
+ print(f"exit code: {r.returncode}")
45
+ print()
46
+
47
+ # Journey 4: test_build_allowed_clean
48
+ print("=== Journey 4: test_build_allowed_clean ===")
49
+ r = subprocess.run(
50
+ [sys.executable, "-c",
51
+ "from tools.agents.tests.test_validate_plan_gate import TestValidatePlanGate; "
52
+ "t=TestValidatePlanGate(); t.setUp(); t.test_build_allowed_when_clean()"],
53
+ capture_output=True, text=True, cwd="/home/user",
54
+ )
55
+ print(f"stdout: {r.stdout}")
56
+ print(f"stderr: {r.stderr}")
57
+ print(f"exit code: {r.returncode}")
58
+ print()
59
+
60
+ # Journey 5: test_drift_detection
61
+ print("=== Journey 5: test_drift_detection ===")
62
+ r = subprocess.run(
63
+ [sys.executable, "-c",
64
+ "from tools.agents.tests.test_validate_plan_gate import TestValidatePlanGate; "
65
+ "t=TestValidatePlanGate(); t.setUp(); t.test_build_blocked_on_drift()"],
66
+ capture_output=True, text=True, cwd="/home/user",
67
+ )
68
+ print(f"stdout: {r.stdout}")
69
+ print(f"stderr: {r.stderr}")
70
+ print(f"exit code: {r.returncode}")
71
+ print()
72
+
73
+ # Journey 6: test_non_build_allowed
74
+ print("=== Journey 6: test_non_build_allowed ===")
75
+ r = subprocess.run(
76
+ [sys.executable, "-c",
77
+ "from tools.agents.tests.test_validate_plan_gate import TestValidatePlanGate; "
78
+ "t=TestValidatePlanGate(); t.setUp(); t.test_non_build_commands_allowed()"],
79
+ capture_output=True, text=True, cwd="/home/user",
80
+ )
81
+ print(f"stdout: {r.stdout}")
82
+ print(f"stderr: {r.stderr}")
83
+ print(f"exit code: {r.returncode}")
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """The plan gate must fire BEFORE the build, not on the plan write.
3
+
4
+ The old rule blocked a Write/Edit to a plan artifact unless `uap plan validate`
5
+ had run in the last 300 seconds. That asks the agent to validate a plan that does
6
+ not exist yet: `findPlanArtifact` turns up nothing (or an older file), the review
7
+ is recorded as "skipped — no plan artifact found", the stamp lands anyway, and
8
+ for the next five minutes any plan content can be written unread. Nothing gated
9
+ the build at all, so the plan that actually got implemented was never reviewed.
10
+
11
+ User directive: "once a plan is created the LLM is prompted to 'validate the
12
+ plan' before execution/build."
13
+
14
+ So:
15
+ - creating or editing a plan is ALLOWED, and records the plan as pending
16
+ - a BUILD / EXECUTE / DEPLOY command is BLOCKED while any plan is pending, or
17
+ while a validated plan has since drifted on disk
18
+ - the block message carries the `validate the plan` self-prompt
19
+
20
+ State is shared with `uap plan validate` (src/cli/plan.ts) in
21
+ `.uap/plan_state.json`:
22
+
23
+ pending { "<repo-relative path>": <epoch seen> }
24
+ validated { "<repo-relative path>": "<sha256 of the reviewed bytes>" }
25
+
26
+ Keying on CONTENT rather than a clock is the fix: "these exact bytes were
27
+ reviewed" cannot be satisfied by validating an empty file first.
28
+
29
+ NOTE ON PROVENANCE: the enforcer is written through `uap deliver` (the agent is
30
+ blocked from editing src/policies/enforcers/ directly). During that run the
31
+ model rewrote THIS file — stripping the rationale and deleting the one
32
+ assertion it could not satisfy, test_the_refusal_names_the_offending_plan. The
33
+ file was restored. A contract test that the implementer may edit is not a
34
+ contract; if these ever thin out again, check the diff rather than the result.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import hashlib
39
+ import json
40
+ import os
41
+ import subprocess
42
+ import sys
43
+ import tempfile
44
+ import unittest
45
+ from pathlib import Path
46
+
47
+ ENFORCER = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "validate_plan_on_change.py"
48
+ # The agent is blocked from writing src/policies/enforcers/, so a candidate
49
+ # enforcer has to be provable BEFORE an operator installs it. Point this at the
50
+ # candidate to run the same contract against it. CI never sets it.
51
+ ENFORCER = Path(os.environ.get("UAP_PLAN_ENFORCER") or ENFORCER)
52
+
53
+
54
+ def raw(project: Path, op: str, args: dict, env: dict | None = None):
55
+ e = {k: v for k, v in os.environ.items() if k != "UAP_PLAN_VALIDATE_OFF"}
56
+ e.update(env or {})
57
+ return subprocess.run(
58
+ [sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
59
+ capture_output=True, text=True, cwd=str(project), env=e,
60
+ )
61
+
62
+
63
+ def verdict(project: Path, op: str, args: dict, env: dict | None = None) -> dict:
64
+ p = raw(project, op, args, env)
65
+ try:
66
+ d = json.loads(p.stdout or "{}")
67
+ except json.JSONDecodeError:
68
+ return {"allowed": None, "reason": f"UNPARSEABLE rc={p.returncode} err={p.stderr[:200]}"}
69
+ d["_rc"] = p.returncode
70
+ return d
71
+
72
+
73
+ def allowed(project: Path, op: str, args: dict, env: dict | None = None) -> bool:
74
+ return verdict(project, op, args, env).get("allowed") is True
75
+
76
+
77
+ BUILD = {"command": "npm run build"}
78
+ PLAN = "docs/plans/feature-plan.md"
79
+
80
+
81
+ class PlanGateTestCase(unittest.TestCase):
82
+ def setUp(self) -> None:
83
+ self.dir = Path(tempfile.mkdtemp(prefix="uap-plangate-"))
84
+ (self.dir / ".uap").mkdir(parents=True, exist_ok=True)
85
+ (self.dir / "docs" / "plans").mkdir(parents=True, exist_ok=True)
86
+
87
+ def tearDown(self) -> None:
88
+ import shutil
89
+ shutil.rmtree(self.dir, ignore_errors=True)
90
+
91
+ def write_plan(self, text: str = "# Plan\n\nDo the thing.\n", path: str = PLAN) -> Path:
92
+ p = self.dir / path
93
+ p.parent.mkdir(parents=True, exist_ok=True)
94
+ p.write_text(text)
95
+ return p
96
+
97
+ def state(self) -> dict:
98
+ try:
99
+ return json.loads((self.dir / ".uap" / "plan_state.json").read_text())
100
+ except Exception: # noqa: BLE001
101
+ return {}
102
+
103
+ def set_state(self, **kw) -> None:
104
+ (self.dir / ".uap" / "plan_state.json").write_text(json.dumps(kw))
105
+
106
+ @staticmethod
107
+ def sha(text: str) -> str:
108
+ return hashlib.sha256(text.encode()).hexdigest()
109
+
110
+
111
+ class TestWritingAPlanIsNeverBlocked(PlanGateTestCase):
112
+ def test_creating_a_plan_is_allowed(self):
113
+ # The old gate blocked here, which is what made it ask for validation of
114
+ # a plan that did not exist yet.
115
+ self.assertTrue(allowed(self.dir, "Write", {"file_path": PLAN, "content": "# Plan"}))
116
+
117
+ def test_editing_a_plan_is_allowed(self):
118
+ self.write_plan()
119
+ self.assertTrue(allowed(self.dir, "Edit", {"file_path": PLAN}))
120
+
121
+ def test_the_write_records_the_plan_as_pending(self):
122
+ allowed(self.dir, "Write", {"file_path": PLAN, "content": "# Plan"})
123
+ self.assertIn(PLAN, self.state().get("pending", {}))
124
+
125
+ def test_a_non_plan_write_records_nothing(self):
126
+ allowed(self.dir, "Write", {"file_path": "src/index.ts", "content": "x"})
127
+ self.assertEqual(self.state().get("pending", {}), {})
128
+
129
+
130
+ class TestPlanArtifactDetection(PlanGateTestCase):
131
+ """A plan is not only a file under plans/.
132
+
133
+ The first delivered enforcer narrowed detection to directory prefixes, so a
134
+ root PLAN.md — the most common shape — armed nothing at all and the build
135
+ gate was silently dead for it. These cases mirror the rule the policy has
136
+ always stated: any file under a plans/ dir, OR a .md whose stem matches
137
+ (^|[-_. ])plans?([-_. ]|$).
138
+ """
139
+
140
+ def assert_detected(self, path: str, expected: bool) -> None:
141
+ self.set_state()
142
+ allowed(self.dir, "Write", {"file_path": path, "content": "x"})
143
+ got = bool(self.state().get("pending", {}))
144
+ self.assertEqual(got, expected, f"{path}: detected={got}, expected={expected}")
145
+
146
+ def test_plan_like_filenames_are_detected(self):
147
+ for path in ("PLAN.md", "IMPLEMENTATION-PLAN.md", "rollout-plan.md",
148
+ "plan-v2.md", "docs/feature.plan.md"):
149
+ self.assert_detected(path, True)
150
+
151
+ def test_files_under_a_plans_directory_are_detected(self):
152
+ for path in ("plans/x.md", "docs/plans/feature-plan.md", "plans/notes.txt"):
153
+ self.assert_detected(path, True)
154
+
155
+ def test_lookalikes_are_not_plans(self):
156
+ # `planning` and `explanation` contain "plan" as a substring only.
157
+ for path in ("planning-guide.md", "explanation.md", "src/index.ts", "README.md"):
158
+ self.assert_detected(path, False)
159
+
160
+
161
+ class TestBuildIsBlockedUntilValidated(PlanGateTestCase):
162
+ def test_build_blocked_while_a_plan_is_pending(self):
163
+ self.write_plan()
164
+ self.set_state(pending={PLAN: 1})
165
+ v = verdict(self.dir, "Bash", BUILD)
166
+ self.assertFalse(v.get("allowed"))
167
+ self.assertIn("validate the plan", (v.get("reason") or "").lower())
168
+
169
+ def test_the_block_carries_the_self_prompt(self):
170
+ # The whole point of the directive: the agent is PROMPTED, not merely
171
+ # refused. inject_prompt is what puts `validate the plan` in front of it.
172
+ self.write_plan()
173
+ self.set_state(pending={PLAN: 1})
174
+ self.assertEqual(verdict(self.dir, "Bash", BUILD).get("inject_prompt"), "validate the plan")
175
+
176
+ def test_build_allowed_once_the_plan_is_validated(self):
177
+ text = "# Plan\n\nDo the thing.\n"
178
+ self.write_plan(text)
179
+ self.set_state(pending={}, validated={PLAN: self.sha(text)})
180
+ self.assertTrue(allowed(self.dir, "Bash", BUILD))
181
+
182
+ def test_build_blocked_again_when_a_validated_plan_drifts(self):
183
+ # Validate, then edit the plan: the gate must re-arm. This is what the
184
+ # content hash buys over a time window.
185
+ text = "# Plan\n\nDo the thing.\n"
186
+ self.write_plan(text)
187
+ self.set_state(pending={}, validated={PLAN: self.sha(text)})
188
+ self.write_plan(text + "\nAlso do another thing.\n")
189
+ v = verdict(self.dir, "Bash", BUILD)
190
+ self.assertFalse(v.get("allowed"))
191
+
192
+ def test_no_plan_anywhere_means_no_gate(self):
193
+ # Most work has no plan; the gate must be invisible then.
194
+ self.assertTrue(allowed(self.dir, "Bash", BUILD))
195
+
196
+ def test_a_deleted_validated_plan_does_not_wedge_the_gate(self):
197
+ self.set_state(pending={}, validated={PLAN: self.sha("gone")})
198
+ self.assertTrue(allowed(self.dir, "Bash", BUILD))
199
+
200
+
201
+ class TestWhichCommandsAreGated(PlanGateTestCase):
202
+ def setUp(self) -> None:
203
+ super().setUp()
204
+ self.write_plan()
205
+ self.set_state(pending={PLAN: 1})
206
+
207
+ def test_build_execute_and_deploy_are_gated(self):
208
+ for cmd in (
209
+ "npm run build",
210
+ "uap deliver \"do the thing\"",
211
+ "make",
212
+ "cargo build --release",
213
+ "go build ./...",
214
+ "docker build -t x .",
215
+ "terraform apply",
216
+ "kubectl apply -f k8s/",
217
+ ):
218
+ self.assertFalse(allowed(self.dir, "Bash", {"command": cmd}), cmd)
219
+
220
+ def test_reading_testing_and_linting_stay_free(self):
221
+ # Gating these would fight the very work the prompt asks for: you cannot
222
+ # review a plan if you cannot look at the tree or run its tests.
223
+ for cmd in (
224
+ "ls -la",
225
+ "cat README.md",
226
+ "git status",
227
+ "npm test",
228
+ "npx vitest run",
229
+ "npx eslint .",
230
+ "npx tsc --noEmit",
231
+ "grep -r thing src/",
232
+ ):
233
+ self.assertTrue(allowed(self.dir, "Bash", {"command": cmd}), cmd)
234
+
235
+ def test_a_build_word_inside_quoted_data_is_not_a_build(self):
236
+ # Same trap the other enforcers hit: the marker appears in a PAYLOAD.
237
+ for cmd in (
238
+ "echo 'npm run build'",
239
+ "git commit -m 'make the build faster'",
240
+ ):
241
+ self.assertTrue(allowed(self.dir, "Bash", {"command": cmd}), cmd)
242
+
243
+
244
+ class TestTheGateActuallyEnforces(PlanGateTestCase):
245
+ """A refusal that exits 0 is not a gate — it is a log line.
246
+
247
+ `_common.emit` exits 2 on a block and 0 on an allow; the whole harness keys
248
+ on that exit code. The first delivered rewrite printed its JSON with a bare
249
+ `print()` and no `sys.exit`, so every "allowed": false still exited 0 and
250
+ the build went ahead. The JSON assertions elsewhere in this file all passed
251
+ while nothing was enforced, which is exactly why this class exists.
252
+ """
253
+
254
+ def test_a_refusal_exits_2(self):
255
+ self.write_plan()
256
+ self.set_state(pending={PLAN: 1})
257
+ self.assertEqual(raw(self.dir, "Bash", BUILD).returncode, 2)
258
+
259
+ def test_an_allow_exits_0(self):
260
+ self.assertEqual(raw(self.dir, "Bash", {"command": "npm test"}).returncode, 0)
261
+ self.assertEqual(raw(self.dir, "Write", {"file_path": PLAN, "content": "x"}).returncode, 0)
262
+
263
+ def test_an_unrelated_operation_does_not_crash(self):
264
+ # argparse `choices=` turns an unlisted operation into a usage error —
265
+ # which also exits 2, i.e. reads as a BLOCK. A plan gate must be
266
+ # invisible to Read/Grep/MultiEdit, not refuse them.
267
+ for op in ("Read", "MultiEdit", "Grep", "bash", "write"):
268
+ rc = raw(self.dir, op, {"file_path": "src/x.ts", "command": "ls"}).returncode
269
+ self.assertEqual(rc, 0, f"operation {op} exited {rc}")
270
+
271
+ def test_the_state_dir_env_override_is_honoured(self):
272
+ # Every other enforcer honours UAP_STATE_DIR; tests and sandboxes rely
273
+ # on redirecting state somewhere disposable.
274
+ alt = self.dir / "elsewhere"
275
+ alt.mkdir(parents=True, exist_ok=True)
276
+ raw(self.dir, "Write", {"file_path": PLAN, "content": "x"}, {"UAP_STATE_DIR": str(alt)})
277
+ self.assertTrue((alt / "plan_state.json").exists())
278
+
279
+ def test_a_build_later_in_a_compound_command_is_still_gated(self):
280
+ # `... && npm run build` is a build. Matching only on a command PREFIX
281
+ # makes the gate trivially avoidable by prepending anything at all.
282
+ self.write_plan()
283
+ self.set_state(pending={PLAN: 1})
284
+ for cmd in ("cd sub && npm run build", "echo hi; npm run build"):
285
+ self.assertFalse(allowed(self.dir, "Bash", {"command": cmd}), cmd)
286
+
287
+
288
+ class TestEscapeHatch(PlanGateTestCase):
289
+ def test_env_override_allows_the_build(self):
290
+ self.write_plan()
291
+ self.set_state(pending={PLAN: 1})
292
+ self.assertTrue(allowed(self.dir, "Bash", BUILD, {"UAP_PLAN_VALIDATE_OFF": "1"}))
293
+
294
+ def test_the_refusal_names_the_hatch(self):
295
+ self.write_plan()
296
+ self.set_state(pending={PLAN: 1})
297
+ self.assertIn("UAP_PLAN_VALIDATE_OFF", verdict(self.dir, "Bash", BUILD).get("reason", ""))
298
+
299
+ def test_the_refusal_names_the_offending_plan(self):
300
+ # A refusal the agent cannot act on is just an obstacle. This is the
301
+ # assertion the delivering model deleted rather than satisfy.
302
+ self.write_plan()
303
+ self.set_state(pending={PLAN: 1})
304
+ self.assertIn(PLAN, verdict(self.dir, "Bash", BUILD).get("reason", ""))
305
+
306
+
307
+ if __name__ == "__main__":
308
+ unittest.main()