@miller-tech/uap 1.179.0 → 1.179.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.179.0",
3
+ "version": "1.179.2",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate",
24
+ "test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -31,7 +31,7 @@
31
31
  "install:web": "bash scripts/setup/install-web.sh",
32
32
  "install:desktop": "bash scripts/setup/install-desktop.sh",
33
33
  "install:cloakbrowser": "tsx scripts/setup/install-cloakbrowser.ts",
34
- "postinstall": "echo '\n\u2728 Run: npx @miller-tech/uap init --interactive'",
34
+ "postinstall": "echo '\n Run: npx @miller-tech/uap init --interactive'",
35
35
  "version:patch": "bash scripts/version-bump.sh patch",
36
36
  "version:minor": "bash scripts/version-bump.sh minor",
37
37
  "version:major": "bash scripts/version-bump.sh major",
@@ -119,6 +119,49 @@ def _is_plan_file(target: str) -> bool:
119
119
  return bool(PLAN_STEM_RE.search(name[:-3]))
120
120
 
121
121
 
122
+ def _inside_project(target: str) -> bool:
123
+ """True when `target` resolves to the project root or below it.
124
+
125
+ `uap plan validate` refuses any file outside the project directory, so
126
+ recording one creates a pending entry NOTHING can clear: every build in the
127
+ repo blocks, and the remedy the refusal names declines the file. Observed
128
+ live with a memory note under ~/.claude/, matched only because its filename
129
+ contained "plan".
130
+
131
+ Fails CLOSED on error (treat as inside, i.e. record it). Failing open here
132
+ would let a transient getcwd() error silently un-gate a plan write.
133
+ """
134
+ try:
135
+ root = os.path.realpath(os.getcwd())
136
+ abs_target = os.path.realpath(os.path.join(root, target))
137
+ return abs_target == root or abs_target.startswith(root + os.sep)
138
+ except Exception: # noqa: BLE001
139
+ return True
140
+
141
+
142
+ def _prune_unvalidatable(pending: dict) -> tuple[dict, list]:
143
+ """Split pending into (kept, dropped) — OUTSIDE-PROJECT entries only.
144
+
145
+ Those can never be cleared: `uap plan validate` refuses them by design, so
146
+ they block every build with a remedy that declines the file. This only
147
+ matters for state recorded before the tracking fix above.
148
+
149
+ A MISSING file is deliberately NOT pruned here. `mv PLAN.md PLAN2.md` is not
150
+ an edit op, so no new entry is recorded; auto-forgiving the old key on the
151
+ build path would make a rename a silent, unattended gate bypass with the
152
+ plan content fully intact. A deleted plan is dropped only by an operator
153
+ running `uap plan clear`, which records what it dropped and why.
154
+ """
155
+ kept: dict = {}
156
+ dropped: list = []
157
+ for key, seen in pending.items():
158
+ if not _inside_project(key):
159
+ dropped.append(key)
160
+ else:
161
+ kept[key] = seen
162
+ return kept, dropped
163
+
164
+
122
165
  def _key(target: str) -> str:
123
166
  """Repo-relative, forward-slashed — the shape `uap plan validate` records."""
124
167
  posix = target.replace(os.sep, "/")
@@ -148,6 +191,8 @@ def _refuse(paths: list[str], why: str) -> None:
148
191
  "risks, and whether it still matches the request.\n"
149
192
  f" 2. Record it: `uap plan validate {sorted(paths)[0]}`.\n"
150
193
  " 3. Retry this command.\n"
194
+ "(If validation refuses the file — outside the project, or deleted — it can "
195
+ "never clear: `uap plan status` names it and `uap plan clear` drops it.)\n"
151
196
  "(Escape hatch, justify in the plan/PR: UAP_PLAN_VALIDATE_OFF=1.)",
152
197
  inject_prompt="validate the plan",
153
198
  )
@@ -164,6 +209,9 @@ def main() -> None:
164
209
  target = _target(args)
