@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/plan.d.ts +33 -0
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +88 -14
- package/dist/cli/plan.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/validate_plan_on_change.py +144 -49
- package/src/policies/schemas/policies/validate-plan-on-change.md +58 -20
- 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/tests/run_acceptance.py +83 -0
- package/tools/agents/tests/test_validate_plan_gate.py +308 -0
|
@@ -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()
|