@miller-tech/uap 1.179.0 → 1.179.3
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 +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/delivery_enforcement.py +63 -0
- package/src/policies/enforcers/enforcement_infra_protect.py +268 -7
- package/src/policies/enforcers/validate_plan_on_change.py +62 -1
- package/src/policies/schemas/policies/delivery-enforcement.md +19 -1
- package/src/policies/schemas/policies/enforcement-infra-protect.md +47 -3
- package/src/policies/schemas/policies/validate-plan-on-change.md +14 -2
- 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/scripts/anthropic_proxy.py +55 -4
- package/tools/agents/tests/test_error_loop_ignores_correctives.py +91 -2
- package/tools/agents/tests/test_validate_plan_inside_project.py +106 -0
- package/docs/patches/validate-plan-on-change-inside-project.patch +0 -154
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.179.
|
|
3
|
+
"version": "1.179.3",
|
|
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
|
|
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",
|
|
Binary file
|
|
@@ -211,6 +211,31 @@ def _local_mode() -> str:
|
|
|
211
211
|
|
|
212
212
|
BASH_OPS = {"Bash", "bash", "run_bash", "shell"}
|
|
213
213
|
|
|
214
|
+
# The delivery gate's own state. The pending log is the replay queue for
|
|
215
|
+
# `uap deliver --pending`; the lock and heartbeat are how an in-flight run is
|
|
216
|
+
# found, followed, and reclaimed when wedged.
|
|
217
|
+
#
|
|
218
|
+
# Guarding only `rm <literal path>` would repeat the mistake this whole change
|
|
219
|
+
# exists to fix. `: > .uap/pending-deliver.jsonl` is SHORTER than the command it
|
|
220
|
+
# blocks and just as destructive, and `rm -rf .uap` takes all three at once. So
|
|
221
|
+
# match any destructive verb (or a truncating redirect) against any path under
|
|
222
|
+
# `.uap/` that names deliver state — including the directory itself and globs.
|
|
223
|
+
_DELIVER_STATE_PATH = (
|
|
224
|
+
r"(?:pending-deliver\.jsonl|deliver\.lock|deliver\.heartbeat"
|
|
225
|
+
r"|pending-[^\s'\"|;&]*|deliver\.[^\s'\"|;&]*|\*[^\s'\"|;&]*)"
|
|
226
|
+
)
|
|
227
|
+
_DELIVER_STATE_RM_RE = re.compile(
|
|
228
|
+
r"(?:\b(?:rm|unlink|shred|truncate|mv)\b"
|
|
229
|
+
r"|\bfind\b[^|;&\n]*-(?:delete|exec\s+rm)"
|
|
230
|
+
r"|\bgit\s+clean\b"
|
|
231
|
+
r"|(?<![0-9<>])>(?!>))"
|
|
232
|
+
r"[^|;&\n]*?"
|
|
233
|
+
# The inner path is optional INSIDE the slash group so a bare `.uap/`
|
|
234
|
+
# (trailing slash, nothing after) still matches — `rm -rf .uap/` destroys
|
|
235
|
+
# exactly as much as `rm -rf .uap`.
|
|
236
|
+
r"((?:[^\s'\"|;&]*/)?\.uap(?:/(?:" + _DELIVER_STATE_PATH + r")?)?)(?:\s|$|['\"])"
|
|
237
|
+
)
|
|
238
|
+
|
|
214
239
|
# A bash command that WRITES a source file: `> f.ts`, `>> f.ts`, `tee f.ts`,
|
|
215
240
|
# `sed -i ... f.ts`. Without this, Edit/Write gating is trivially bypassable —
|
|
216
241
|
# `cat > app.js <<EOF` writes source with no deliver run and no validation.
|
|
@@ -375,6 +400,44 @@ def _handle_bash(args: dict) -> None:
|
|
|
375
400
|
if os.environ.get("UAP_DELIVER_BYPASS") == "1":
|
|
376
401
|
emit(True, "bash: UAP_DELIVER_BYPASS override set")
|
|
377
402
|
return
|
|
403
|
+
# Destroying the gate's own state is not a way out of the gate. Observed
|
|
404
|
+
# live 7x on 2026-07-31 (octopus_invaders_v3), interleaved with kill -9 of
|
|
405
|
+
# the running deliver: the queued edit intents were discarded to escape a
|
|
406
|
+
# block rather than completing the work.
|
|
407
|
+
#
|
|
408
|
+
# deliver's own housekeeping is unaffected twice over: it rewrites the
|
|
409
|
+
# pending log in-process (delivery/pending-intents.ts) rather than shelling
|
|
410
|
+
# out, and its subprocesses carry UAP_DELIVER_ACTIVE=1, which returned
|
|
411
|
+
# above. UAP_DELIVER_BYPASS=1 also returned above — operator-set only, since
|
|
412
|
+
# enforcement_self_protect refuses the inline form.
|
|
413
|
+
#
|
|
414
|
+
# This block is deliberately NOT relaxed by UAP_ENFORCE_DELIVERY=advisory:
|
|
415
|
+
# advisory trades verification for speed on an EDIT, but destroying recorded
|
|
416
|
+
# state is not an edit and has no verified-later equivalent.
|
|
417
|
+
sm = _DELIVER_STATE_RM_RE.search(cmd)
|
|
418
|
+
if sm:
|
|
419
|
+
target = sm.group(1)
|
|
420
|
+
if "pending-" in target:
|
|
421
|
+
what = (
|
|
422
|
+
"that is the queue of edit intents deliver replays; removing it "
|
|
423
|
+
"discards recorded work rather than completing it. To apply what "
|
|
424
|
+
"is already queued, run `uap deliver --pending`"
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
what = (
|
|
428
|
+
"that is the single-flight lock/heartbeat; deleting it starts a "
|
|
429
|
+
"SECOND concurrent run on the same tree. A stale lock is "
|
|
430
|
+
"reclaimed automatically by heartbeat age, so it never needs "
|
|
431
|
+
"deleting"
|
|
432
|
+
)
|
|
433
|
+
emit(
|
|
434
|
+
False,
|
|
435
|
+
f"BLOCKED: do not destroy the delivery gate's own state ('{target}') — "
|
|
436
|
+
f"{what}. If a deliver run is in flight, wait for it (deliver tool "
|
|
437
|
+
"with follow:true, or `uap deliver --await-run`).",
|
|
438
|
+
)
|
|
439
|
+
return
|
|
440
|
+
|
|
378
441
|
m = _BASH_WRITE_RE.search(cmd)
|
|
379
442
|
if m:
|
|
380
443
|
target = m.group(1)
|
|
@@ -26,13 +26,34 @@ Scope (Bash/bash/run_bash commands only):
|
|
|
26
26
|
- broad `-f`/`--full` kill whose pattern is a substring of the stack's argv
|
|
27
27
|
(uap/llama/qwen/mmproj/nomic/anthropic) or a glob over the python
|
|
28
28
|
interpreter that runs the proxy.
|
|
29
|
+
- a kill held APART from its target — by a pipe (`ps aux | grep llama-server
|
|
30
|
+
| xargs kill -9`), by an infra-port lookup feeding it (`lsof -t -i:4000 |
|
|
31
|
+
xargs kill -9`), by a variable (`X=$(pgrep -f llama-server); kill -9 $X`),
|
|
32
|
+
or by a `-f` pattern that is an infra port (`pkill -f 8080`) — rule 8.
|
|
33
|
+
Matching is on TEXT, so a token counts wherever it appears; quoted data and
|
|
34
|
+
heredoc bodies are stripped first so prose about a kill is not a kill.
|
|
35
|
+
- a kill whose bare PID resolves to a deliver run or the inference stack
|
|
36
|
+
(`kill -9 3936358`, `kill -9 -3936358`) — rule 9. Resolved from
|
|
37
|
+
.uap/deliver.lock and /proc argv, not from the command text, and only for
|
|
38
|
+
the numbers the command actually names.
|
|
29
39
|
- systemctl stop/restart/kill/disable of the inference services.
|
|
30
40
|
- Starting a server that BINDS an infra port (http.server 8080 etc.) —
|
|
31
41
|
this is how the port got stolen even with kills blocked (the model
|
|
32
42
|
bound the port inside llama-server's crash/restart window).
|
|
33
43
|
|
|
34
44
|
Killing a SPECIFIC process pattern (e.g. `pkill -f "python3 -m http.server
|
|
35
|
-
8765"`) and serving on non-infra ports stay allowed
|
|
45
|
+
8765"`) and serving on non-infra ports stay allowed — unless the pattern itself
|
|
46
|
+
carries a stack token, so `pkill -f 'uap deliver'` is refused; wait for the run
|
|
47
|
+
instead.
|
|
48
|
+
|
|
49
|
+
KNOWN LIMITS, so nobody reads this as airtight. It is a TEXT gate on a language
|
|
50
|
+
with unbounded ways to say the same thing:
|
|
51
|
+
- obfuscated patterns (`grep '[l]lama-server'`, `pkill -f 'llam[a]-server'`)
|
|
52
|
+
match the real process but not these rules;
|
|
53
|
+
- anything routed through `eval`/base64 defeats every text rule by design;
|
|
54
|
+
- the script-body scan is depth-1 and shell-only (`.sh/.bash/.zsh`).
|
|
55
|
+
Rule 9 is the part that cannot be spelled around, because it resolves PIDs
|
|
56
|
+
rather than reading them. Treat 1-8 as raising the cost, not as a boundary.
|
|
36
57
|
"""
|
|
37
58
|
import os
|
|
38
59
|
import re
|
|
@@ -40,7 +61,7 @@ import sys
|
|
|
40
61
|
from pathlib import Path
|
|
41
62
|
|
|
42
63
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
43
|
-
from _common import emit, parse_cli # noqa: E402
|
|
64
|
+
from _common import emit, parse_cli, repo_root, scannable_command # noqa: E402
|
|
44
65
|
|
|
45
66
|
BASH_OPS = {"Bash", "bash", "run_bash", "shell", "execute_command"}
|
|
46
67
|
|
|
@@ -90,6 +111,233 @@ RULES = (
|
|
|
90
111
|
),
|
|
91
112
|
)
|
|
92
113
|
|
|
114
|
+
# --------------------------------------------------------------------------
|
|
115
|
+
# 8) LAUNDERED kills — the verb and its target pulled apart.
|
|
116
|
+
#
|
|
117
|
+
# Rules 1-7 each scan a single shell SEGMENT (`[^|;&\n]*`), so moving the PID
|
|
118
|
+
# list one hop away splits the kill verb from the thing being killed and the
|
|
119
|
+
# whole rule set goes blind. Every one of these reached the stack:
|
|
120
|
+
# ps aux | grep llama-server | awk '{print $2}' | xargs kill -9 (pipe)
|
|
121
|
+
# lsof -t -i:4000 | xargs kill -9 (pipe)
|
|
122
|
+
# PIDS=$(pgrep -f llama-server); kill -9 $PIDS (variable)
|
|
123
|
+
# for p in $(pgrep -f llama-server); do kill -9 $p; done (loop)
|
|
124
|
+
#
|
|
125
|
+
# Observed live (2026-07-31, octopus_invaders_v3, qwen on opencode): the model's
|
|
126
|
+
# `pkill -f "uap deliver"` was refused at 11:13:46Z and EIGHT SECONDS LATER it
|
|
127
|
+
# ran the xargs form and reported "cleaned"; it repeated that at 11:41:53Z. The
|
|
128
|
+
# evasion is found without being sought, so closing only the observed spelling
|
|
129
|
+
# is not closing anything — the `;` twin is the same thought one keystroke away.
|
|
130
|
+
#
|
|
131
|
+
# Three passes, all on ONE logical command:
|
|
132
|
+
# a) statement-local: a kill verb plus a stack token / infra-port lookup.
|
|
133
|
+
# b) cross-statement taint: a LOOKUP that names the stack (pgrep/ps/lsof/...)
|
|
134
|
+
# anywhere, plus a kill verb anywhere. This is what catches the `;` and
|
|
135
|
+
# loop forms, where neither half is damning alone.
|
|
136
|
+
# c) a `-f` kill whose pattern IS an infra port (`pkill -f 8080`), which names
|
|
137
|
+
# no token yet matches llama-server's argv.
|
|
138
|
+
# Statements split on `;`, `&&`, `||`, newline — never on `|`, since a pipeline
|
|
139
|
+
# is one command.
|
|
140
|
+
#
|
|
141
|
+
# Quoted DATA is stripped first via scannable_command(), which removes heredoc
|
|
142
|
+
# bodies and multi-word quoted blobs but keeps single-word quoted arguments, and
|
|
143
|
+
# strips nothing when the command hands text to a shell (`bash <<EOF`, `sh -c`,
|
|
144
|
+
# `eval`, `xargs`). That kills the false-positive class — a commit message or an
|
|
145
|
+
# `echo` describing a kill — WITHOUT opening a heredoc as an evasion. Rules 1-7
|
|
146
|
+
# still read the raw text, so `pkill -f "uap deliver"` stays caught by rule 6.
|
|
147
|
+
_STATEMENT_SPLIT_RE = re.compile(r";|&&|\|\||\n")
|
|
148
|
+
# A kill in COMMAND POSITION, not the word "kill" inside prose or a grep pattern.
|
|
149
|
+
# The `xargs` branch is what keeps the pipe form matching.
|
|
150
|
+
_KILL_VERB_RE = re.compile(
|
|
151
|
+
r"(?:^|[|;&(`]|\|\||&&|\bxargs\b[^|;&\n]*|\bsudo\b\s+|\bexec\b\s+"
|
|
152
|
+
r"|\bthen\b\s+|\bdo\b\s+|\$\()"
|
|
153
|
+
r"\s*(?:\w+=\S*\s+)*(?:[\w./~-]*/)?(?:p?kill(?:all)?[0-9]*|skill)\b"
|
|
154
|
+
)
|
|
155
|
+
_LOOKUP_VERB_RE = re.compile(r"\b(?:pgrep|pidof|ps|lsof|fuser|ss|netstat)\b")
|
|
156
|
+
_INFRA_TOKEN_RE = re.compile(
|
|
157
|
+
r"\b(uap|llama|llama-server|anthropic|anthropic_proxy|nomic|mmproj"
|
|
158
|
+
r"|qwen[0-9.]*|llama-slots|slots?[_-]?save|deliver)\b",
|
|
159
|
+
re.IGNORECASE,
|
|
160
|
+
)
|
|
161
|
+
_PORT_LOOKUP_RE = re.compile(
|
|
162
|
+
r"\b(lsof|fuser|ss|netstat)\b[^\n]*?[:\s=]" + INFRA_PORTS + r"\b"
|
|
163
|
+
)
|
|
164
|
+
# `pkill -f 8080` — the pattern is the port itself; no token, still fatal.
|
|
165
|
+
_KILL_PATTERN_PORT_RE = re.compile(
|
|
166
|
+
r"\b(?:p?kill(?:all)?|skill)\b[^|;&\n]*"
|
|
167
|
+
r"(?:\s-(?:-full|[A-Za-z0-9]*f)\b)[^|;&\n]*\b" + INFRA_PORTS + r"\b"
|
|
168
|
+
)
|
|
169
|
+
# Simple `VAR=value` / `VAR="value"` bindings, substituted before splitting so
|
|
170
|
+
# `X=llama-server; pkill -f "$X"` collapses to the form rule 6 already refuses.
|
|
171
|
+
# Heuristic by design: no scoping, no command-substitution values. It can only
|
|
172
|
+
# ADD matches, never remove one, so it cannot introduce a miss.
|
|
173
|
+
_ASSIGN_RE = re.compile(
|
|
174
|
+
r"\b([A-Za-z_]\w*)=(?:\"([^\"]*)\"|'([^']*)'|([^\s;&|]+))"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _expand_assignments(cmd: str) -> str:
|
|
179
|
+
bindings = {
|
|
180
|
+
m.group(1): (m.group(2) or m.group(3) or m.group(4) or "")
|
|
181
|
+
for m in _ASSIGN_RE.finditer(cmd)
|
|
182
|
+
}
|
|
183
|
+
for name, value in bindings.items():
|
|
184
|
+
if value:
|
|
185
|
+
cmd = re.sub(r"\$\{?" + re.escape(name) + r"\}?", value, cmd)
|
|
186
|
+
return cmd
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _laundered_infra_kill(cmd: str) -> bool:
|
|
190
|
+
"""True when a command kills the stack with the verb held apart from it.
|
|
191
|
+
|
|
192
|
+
The two halves are read from DIFFERENT views of the same command, because
|
|
193
|
+
they fail in opposite directions:
|
|
194
|
+
|
|
195
|
+
* the kill VERB is read from scannable_command() — quoted data removed —
|
|
196
|
+
so `-m "…laundered kill of llama-server"` is not mistaken for a kill;
|
|
197
|
+
* the stack TOKEN is read from the RAW text, because the token routinely
|
|
198
|
+
lives inside the quotes that identify the victim
|
|
199
|
+
(`pgrep -f "uap deliver" | while read p; do kill -9 $p; done`). Reading
|
|
200
|
+
tokens from the blanked view lost exactly that form.
|
|
201
|
+
|
|
202
|
+
Statement counts are compared before pairing the two views; if blanking
|
|
203
|
+
changed the shape (a heredoc removed lines), fall back to the raw text for
|
|
204
|
+
both, which is the conservative direction.
|
|
205
|
+
"""
|
|
206
|
+
raw = _expand_assignments(cmd)
|
|
207
|
+
scannable = _expand_assignments(scannable_command(cmd))
|
|
208
|
+
raw_stmts = _STATEMENT_SPLIT_RE.split(raw)
|
|
209
|
+
kill_stmts = _STATEMENT_SPLIT_RE.split(scannable)
|
|
210
|
+
if len(kill_stmts) != len(raw_stmts):
|
|
211
|
+
kill_stmts = raw_stmts
|
|
212
|
+
|
|
213
|
+
kills = [bool(_KILL_VERB_RE.search(s)) for s in kill_stmts]
|
|
214
|
+
for is_kill, stmt in zip(kills, raw_stmts):
|
|
215
|
+
if is_kill and (_INFRA_TOKEN_RE.search(stmt) or _PORT_LOOKUP_RE.search(stmt)):
|
|
216
|
+
return True
|
|
217
|
+
if _KILL_PATTERN_PORT_RE.search(raw):
|
|
218
|
+
return True
|
|
219
|
+
# Cross-statement: a lookup that NAMES the stack, and a kill anywhere.
|
|
220
|
+
if any(kills) and any(
|
|
221
|
+
_LOOKUP_VERB_RE.search(s)
|
|
222
|
+
and (_INFRA_TOKEN_RE.search(s) or _PORT_LOOKUP_RE.search(s))
|
|
223
|
+
for s in raw_stmts
|
|
224
|
+
):
|
|
225
|
+
return True
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --------------------------------------------------------------------------
|
|
230
|
+
# 9) Kill by BARE PID.
|
|
231
|
+
#
|
|
232
|
+
# `kill -9 3936358` names nothing, so no text rule can see what it hits. The
|
|
233
|
+
# model killed its own in-flight deliver this way six times in one hour on
|
|
234
|
+
# 2026-07-31, discarding each run's completed work — the direct cause of ~50
|
|
235
|
+
# minutes spent in a ps/sleep/kill loop making no progress.
|
|
236
|
+
#
|
|
237
|
+
# Resolution is SEMANTIC, not textual, so it cannot be spelled around: the
|
|
238
|
+
# numbers the command names are looked up in /proc and compared against the
|
|
239
|
+
# deliver lock and the stack's argv. Only the PIDs the command actually names
|
|
240
|
+
# are resolved (never a full /proc walk), which is both cheaper and narrower.
|
|
241
|
+
#
|
|
242
|
+
# A dead PID is never protected, and a lock PID must ALSO still look like a
|
|
243
|
+
# deliver run: a crashed run leaves a stale lock, and without the identity check
|
|
244
|
+
# whatever process later recycles that number becomes unkillable while the
|
|
245
|
+
# refusal says "wait for the deliver run" — recreating the exact stall this rule
|
|
246
|
+
# exists to prevent. delivery_enforcement._deliver_lock_holder() has taken the
|
|
247
|
+
# same precaution since the PID-reuse incident; this must not diverge from it.
|
|
248
|
+
_PID_TOKEN_RE = re.compile(r"(?<![\w.])(-?\d{1,10})(?![\w.-])")
|
|
249
|
+
_STACK_ARGV_RE = re.compile(
|
|
250
|
+
r"(llama-server|anthropic_proxy|nomic-embed"
|
|
251
|
+
r"|\buap\s+deliver\b|(?:cli\.js|uap)\s+(?:\S+\s+)*deliver\b)",
|
|
252
|
+
re.IGNORECASE,
|
|
253
|
+
)
|
|
254
|
+
# A pathological command full of integers must not become a syscall storm.
|
|
255
|
+
_MAX_PID_CANDIDATES = 32
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _lock_holder_pids() -> dict[str, str]:
|
|
259
|
+
"""PIDs claimed by a deliver lock, from the main root AND any worktree.
|
|
260
|
+
|
|
261
|
+
deliver writes its lock under the root it was LAUNCHED from, which under
|
|
262
|
+
this repo's mandated worktree workflow is often `.worktrees/NNN-*/`, while
|
|
263
|
+
the gate resolves repo_root() to the main checkout. Reading only the main
|
|
264
|
+
root would leave rule 9's headline case unprotected in the normal workflow.
|
|
265
|
+
"""
|
|
266
|
+
holders: dict[str, str] = {}
|
|
267
|
+
roots = [repo_root()]
|
|
268
|
+
try:
|
|
269
|
+
roots.extend(sorted((repo_root() / ".worktrees").glob("*")))
|
|
270
|
+
except Exception: # noqa: BLE001 - no worktrees dir: main root only
|
|
271
|
+
pass
|
|
272
|
+
for root in roots:
|
|
273
|
+
try:
|
|
274
|
+
text = (root / ".uap" / "deliver.lock").read_text(errors="replace")
|
|
275
|
+
except OSError:
|
|
276
|
+
continue
|
|
277
|
+
m = re.match(r"\s*(\d+)", text)
|
|
278
|
+
if m:
|
|
279
|
+
holders[str(int(m.group(1)))] = "the deliver run in progress"
|
|
280
|
+
return holders
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _identify_pid(pid: str, holders: dict[str, str]) -> str | None:
|
|
284
|
+
"""What `pid` actually IS right now, or None if it is nothing to protect."""
|
|
285
|
+
try:
|
|
286
|
+
argv = (
|
|
287
|
+
Path(f"/proc/{pid}/cmdline")
|
|
288
|
+
.read_bytes()
|
|
289
|
+
.replace(b"\0", b" ")
|
|
290
|
+
.decode(errors="replace")
|
|
291
|
+
)
|
|
292
|
+
except OSError:
|
|
293
|
+
return None # dead: a stale lock protects nothing
|
|
294
|
+
m = _STACK_ARGV_RE.search(argv)
|
|
295
|
+
if pid in holders:
|
|
296
|
+
# Confirm identity too — a recycled PID must not inherit the claim.
|
|
297
|
+
return holders[pid] if m else None
|
|
298
|
+
return m.group(1).lower() if m else None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _protected_pid_hit(text: str) -> tuple[str, str] | None:
|
|
302
|
+
if not (_KILL_VERB_RE.search(text) and _PID_TOKEN_RE.search(text)):
|
|
303
|
+
return None
|
|
304
|
+
holders = _lock_holder_pids()
|
|
305
|
+
seen: set[str] = set()
|
|
306
|
+
for m in _PID_TOKEN_RE.finditer(text):
|
|
307
|
+
# `kill -9 -3936358` kills the process GROUP — strictly more
|
|
308
|
+
# destructive, and invisible if the sign is treated as part of the token.
|
|
309
|
+
pid = str(abs(int(m.group(1))))
|
|
310
|
+
if pid in seen:
|
|
311
|
+
continue
|
|
312
|
+
seen.add(pid)
|
|
313
|
+
if len(seen) > _MAX_PID_CANDIDATES:
|
|
314
|
+
break
|
|
315
|
+
what = _identify_pid(pid, holders)
|
|
316
|
+
if what:
|
|
317
|
+
return pid, what
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
DELIVER_PID_REASON = (
|
|
322
|
+
"infra-protect: this command kills the deliver run in progress (pid {pid}). "
|
|
323
|
+
"A deliver run that is still working is NOT stuck — killing it discards the "
|
|
324
|
+
"work it has already done and starts the cycle over. Wait for it instead: "
|
|
325
|
+
"call the deliver tool with follow:true, which answers within about a "
|
|
326
|
+
"minute; a 'STILL RUNNING' answer is normal and means keep polling, not "
|
|
327
|
+
"fail. From a shell, `uap deliver --await-run` blocks until the run ends. "
|
|
328
|
+
"Operator override: set UAP_INFRA_PROTECT_OFF=1 in the launch environment "
|
|
329
|
+
"(not inline on the command)."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
STACK_PID_REASON = (
|
|
333
|
+
"infra-protect: this command kills {what} (pid {pid}) — the inference stack "
|
|
334
|
+
"answering this session's own requests (llama-server :8080 / UAP proxy "
|
|
335
|
+
":4000 / embeddings :8081). Killing it ends your own session; it is not a "
|
|
336
|
+
"way to fix a slow response. If the stack genuinely needs restarting, ask "
|
|
337
|
+
"the operator. Operator override: set UAP_INFRA_PROTECT_OFF=1 in the launch "
|
|
338
|
+
"environment (not inline on the command)."
|
|
339
|
+
)
|
|
340
|
+
|
|
93
341
|
REASON = (
|
|
94
342
|
"infra-protect: this command would kill or displace the inference stack this "
|
|
95
343
|
"session runs on (llama-server :8080 / UAP proxy :4000 / embeddings :8081). "
|
|
@@ -175,11 +423,24 @@ def main() -> None:
|
|
|
175
423
|
for rule in RULES:
|
|
176
424
|
if rule.search(cmd):
|
|
177
425
|
emit(False, REASON)
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
426
|
+
# Rules 8 and 9 apply to the command AND to any script it would run — the
|
|
427
|
+
# script-body pass exists because moving a stack-restart one level down into
|
|
428
|
+
# a file slipped past every text rule once already, and a rule that skips
|
|
429
|
+
# that pass reopens exactly that hole.
|
|
430
|
+
for label, text in [("", cmd), *((f" (matched inside the invoked script {p})", b)
|
|
431
|
+
for p, b in _referenced_script_bodies(cmd))]:
|
|
432
|
+
if text is not cmd:
|
|
433
|
+
for rule in RULES:
|
|
434
|
+
if rule.search(text):
|
|
435
|
+
emit(False, f"{REASON}{label}")
|
|
436
|
+
if _laundered_infra_kill(text):
|
|
437
|
+
emit(False, f"{REASON}{label}")
|
|
438
|
+
hit = _protected_pid_hit(text)
|
|
439
|
+
if hit:
|
|
440
|
+
pid, what = hit
|
|
441
|
+
if what == "the deliver run in progress":
|
|
442
|
+
emit(False, DELIVER_PID_REASON.format(pid=pid) + label)
|
|
443
|
+
emit(False, STACK_PID_REASON.format(what=what, pid=pid) + label)
|
|
183
444
|
emit(True, "no infra-destructive pattern")
|
|
184
445
|
|
|
185
446
|
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -12,7 +12,9 @@ which drives a model to verified completion against the project's real gates
|
|
|
12
12
|
(build, type-check, tests) rather than ad-hoc hand edits.
|
|
13
13
|
|
|
14
14
|
The enforcer fires on `Edit` / `Write` / `MultiEdit` operations targeting
|
|
15
|
-
source-code files
|
|
15
|
+
source-code files, and on `Bash` commands that write source through the shell
|
|
16
|
+
(a redirect, heredoc, `tee` or `sed -i`), launch a GUI browser, or destroy the
|
|
17
|
+
gate's own state (see below). It is satisfied when any of the following holds:
|
|
16
18
|
|
|
17
19
|
- the edit runs inside a deliver-driven context (`UAP_DELIVER_ACTIVE=1`),
|
|
18
20
|
- an explicit operator override is set (`UAP_DELIVER_BYPASS=1`),
|
|
@@ -43,3 +45,19 @@ of blocking.
|
|
|
43
45
|
Exempt by construction: non-source files; `docs/`, `scripts/`, `policies/`,
|
|
44
46
|
`src/policies/`, test files (deliver protects those itself); and tooling
|
|
45
47
|
dot-dirs (`.claude/`, `.uap/`, `.worktrees/`, …).
|
|
48
|
+
|
|
49
|
+
**One carve-out from that dot-dir exemption: the gate's own state.** Destroying
|
|
50
|
+
`.uap/pending-deliver.jsonl`, `.uap/deliver.lock` or `.uap/deliver.heartbeat` —
|
|
51
|
+
by `rm`, `unlink`, `shred`, `truncate`, `mv`, `find -delete`, `git clean`, a
|
|
52
|
+
truncating `>` redirect, or removing `.uap/` itself — is blocked. The pending log
|
|
53
|
+
is the replay queue for `uap deliver --pending`, so deleting it discards recorded
|
|
54
|
+
work rather than completing it; the lock and heartbeat are how an in-flight run
|
|
55
|
+
is found, followed, and reclaimed when wedged, so deleting them starts a second
|
|
56
|
+
concurrent run on the same tree. Observed live 7x on 2026-07-31
|
|
57
|
+
(octopus_invaders_v3), interleaved with `kill -9` of the running deliver.
|
|
58
|
+
|
|
59
|
+
Unlike an edit, this block is **not** relaxed by `UAP_ENFORCE_DELIVERY=advisory`:
|
|
60
|
+
advisory trades verification for speed on a change, but destroying recorded state
|
|
61
|
+
has no verified-later equivalent. deliver's own housekeeping is unaffected — it
|
|
62
|
+
rewrites the pending log in-process, and its subprocesses carry
|
|
63
|
+
`UAP_DELIVER_ACTIVE=1`.
|
|
@@ -23,9 +23,38 @@ the session runs on** is blocked:
|
|
|
23
23
|
`vite --port 4000`, …) — this is how the port gets stolen even when kills are
|
|
24
24
|
blocked, during the service's crash/restart window.
|
|
25
25
|
|
|
26
|
+
- A broad `-f`/`--full` kill whose pattern is a substring of the stack's own
|
|
27
|
+
argv (`uap`, `llama`, `qwen`, `mmproj`, `nomic`, `anthropic`), or a glob over
|
|
28
|
+
the python interpreter that runs the proxy (`pkill -f "python.*"`).
|
|
29
|
+
- A kill held **apart** from its target, however the two are separated: by a
|
|
30
|
+
pipe (`ps aux | grep llama-server | xargs kill -9`), by an infra-port lookup
|
|
31
|
+
feeding it (`lsof -t -i:4000 | xargs kill -9`), by a variable
|
|
32
|
+
(`P=$(pgrep -f llama-server); kill -9 $P`), by a loop
|
|
33
|
+
(`for p in $(pgrep -f llama-server); do kill -9 $p; done`), or by a `-f`
|
|
34
|
+
pattern that is itself an infra port (`pkill -f 8080`).
|
|
35
|
+
- A kill whose **bare PID** resolves to a deliver run or the inference stack
|
|
36
|
+
(`kill -9 3936358`, and `kill -9 -3936358`, which kills the whole group).
|
|
37
|
+
This is resolved from `.uap/deliver.lock` and `/proc` rather than from the
|
|
38
|
+
command text, so it cannot be spelled around; a dead or recycled PID is never
|
|
39
|
+
protected.
|
|
40
|
+
- The same rules applied to the body of a shell script the command would run —
|
|
41
|
+
moving a stack-restart one level down into a file once slipped past every
|
|
42
|
+
text rule.
|
|
43
|
+
|
|
26
44
|
**Allowed**: killing a SPECIFIC process by pattern
|
|
27
45
|
(`pkill -f "python3 -m http.server 8765"`) and serving your app on any non-infra
|
|
28
|
-
port (e.g. `8765`)
|
|
46
|
+
port (e.g. `8765`) — **unless the pattern itself carries a stack token**, so
|
|
47
|
+
`pkill -f "uap deliver"` is refused. If a deliver run is in flight, wait for it
|
|
48
|
+
(deliver tool with `follow:true`, or `uap deliver --await-run`) instead of
|
|
49
|
+
killing it: a run that is still working is not stuck, and killing it discards
|
|
50
|
+
the work it has already done.
|
|
51
|
+
|
|
52
|
+
**Known limits.** This is a text gate over a language with unbounded ways to say
|
|
53
|
+
the same thing. Obfuscated patterns (`grep '[l]lama-server'`) match the real
|
|
54
|
+
process but not these rules; anything routed through `eval` or base64 defeats
|
|
55
|
+
every text rule by construction; the script-body scan is depth-1 and shell-only.
|
|
56
|
+
The bare-PID rule is the part that resolves rather than reads. Treat the rest as
|
|
57
|
+
raising the cost, not as a boundary.
|
|
29
58
|
|
|
30
59
|
## Why
|
|
31
60
|
|
|
@@ -36,9 +65,24 @@ proxy), `kill $(lsof -t -i:8080)` (killed `llama-server`), then
|
|
|
36
65
|
into a systemd bind-failure crash loop and `529`-ing its own session. The model
|
|
37
66
|
cannot serve requests once it has killed the stack that answers them.
|
|
38
67
|
|
|
68
|
+
Observed again (2026-07-31, octopus_invaders_v3): the model spent ~50 minutes in
|
|
69
|
+
a `ps`/`sleep`/`kill` loop, destroying six of its own in-flight `uap deliver`
|
|
70
|
+
runs and restarting them — each kill discarding work the run had already
|
|
71
|
+
completed. When its `pkill -f "uap deliver"` was refused at 11:13:46Z it ran the
|
|
72
|
+
`xargs kill -9` form **eight seconds later** and reported "cleaned". That is why
|
|
73
|
+
the rules above cover the whole family rather than the observed spelling: the
|
|
74
|
+
evasion gets found without being sought, and `SIGKILL` runs no exit handler, so
|
|
75
|
+
none of it appears in `.uap/deliver-exits.log`.
|
|
76
|
+
|
|
39
77
|
## Enforcement
|
|
40
78
|
|
|
41
79
|
Python enforcer `enforcement_infra_protect.py` (Bash/run_bash ops only). Emits a
|
|
42
80
|
specific remediation: kill your own processes by exact pattern and serve on a
|
|
43
|
-
port other than `8080`/`4000`/`8081
|
|
44
|
-
|
|
81
|
+
port other than `8080`/`4000`/`8081`, or — for a live deliver run — wait for it
|
|
82
|
+
rather than killing it.
|
|
83
|
+
|
|
84
|
+
Active by default in every project; this protects the runtime, not a per-project
|
|
85
|
+
opt-in. The only override is `UAP_INFRA_PROTECT_OFF=1` set in the launch
|
|
86
|
+
**environment** by the operator. An inline `UAP_INFRA_PROTECT_OFF=1 <cmd>` is
|
|
87
|
+
deliberately NOT honoured and is separately refused by `enforcement-self-protect`
|
|
88
|
+
— an agent that can grant itself the exemption has no control at all.
|
|
@@ -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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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(
|
|
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 =
|
|
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
|
|
55
|
+
return ns
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
|
|
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.
|