165
210
  if not _is_plan_file(target):
166
211
  emit(True, "not a plan artifact")
212
+ # Only track what `uap plan validate` can actually validate.
213
+ if not _inside_project(target):
214
+ emit(True, "plan artifact outside the project — not tracked")
167
215
  state = _load_state()
168
216
  pending = state.get("pending") or {}
169
217
  pending[_key(target)] = int(time.time())
@@ -181,7 +229,20 @@ def main() -> None:
181
229
  emit(True, "not a build/execute/deploy command")
182
230
 
183
231
  state = _load_state()
184
- pending = list((state.get("pending") or {}).keys())
232
+ # Drop legacy entries `uap plan validate` can never clear, recording what was
233
+ # dropped so a shrinking blocking set is auditable.
234
+ pending_map = state.get("pending") or {}
235
+ kept, dropped = _prune_unvalidatable(pending_map)
236
+ if dropped:
237
+ state["pending"] = kept
238
+ cleared = list(state.get("cleared") or [])
239
+ cleared.extend(
240
+ {"key": k, "reason": "outside the project directory", "at": int(time.time())}
241
+ for k in dropped
242
+ )
243
+ state["cleared"] = cleared[-50:]
244
+ _save_state(state)
245
+ pending = list(kept.keys())
185
246
  if pending:
186
247
  _refuse(pending, "these plans were created or modified and never validated")
187
248
 
@@ -59,10 +59,14 @@ State in `.uap/plan_state.json` (honours `UAP_STATE_DIR`), shared with
59
59
  ```json
60
60
  {
61
61
  "pending": { "<repo-relative path>": "<epoch seen>" },
62
- "validated": { "<repo-relative path>": "<sha256 of the reviewed bytes>" }
62
+ "validated": { "<repo-relative path>": "<sha256 of the reviewed bytes>" },
63
+ "cleared": [ { "key": "<path>", "reason": "<why unreachable>", "at": "<epoch>" } ]
63
64
  }
64
65
  ```
65
66
 
67
+ `cleared` is the audit trail of pending entries dropped as UNREACHABLE. The
68
+ blocking set only shrinks through validation or through a recorded drop.
69
+
66
70
  Gated commands: `uap deliver`, `npm run build`, `npm start`, `yarn`/`pnpm build`,
67
71
  `make`, `cargo build|run`, `go build|run`, `mvn package|install`,
68
72
  `gradle build`, `docker build`, `docker compose up`, `terraform apply`,
@@ -75,4 +79,12 @@ Everything not listed is allowed; there is no second allowlist to keep in sync.
75
79
  Escape hatch, justify in the plan/PR: `UAP_PLAN_VALIDATE_OFF=1`.
76
80
 
77
81
  `uap plan status` reports exactly what the gate is waiting on (pending plans and
78
- plans that have drifted since validation).
82
+ plans that have drifted since validation), listing separately any entry that is
83
+ UNREACHABLE — one validation can never clear, because the file is outside the
84
+ project, deleted, or unreadable.
85
+
86
+ `uap plan clear` drops those unreachable entries and records them under
87
+ `cleared`. It REFUSES a plan that is present and reviewable, pointing back at
88
+ `uap plan validate`: it is a recovery hatch for a wedged gate, not a way to skip
89
+ review. Without it the only exit from a wedge was editing the state file by
90
+ hand.
@@ -393,12 +393,55 @@ def _error_signature(text: str) -> str:
393
393
  return ""
394
394
  if _NEGATED_FAILURE_RE.search(line):
395
395
  return ""
396
+ return _normalize_error_line(line)
397
+
398
+
399
+ def _normalize_error_line(line: str) -> str:
400
+ """Edit-invariant form of one error line: paths, hex and digits collapsed."""
396
401
  line = re.sub(r"(/[^\s:]+)+", "<path>", line) # unix paths
397
402
  line = re.sub(r"\b[0-9a-fA-F]{6,}\b", "<hex>", line) # hashes/addresses
398
403
  line = re.sub(r"\d+", "#", line) # line numbers, counts
399
404
  line = re.sub(r"\s+", " ", line).strip().lower()
400
405
  return line[:200]
401
406
 
407
+
408
+ def _error_signature_for_result(text: str, result_error: bool | None = None) -> str:
409
+ """Signature for a tool_result, honouring the protocol's own `is_error`.
410
+
411
+ Keyword sniffing cannot tell a program REPORTING a failure from a file that
412
+ merely CONTAINS the word. Observed live (2026-07-31): the model read a Python
413
+ source file, `_ERROR_LINE_RE` matched the line
414
+
415
+ except Exception: # noqa: BLE001 - unreadable state must not break ...
416
+
417
+ and three reads of the same file produced three identical "failures" — so
418
+ ERROR-LOOP fired and told the model to re-read the file it had just read.
419
+ Reading a file is the single most common thing an agent does; any file whose
420
+ text contains `Exception`, `error`, or `not found` could manufacture a streak.
421
+
422
+ The transcript already carries the answer. A tool_result has an `is_error`
423
+ flag, and the DOUBLING-DOWN guard has always preferred it over the keyword
424
+ heuristics; ERROR-LOOP computed its signature from the text and threw the
425
+ flag away. Now:
426
+
427
+ is_error False -> a clean result. No signature, whatever the bytes say.
428
+ is_error True -> a real failure. Signature from the text, and if none of
429
+ the keywords match, from its first meaningful line, so
430
+ an unfamiliar error shape still forms a streak.
431
+ absent -> fall back to the keyword heuristics as before.
432
+ """
433
+ if result_error is False:
434
+ return ""
435
+ sig = _error_signature(text)
436
+ if sig or result_error is not True:
437
+ return sig
438
+ # Declared an error, but shaped like nothing we recognise. Anchor the streak
439
+ # on the first meaningful line rather than losing the failure entirely.
440
+ for raw in (text or "").splitlines():
441
+ if raw.strip():
442
+ return _normalize_error_line(raw)
443
+ return ""
444
+
402
445
  # ---------------------------------------------------------------------------
403
446
  # DEFERRAL-BREAK guardrail (Fix A): a model can end a turn with plain prose that
404
447
  # DEFERS the work instead of doing it -- "I need more exploration cycles to
@@ -1786,15 +1829,23 @@ class SessionMonitor:
1786
1829
  by_tool = self.tool_target_history.setdefault(name, {})
1787
1830
  by_tool[target] = by_tool.get(target, 0) + 1
1788
1831
 
1789
- def note_tool_result_error(self, latest_result_text: str) -> None:
1832
+ def note_tool_result_error(
1833
+ self, latest_result_text: str, result_error: bool | None = None
1834
+ ) -> None:
1790
1835
  """Track a repeated tool_result error signature (ERROR-LOOP guardrail).
1791
1836
 
1792
1837
  Same normalized error as last turn -> increment the streak; a new error
1793
1838
  or a clean (error-free) result -> reset. Only a SUSTAINED same-failure
1794
- streak (despite the model's varied edits) trips the nudge."""
1839
+ streak (despite the model's varied edits) trips the nudge.
1840
+
1841
+ result_error is the tool_result `is_error` flag when the client sent one.
1842
+ It beats the keyword heuristics in both directions — see
1843
+ _error_signature_for_result. The caller has always computed this flag;
1844
+ it just was not passed here, so reading a file containing the word
1845
+ `Exception` three times looked exactly like failing three times."""
1795
1846
  if not PROXY_ERROR_LOOP:
1796
1847
  return
1797
- sig = _error_signature(latest_result_text or "")
1848
+ sig = _error_signature_for_result(latest_result_text or "", result_error)
1798
1849
  if sig and sig == self.last_error_signature:
1799
1850
  self.error_signature_streak += 1
1800
1851
  elif sig:
@@ -6773,7 +6824,7 @@ def _record_last_assistant_tool_calls(
6773
6824
  if _flags:
6774
6825
  _latest_err = any(_flags)
6775
6826
  break
6776
- monitor.note_tool_result_error(_latest_tr)
6827
+ monitor.note_tool_result_error(_latest_tr, _latest_err)
6777
6828
  tool_fingerprints = []
6778
6829
  tool_targets: dict[str, str] = {}
6779
6830
  assistant_had_text = False # Fix B: did the last assistant turn emit prose?
@@ -52,10 +52,12 @@ def _load_signature():
52
52
  start = src.index("_ERROR_LINE_RE = re.compile(")
53
53
  end = src.index("\n# ---", src.index("def _error_signature"))
54
54
  exec(src[start:end], ns) # noqa: S102 - reading our own source, not input
55
- return ns["_error_signature"]
55
+ return ns
56
56
 
57
57
 
58
- _error_signature = _load_signature()
58
+ _NS = _load_signature()
59
+ _error_signature = _NS["_error_signature"]
60
+ _error_signature_for_result = _NS["_error_signature_for_result"]
59
61
 
60
62
 
61
63
  class TestHarnessCorrectivesAreNotFailures(unittest.TestCase):
@@ -164,3 +166,90 @@ class TestDeniedFailuresInPlainProse(unittest.TestCase):
164
166
  "2 tests failed",
165
167
  ):
166
168
  assert _error_signature(line) != "", line
169
+
170
+
171
+ # The source file the model was reading when ERROR-LOOP fired on 2026-07-31.
172
+ # Nothing failed; this is a file's CONTENTS, delivered as a successful Read.
173
+ SOURCE_FILE_READ = '''def _load_state() -> dict:
174
+ try:
175
+ data = json.loads(_state_path().read_text())
176
+ return data if isinstance(data, dict) else {}
177
+ except Exception: # noqa: BLE001 - unreadable state must not break the tool call
178
+ return {}
179
+ '''
180
+
181
+
182
+ class TestIsErrorFlagBeatsKeywords(unittest.TestCase):
183
+ """A file that CONTAINS "Exception" is not a failure.
184
+
185
+ Keyword sniffing cannot tell a program reporting a failure from a file that
186
+ merely mentions one. Reading a file is the most common thing an agent does,
187
+ so any source containing `Exception`, `error` or `not found` could
188
+ manufacture a streak — and did: three reads of one file produced three
189
+ identical signatures, and ERROR-LOOP told the model to re-read the file it
190
+ had just read.
191
+ """
192
+
193
+ def test_a_successful_read_never_produces_a_signature(self):
194
+ self.assertEqual(_error_signature_for_result(SOURCE_FILE_READ, False), "")
195
+
196
+ def test_three_successful_reads_cannot_arm_the_guard(self):
197
+ # Three was the threshold, and re-reading a file is not a failure streak.
198
+ sigs = {_error_signature_for_result(SOURCE_FILE_READ, False) for _ in range(3)}
199
+ self.assertEqual(sigs, {""})
200
+
201
+ def test_is_error_false_wins_over_a_real_looking_traceback(self):
202
+ # `cat` of a log file full of tracebacks is still a successful read.
203
+ text = "Traceback (most recent call last):\nTypeError: x is not a function"
204
+ self.assertEqual(_error_signature_for_result(text, False), "")
205
+
206
+ def test_is_error_true_still_produces_a_signature(self):
207
+ text = "ERROR: TypeError: x is not a function at /a/b.js:12"
208
+ self.assertNotEqual(_error_signature_for_result(text, True), "")
209
+
210
+ def test_is_error_true_with_an_unfamiliar_shape_still_forms_a_streak(self):
211
+ # The client declared a failure; losing it because no keyword matched
212
+ # would be the opposite mistake — a real repeated failure going untracked.
213
+ text = "the frobnicator declined\nmore detail here"
214
+ sig = _error_signature_for_result(text, True)
215
+ self.assertNotEqual(sig, "")
216
+ self.assertEqual(sig, _error_signature_for_result(text, True)) # stable
217
+
218
+ def test_without_the_flag_the_old_heuristics_still_apply(self):
219
+ # Clients that never send is_error must keep working exactly as before.
220
+ self.assertNotEqual(_error_signature_for_result("ERROR: SyntaxError: bad", None), "")
221
+ self.assertEqual(_error_signature_for_result("all tests passed", None), "")
222
+ self.assertEqual(
223
+ _error_signature_for_result(SOURCE_FILE_READ, None), _error_signature(SOURCE_FILE_READ)
224
+ )
225
+
226
+ def test_an_empty_declared_error_does_not_crash(self):
227
+ self.assertEqual(_error_signature_for_result("", True), "")
228
+ self.assertEqual(_error_signature_for_result(" \n\n ", True), "")
229
+
230
+
231
+ class TestTheFlagIsActuallyWiredThrough(unittest.TestCase):
232
+ """The logic above is worthless if the caller never passes the flag.
233
+
234
+ That was the whole bug: the request handler computed `_latest_err` from the
235
+ tool_result blocks and then called `note_tool_result_error(_latest_tr)`,
236
+ dropping it. Every unit test passed. Reverting the call site to the one-arg
237
+ form — i.e. restoring the live bug exactly — still passes the entire suite,
238
+ which is why this asserts on the source.
239
+ """
240
+
241
+ def test_the_request_handler_passes_the_is_error_flag(self):
242
+ src = PROXY.read_text()
243
+ assert "_latest_err" in src, "the handler no longer computes the is_error flag"
244
+ assert re.search(r"note_tool_result_error\(\s*_latest_tr\s*,\s*_latest_err\s*\)", src), (
245
+ "note_tool_result_error is called without the is_error flag — reading a file that "
246
+ "contains the word 'Exception' will manufacture a failure streak again"
247
+ )
248
+
249
+ def test_the_signature_helper_is_the_one_being_used(self):
250
+ # A refactor that quietly points the monitor back at _error_signature
251
+ # would reinstate the keyword-only behaviour.
252
+ src = PROXY.read_text()
253
+ assert re.search(
254
+ r"sig = _error_signature_for_result\(latest_result_text or \"\", result_error\)", src
255
+ ), "note_tool_result_error no longer routes through _error_signature_for_result"
@@ -0,0 +1,106 @@
1
+ """validate-plan-on-change: only track what `uap plan validate` can validate.
2
+
3
+ The enforcer recorded ANY plan-named file it saw written, including paths
4
+ outside the project. `uap plan validate` refuses those ("explicit plan file must
5
+ live under the project directory"), so the entry could never be cleared: every
6
+ build in the repo blocked, and the remedy the refusal named declined the file.
7
+ Observed live with a memory note at
8
+ ~/.claude/projects/<slug>/memory/plan_gate_before_build.md — not a plan at all,
9
+ matched only because its filename contains "plan".
10
+
11
+ The rename case is the one to guard hardest. `mv PLAN.md PLAN2.md` is not an
12
+ edit op, so no new pending entry is recorded; auto-forgiving the old key on the
13
+ build path would make a rename a silent, unattended gate bypass with the plan
14
+ content fully intact. An earlier draft of this change did exactly that.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import unittest
24
+ from pathlib import Path
25
+
26
+ ENFORCER = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "validate_plan_on_change.py"
27
+
28
+
29
+ def run(cwd: str, op: str, args: dict) -> str:
30
+ proc = subprocess.run(
31
+ [sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
32
+ capture_output=True,
33
+ text=True,
34
+ cwd=cwd,
35
+ )
36
+ return proc.stdout + proc.stderr
37
+
38
+
39
+ def state(cwd: str) -> dict:
40
+ path = Path(cwd) / ".uap" / "plan_state.json"
41
+ return json.loads(path.read_text()) if path.exists() else {}
42
+
43
+
44
+ class TestPlanGateTracksOnlyProjectFiles(unittest.TestCase):
45
+ def setUp(self) -> None:
46
+ self._tmp = tempfile.TemporaryDirectory()
47
+ self.cwd = self._tmp.name
48
+ os.makedirs(os.path.join(self.cwd, "docs", "plans"), exist_ok=True)
49
+
50
+ def tearDown(self) -> None:
51
+ self._tmp.cleanup()
52
+
53
+ def test_outside_project_plan_is_not_tracked(self) -> None:
54
+ out = run(self.cwd, "Write", {"file_path": "/tmp/elsewhere/stray-plan.md"})
55
+ self.assertIn("outside the project", out)
56
+ self.assertEqual(state(self.cwd).get("pending", {}), {})
57
+
58
+ def test_in_project_plan_is_still_tracked(self) -> None:
59
+ Path(self.cwd, "docs", "plans", "real-plan.md").write_text("# real")
60
+ run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
61
+ self.assertIn("docs/plans/real-plan.md", state(self.cwd).get("pending", {}))
62
+
63
+ def test_build_is_blocked_and_names_the_recovery_command(self) -> None:
64
+ Path(self.cwd, "docs", "plans", "real-plan.md").write_text("# real")
65
+ run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
66
+ out = run(self.cwd, "Bash", {"command": "npm run build"})
67
+ self.assertIn("never validated", out)
68
+ # A wedged agent must not be sent to the one command that declines the file.
69
+ self.assertIn("uap plan clear", out)
70
+
71
+ def test_renaming_a_pending_plan_does_not_forgive_it(self) -> None:
72
+ plan = Path(self.cwd, "docs", "plans", "real-plan.md")
73
+ plan.write_text("# real")
74
+ run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
75
+ plan.rename(Path(self.cwd, "docs", "plans", "real-plan-v2.md"))
76
+
77
+ out = run(self.cwd, "Bash", {"command": "npm run build"})
78
+ self.assertIn("never validated", out)
79
+ self.assertIn("docs/plans/real-plan.md", state(self.cwd).get("pending", {}))
80
+
81
+ def test_legacy_outside_entry_is_pruned_and_audited(self) -> None:
82
+ Path(self.cwd, "docs", "plans", "real-plan.md").write_text("# real")
83
+ run(self.cwd, "Write", {"file_path": "docs/plans/real-plan.md"})
84
+ st = state(self.cwd)
85
+ st.setdefault("pending", {})["/home/somewhere/legacy-plan.md"] = 1
86
+ Path(self.cwd, ".uap", "plan_state.json").write_text(json.dumps(st))
87
+
88
+ run(self.cwd, "Bash", {"command": "npm run build"})
89
+ st = state(self.cwd)
90
+ self.assertNotIn("/home/somewhere/legacy-plan.md", st.get("pending", {}))
91
+ # The in-project plan still gates the build.
92
+ self.assertIn("docs/plans/real-plan.md", st.get("pending", {}))
93
+ # A shrinking blocking set must leave a trail.
94
+ cleared = st.get("cleared", [])
95
+ self.assertEqual(len(cleared), 1)
96
+ self.assertEqual(cleared[0]["key"], "/home/somewhere/legacy-plan.md")
97
+ self.assertIn("outside the project", cleared[0]["reason"])
98
+
99
+ def test_non_plan_writes_are_untouched(self) -> None:
100
+ out = run(self.cwd, "Write", {"file_path": "src/index.ts"})
101
+ self.assertIn("not a plan artifact", out)
102
+ self.assertEqual(state(self.cwd).get("pending", {}), {})
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()
@@ -1,154 +0,0 @@
1
- # validate-plan-on-change: stop the gate wedging on paths it cannot validate
2
- #
3
- # The enforcer records ANY plan-named file it sees written, including paths
4
- # OUTSIDE the project. `uap plan validate` refuses those ('explicit plan file
5
- # must live under the project directory'), so such an entry can never be
6
- # cleared: every build in the repo blocks, and the remedy the refusal names
7
- # declines the file. Observed live: a memory note at
8
- # ~/.claude/projects/<slug>/memory/plan_gate_before_build.md — not a plan at
9
- # all, matched only because its filename contains 'plan' — blocked the repo
10
- # until .uap/plan_state.json was edited by hand.
11
- #
12
- # This patch:
13
- # 1. stop TRACKING plan files outside the project (the root cause);
14
- # 2. prune legacy outside-project entries, with an audit record;
15
- # 3. point the refusal at `uap plan clear` when validation cannot help.
16
- #
17
- # It deliberately does NOT auto-prune entries whose file is merely MISSING.
18
- # `mv PLAN.md PLAN2.md` is not an edit op, so no new entry is recorded — and
19
- # forgiving the old key on the build path would turn a rename into a silent,
20
- # unattended gate bypass with the plan content fully intact. A deleted plan is
21
- # dropped only by an operator running `uap plan clear`, which records it.
22
- #
23
- # `src/policies/enforcers/**` is protected by Enforcement Self-Protect, so an
24
- # agent cannot apply this. Operator, from the repo root:
25
- #
26
- # git apply docs/patches/validate-plan-on-change-inside-project.patch
27
- #
28
- # `uap plan clear` (shipped in this PR) recovers an ALREADY-wedged gate without
29
- # this patch; the patch is what stops the wedge recurring.
30
-
31
- --- a/src/policies/enforcers/validate_plan_on_change.py
32
- +++ b/src/policies/enforcers/validate_plan_on_change.py
33
- @@ -119,6 +119,49 @@
34
- return bool(PLAN_STEM_RE.search(name[:-3]))
35
-
36
-
37
- +def _inside_project(target: str) -> bool:
38
- + """True when `target` resolves to the project root or below it.
39
- +
40
- + `uap plan validate` refuses any file outside the project directory, so
41
- + recording one creates a pending entry NOTHING can clear: every build in the
42
- + repo blocks, and the remedy the refusal names declines the file. Observed
43
- + live with a memory note under ~/.claude/, matched only because its filename
44
- + contained "plan".
45
- +
46
- + Fails CLOSED on error (treat as inside, i.e. record it). Failing open here
47
- + would let a transient getcwd() error silently un-gate a plan write.
48
- + """
49
- + try:
50
- + root = os.path.realpath(os.getcwd())
51
- + abs_target = os.path.realpath(os.path.join(root, target))
52
- + return abs_target == root or abs_target.startswith(root + os.sep)
53
- + except Exception: # noqa: BLE001
54
- + return True
55
- +
56
- +
57
- +def _prune_unvalidatable(pending: dict) -> tuple[dict, list]:
58
- + """Split pending into (kept, dropped) — OUTSIDE-PROJECT entries only.
59
- +
60
- + Those can never be cleared: `uap plan validate` refuses them by design, so
61
- + they block every build with a remedy that declines the file. This only
62
- + matters for state recorded before the tracking fix above.
63
- +
64
- + A MISSING file is deliberately NOT pruned here. `mv PLAN.md PLAN2.md` is not
65
- + an edit op, so no new entry is recorded; auto-forgiving the old key on the
66
- + build path would make a rename a silent, unattended gate bypass with the
67
- + plan content fully intact. A deleted plan is dropped only by an operator
68
- + running `uap plan clear`, which records what it dropped and why.
69
- + """
70
- + kept: dict = {}
71
- + dropped: list = []
72
- + for key, seen in pending.items():
73
- + if not _inside_project(key):
74
- + dropped.append(key)
75
- + else:
76
- + kept[key] = seen
77
- + return kept, dropped
78
- +
79
- +
80
- def _key(target: str) -> str:
81
- """Repo-relative, forward-slashed — the shape `uap plan validate` records."""
82
- posix = target.replace(os.sep, "/")
83
- @@ -148,6 +191,8 @@
84
- "risks, and whether it still matches the request.\n"
85
- f" 2. Record it: `uap plan validate {sorted(paths)[0]}`.\n"
86
- " 3. Retry this command.\n"
87
- + "(If validation refuses the file — outside the project, or deleted — it can "
88
- + "never clear: `uap plan status` names it and `uap plan clear` drops it.)\n"
89
- "(Escape hatch, justify in the plan/PR: UAP_PLAN_VALIDATE_OFF=1.)",
90
- inject_prompt="validate the plan",
91
- )
92
- @@ -164,6 +209,9 @@
93
- target = _target(args)
94
- if not _is_plan_file(target):
95
- emit(True, "not a plan artifact")
96
- + # Only track what `uap plan validate` can actually validate.
97
- + if not _inside_project(target):
98
- + emit(True, "plan artifact outside the project — not tracked")
99
- state = _load_state()
100
- pending = state.get("pending") or {}
101
- pending[_key(target)] = int(time.time())
102
- @@ -181,7 +229,20 @@
103
- emit(True, "not a build/execute/deploy command")
104
-
105
- state = _load_state()
106
- - pending = list((state.get("pending") or {}).keys())
107
- + # Drop legacy entries `uap plan validate` can never clear, recording what was
108
- + # dropped so a shrinking blocking set is auditable.
109
- + pending_map = state.get("pending") or {}
110
- + kept, dropped = _prune_unvalidatable(pending_map)
111
- + if dropped:
112
- + state["pending"] = kept
113
- + cleared = list(state.get("cleared") or [])
114
- + cleared.extend(
115
- + {"key": k, "reason": "outside the project directory", "at": int(time.time())}
116
- + for k in dropped
117
- + )
118
- + state["cleared"] = cleared[-50:]
119
- + _save_state(state)
120
- + pending = list(kept.keys())
121
- if pending:
122
- _refuse(pending, "these plans were created or modified and never validated")
123
-
124
- --- a/src/policies/schemas/policies/validate-plan-on-change.md
125
- +++ b/src/policies/schemas/policies/validate-plan-on-change.md
126
- @@ -59,9 +59,13 @@
127
- ```json
128
- {
129
- "pending": { "<repo-relative path>": "<epoch seen>" },
130
- - "validated": { "<repo-relative path>": "<sha256 of the reviewed bytes>" }
131
- + "validated": { "<repo-relative path>": "<sha256 of the reviewed bytes>" },
132
- + "cleared": [ { "key": "<path>", "reason": "<why unreachable>", "at": "<epoch>" } ]
133
- }
134
- ```
135
- +
136
- +`cleared` is the audit trail of pending entries dropped as UNREACHABLE. The
137
- +blocking set only shrinks through validation or through a recorded drop.
138
-
139
- Gated commands: `uap deliver`, `npm run build`, `npm start`, `yarn`/`pnpm build`,
140
- `make`, `cargo build|run`, `go build|run`, `mvn package|install`,
141
- @@ -75,4 +79,12 @@
142
- Escape hatch, justify in the plan/PR: `UAP_PLAN_VALIDATE_OFF=1`.
143
-
144
- `uap plan status` reports exactly what the gate is waiting on (pending plans and
145
- -plans that have drifted since validation).
146
- +plans that have drifted since validation), listing separately any entry that is
147
- +UNREACHABLE — one validation can never clear, because the file is outside the
148
- +project, deleted, or unreadable.
149
- +
150
- +`uap plan clear` drops those unreachable entries and records them under
151
- +`cleared`. It REFUSES a plan that is present and reviewable, pointing back at
152
- +`uap plan validate`: it is a recovery hatch for a wedged gate, not a way to skip
153
- +review. Without it the only exit from a wedge was editing the state file by
154
- +hand.