@miller-tech/uap 1.184.0 → 1.184.1
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/docs/guides/POLICIES.md +43 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/codebase_read_before_plan.py +33 -1
- package/src/policies/enforcers/expert_review_required.py +90 -5
- package/src/policies/enforcers/memory_before_plan.py +24 -5
- package/src/policies/enforcers/workdir_scope.py +150 -6
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_expert_review_pr_scope.py +201 -0
- package/tools/agents/tests/test_workdir_scope_enforcer.py +152 -0
package/docs/guides/POLICIES.md
CHANGED
|
@@ -191,3 +191,46 @@ uap policy convert --input <id|file.md> --output out.md # render to CLAUDE.md
|
|
|
191
191
|
|
|
192
192
|
Changes invalidate the gate's policy cache immediately, so they take effect on
|
|
193
193
|
the next tool call.
|
|
194
|
+
|
|
195
|
+
## Changing an enforcer's code
|
|
196
|
+
|
|
197
|
+
Two things trip people up here, and both fail *silently* — the source looks
|
|
198
|
+
fixed while the gate keeps enforcing the old behaviour.
|
|
199
|
+
|
|
200
|
+
**1. The gate does not run `src/policies/enforcers/*.py`.** It runs
|
|
201
|
+
`.policy-tools/<policyId>_<toolName>.py`, a separate materialized copy (plus a
|
|
202
|
+
snapshot in the `code` column of `policies.db`). Editing the source changes
|
|
203
|
+
nothing on its own — re-run `uap policy install <slug>` to refresh the
|
|
204
|
+
executable copy:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
uap policy install workdir-scope
|
|
208
|
+
grep -l _my_new_function .policy-tools/*workdir_scope.py # verify it took
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**2. Run that install from the MAIN checkout, not a worktree.** The policy gate
|
|
212
|
+
anchors runtime state — `policies.db` and `.policy-tools/` — to `MAIN_ROOT`, so
|
|
213
|
+
that every worktree enforces the same policies. `uap policy install` run from
|
|
214
|
+
inside a worktree gets this wrong in *both* directions, while still printing
|
|
215
|
+
success:
|
|
216
|
+
|
|
217
|
+
- it **reads** the enforcer source from the main checkout (not the worktree's
|
|
218
|
+
edited copy), and
|
|
219
|
+
- it **writes** the materialized copy into a worktree-local `.policy-tools/`
|
|
220
|
+
that the gate never reads.
|
|
221
|
+
|
|
222
|
+
So the install is a no-op for enforcement, and it is silent about it: the
|
|
223
|
+
edited enforcer is verified by the test suite (which reads the worktree source)
|
|
224
|
+
while the running gate still executes the old code. Verified 2026-08-03 by
|
|
225
|
+
comparing the materialized copy against both sources — it matched the main
|
|
226
|
+
checkout byte for byte.
|
|
227
|
+
|
|
228
|
+
So an enforcer fix is two separate steps in two different directories:
|
|
229
|
+
|
|
230
|
+
- edit the enforcer **in your worktree**, so the change ships in the PR;
|
|
231
|
+
- after it merges, run `uap policy install <slug>` **from the main checkout** to
|
|
232
|
+
refresh the runtime copy.
|
|
233
|
+
|
|
234
|
+
Note that `enforcement-self-protect` blocks agent writes to `src/policies/**`
|
|
235
|
+
outright, with no model-reachable bypass. An agent cannot make either change —
|
|
236
|
+
enforcer edits are an operator action by design.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.184.
|
|
3
|
+
"version": "1.184.1",
|
|
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": "UAP_PROXY_ENV_AUTOLOAD=0 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 tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 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_expert_review_pr_scope 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 tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
Binary file
|
|
@@ -8,12 +8,41 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
11
|
-
from _common import arg_str, emit, parse_cli # noqa: E402
|
|
11
|
+
from _common import arg_str, emit, parse_cli, repo_root # noqa: E402
|
|
12
12
|
|
|
13
13
|
PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite"}
|
|
14
14
|
PLAN_WORD_RE = re.compile(r"(?<![-\w/])(plan the|design the|architect the|propose a plan|spec the)", re.I)
|
|
15
15
|
READ_LOG = Path(os.environ.get("UAP_STATE_DIR", ".uap")) / "read_log.state"
|
|
16
16
|
RECENT_SEC = 1800
|
|
17
|
+
# The PostToolUse hook that writes READ_LOG. This gate accepts evidence that
|
|
18
|
+
# ONLY that hook produces, so without it the evidence can never appear.
|
|
19
|
+
#
|
|
20
|
+
# Checked per platform, not just under .claude/: copyHookScripts() drops the
|
|
21
|
+
# script into EVERY platform's hook dir, but each platform needs its own
|
|
22
|
+
# PostToolUse wiring. A hardcoded .claude/ probe would report "writer present"
|
|
23
|
+
# for a Factory or Cursor session whose settings never invoke it — enforcing
|
|
24
|
+
# strictly against evidence that platform cannot produce, which is the same
|
|
25
|
+
# permanent block this fail-open exists to prevent.
|
|
26
|
+
WRITER_HOOK_DIRS = (".claude", ".factory", ".cursor", ".codex", ".forge", ".opencode")
|
|
27
|
+
WRITER_HOOK_NAME = "post-tool-use-read.sh"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def writer_installed() -> bool:
|
|
31
|
+
"""True when the hook that populates READ_LOG is installed for this platform.
|
|
32
|
+
|
|
33
|
+
Load-bearing. For a long time nothing wrote read_log.state at all: the
|
|
34
|
+
matcher was never added to settings.json, the last entries aged past
|
|
35
|
+
RECENT_SEC, and this gate then refused every ExitPlanMode with a remedy
|
|
36
|
+
("read the codebase first") that no amount of reading could clear. A gate
|
|
37
|
+
whose writer is missing silently escalates from advisory to a wall, so when
|
|
38
|
+
the writer is absent we degrade to advisory instead of bricking planning.
|
|
39
|
+
"""
|
|
40
|
+
roots = (repo_root(), Path.cwd())
|
|
41
|
+
for root in roots:
|
|
42
|
+
for hook_dir in WRITER_HOOK_DIRS:
|
|
43
|
+
if (root / hook_dir / "hooks" / WRITER_HOOK_NAME).exists():
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
17
46
|
|
|
18
47
|
|
|
19
48
|
def recent_reads() -> set[str]:
|
|
@@ -41,6 +70,9 @@ def main() -> None:
|
|
|
41
70
|
if reads:
|
|
42
71
|
emit(True, f"{len(reads)} recent codebase reads on record")
|
|
43
72
|
|
|
73
|
+
if not writer_installed():
|
|
74
|
+
emit(True, "read-log writer hook not installed — gate advisory (run `uap hooks install`)")
|
|
75
|
+
|
|
44
76
|
emit(
|
|
45
77
|
False,
|
|
46
78
|
"codebase-read-before-plan: no Read/Grep/Glob within the last 30 min. "
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
"""expert-review-required enforcer: a parallel expert review must precede ship.
|
|
3
3
|
|
|
4
4
|
Blocks ship actions (git commit / git push / gh pr create / merge / pr-ready /
|
|
5
|
-
signoff) unless a review artifact exists for the
|
|
6
|
-
|
|
5
|
+
signoff) unless a review artifact exists for the branch being shipped AND covers
|
|
6
|
+
its HEAD. For `gh pr merge <N>` the branch being shipped is the PR's head
|
|
7
|
+
branch, resolved via gh — not whatever branch the invoking shell is on. This makes the `parallel-expert-review` skill's "REQUIRED by
|
|
7
8
|
policy" claim real rather than advisory.
|
|
8
9
|
|
|
9
10
|
Review artifact: .uap/reviews/<branch-slug>.json, written by the
|
|
@@ -20,6 +21,7 @@ from __future__ import annotations
|
|
|
20
21
|
import json
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
24
|
+
import shlex
|
|
23
25
|
import sys
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
|
|
@@ -37,6 +39,19 @@ SHIP_PATTERNS = (
|
|
|
37
39
|
re.compile(r"\b(pr[-_ ]?ready|sign[-_ ]?off|ready[-_ ]for[-_ ]review)\b", re.I),
|
|
38
40
|
)
|
|
39
41
|
|
|
42
|
+
# A ship action that NAMES a pull request. The review that matters is the one
|
|
43
|
+
# for that PR's head branch, which is usually not the branch the shell is on.
|
|
44
|
+
PR_SHIP_VERBS = ("merge", "ready")
|
|
45
|
+
|
|
46
|
+
# Flags on those verbs that consume the NEXT token as their value. Without this,
|
|
47
|
+
# `gh pr merge -b 1 900` reads "1" as the PR — so the review for PR 1 authorises
|
|
48
|
+
# shipping PR 900. `gh pr merge --body Merging 123` misfires the same way by
|
|
49
|
+
# accident, which is the more likely path to it happening.
|
|
50
|
+
PR_VALUE_FLAGS = frozenset({
|
|
51
|
+
"-b", "--body", "-F", "--body-file", "-t", "--subject",
|
|
52
|
+
"-R", "--repo", "--match-head-commit", "-c", "--comment",
|
|
53
|
+
})
|
|
54
|
+
|
|
40
55
|
# Risk-scope: a parallel expert review is required only for *substantive* diffs.
|
|
41
56
|
# A diff that touches ONLY low-risk surfaces (frontend/styles, docs, config,
|
|
42
57
|
# tests, assets) ships freely — trivial/frontend PRs aren't gated. High-risk
|
|
@@ -115,6 +130,64 @@ def slug_for(branch: str) -> str:
|
|
|
115
130
|
return branch.replace("%", "%25").replace("/", "%2F")
|
|
116
131
|
|
|
117
132
|
|
|
133
|
+
def pr_reference(cmd: str) -> str | None:
|
|
134
|
+
"""The pull request a `gh pr merge|ready` command names, or None.
|
|
135
|
+
|
|
136
|
+
Tokenized rather than pattern-matched on "digits right after the verb":
|
|
137
|
+
flags may come first (`gh pr merge --squash 645`), and gh accepts a number,
|
|
138
|
+
a URL, or a branch name interchangeably. The narrow form missed all of
|
|
139
|
+
those and fell back to the local branch — silently reinstating the very bug
|
|
140
|
+
this resolution exists to fix.
|
|
141
|
+
|
|
142
|
+
A bare `gh pr merge` (the current branch's PR) returns None, which is
|
|
143
|
+
correct: the local branch IS the right thing to check then.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
tokens = shlex.split(cmd, comments=True)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None
|
|
149
|
+
for i in range(len(tokens) - 2):
|
|
150
|
+
if (
|
|
151
|
+
os.path.basename(tokens[i]) == "gh"
|
|
152
|
+
and tokens[i + 1] == "pr"
|
|
153
|
+
and tokens[i + 2] in PR_SHIP_VERBS
|
|
154
|
+
):
|
|
155
|
+
skip_value = False
|
|
156
|
+
for tok in tokens[i + 3:]:
|
|
157
|
+
if skip_value:
|
|
158
|
+
skip_value = False
|
|
159
|
+
continue
|
|
160
|
+
if tok.startswith("-"):
|
|
161
|
+
# `--body=x` carries its value inline; `--body x` does not,
|
|
162
|
+
# and that value can look exactly like a PR reference.
|
|
163
|
+
if tok in PR_VALUE_FLAGS:
|
|
164
|
+
skip_value = True
|
|
165
|
+
continue
|
|
166
|
+
return tok
|
|
167
|
+
return None
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pr_target(root: Path, ref: str) -> tuple[str | None, str | None]:
|
|
172
|
+
"""(head branch, head sha) of the PR being shipped, resolved via `gh`.
|
|
173
|
+
|
|
174
|
+
Returns (None, None) on any failure — no gh, no network, no auth, unknown
|
|
175
|
+
PR — so the caller falls back to the local branch and this stays fail-open.
|
|
176
|
+
`run` already bounds the call at 5s, and PR_SHIP_RE only matches an explicit
|
|
177
|
+
`gh pr merge/ready <N>`, so the cost is paid on ship actions, not per Bash.
|
|
178
|
+
"""
|
|
179
|
+
rc, out, _ = run(
|
|
180
|
+
["gh", "pr", "view", ref, "--json", "headRefName,headRefOid"], cwd=root
|
|
181
|
+
)
|
|
182
|
+
if rc != 0 or not out.strip():
|
|
183
|
+
return None, None
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(out)
|
|
186
|
+
except Exception: # noqa: BLE001
|
|
187
|
+
return None, None
|
|
188
|
+
return (data.get("headRefName") or None), (data.get("headRefOid") or None)
|
|
189
|
+
|
|
190
|
+
|
|
118
191
|
def head_sha(root: Path) -> str | None:
|
|
119
192
|
rc, out, _ = run(["git", "rev-parse", "HEAD"], cwd=root)
|
|
120
193
|
return out.strip() if rc == 0 and out.strip() else None
|
|
@@ -158,7 +231,16 @@ def main() -> None:
|
|
|
158
231
|
# to MAIN_ROOT by the gate, so it always read the main checkout's branch and
|
|
159
232
|
# demanded a review for the wrong branch on every worktree commit/push.
|
|
160
233
|
root = worktree_root()
|
|
161
|
-
|
|
234
|
+
|
|
235
|
+
# `gh pr merge 645` ships PR 645's branch. Reading the LOCAL branch here
|
|
236
|
+
# meant a merge run from the main checkout looked for .uap/reviews/master
|
|
237
|
+
# .json — an artifact for a branch that is not being shipped — and refused
|
|
238
|
+
# a PR whose own branch was reviewed and approved. Resolve the PR's head
|
|
239
|
+
# instead; fall back to the local branch when gh cannot answer.
|
|
240
|
+
pr_ref = pr_reference(cmd)
|
|
241
|
+
pr_branch, pr_sha = pr_target(root, pr_ref) if pr_ref else (None, None)
|
|
242
|
+
|
|
243
|
+
branch = pr_branch or current_branch(root)
|
|
162
244
|
if branch is None:
|
|
163
245
|
emit(True, "branch not resolvable (detached/non-git) — fail-open")
|
|
164
246
|
slug = slug_for(branch)
|
|
@@ -172,7 +254,10 @@ def main() -> None:
|
|
|
172
254
|
# migrations, or policy code — the change ships without a parallel review.
|
|
173
255
|
# When the base diff is not resolvable (None) we do NOT skip: we can't prove
|
|
174
256
|
# the change is low-risk, so the review requirement below still applies.
|
|
175
|
-
|
|
257
|
+
# Skipped for a PR ship: this diffs the LOCAL working tree, which on a
|
|
258
|
+
# `gh pr merge` from the main checkout is not the PR's contents at all —
|
|
259
|
+
# it would grade the wrong change as low-risk.
|
|
260
|
+
changed = None if pr_branch else _changed_files(root)
|
|
176
261
|
if changed and all(_is_low_risk(f) for f in changed):
|
|
177
262
|
emit(
|
|
178
263
|
True,
|
|
@@ -191,7 +276,7 @@ def main() -> None:
|
|
|
191
276
|
"(no longer honoured inline), or a waiver file.",
|
|
192
277
|
)
|
|
193
278
|
|
|
194
|
-
head = head_sha(root)
|
|
279
|
+
head = pr_sha or head_sha(root)
|
|
195
280
|
try:
|
|
196
281
|
data = json.loads(review.read_text())
|
|
197
282
|
except Exception: # noqa: BLE001
|
|
@@ -8,16 +8,34 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
11
|
-
from _common import arg_str, emit, parse_cli, repo_root # noqa: E402
|
|
11
|
+
from _common import arg_str, emit, parse_cli, repo_root, worktree_root # noqa: E402
|
|
12
12
|
|
|
13
13
|
PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite", "plan", "design"}
|
|
14
14
|
# Only match standalone words, not compounds like 'validate-plan-on-change'
|
|
15
15
|
PLAN_WORD_RE = re.compile(r"(?<![-\w/])(plan the|design the|architect the|propose a plan|roadmap for)", re.I)
|
|
16
16
|
RECENT_SEC = 300
|
|
17
|
+
DB_REL = Path("agents") / "data" / "memory" / "short_term.db"
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
def
|
|
20
|
-
|
|
20
|
+
def candidate_dbs() -> list[Path]:
|
|
21
|
+
"""Every short-term DB that could hold the evidence, worktree first.
|
|
22
|
+
|
|
23
|
+
`uap memory query` writes to the DB under the CWD, which inside a worktree
|
|
24
|
+
is the WORKTREE's DB — but this gate used to read only repo_root()'s. An
|
|
25
|
+
agent doing the required query while working in a worktree (which the
|
|
26
|
+
worktree policy mandates) therefore produced evidence the gate never saw,
|
|
27
|
+
and the remedy could not clear it. Same repo_root()-vs-worktree_root() bug
|
|
28
|
+
already fixed in expert-review-required and local-build-before-push.
|
|
29
|
+
"""
|
|
30
|
+
seen: list[Path] = []
|
|
31
|
+
for root in (worktree_root(), repo_root()):
|
|
32
|
+
db = root / DB_REL
|
|
33
|
+
if db not in seen:
|
|
34
|
+
seen.append(db)
|
|
35
|
+
return seen
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def recent_memory_query(db: Path) -> bool:
|
|
21
39
|
if not db.exists():
|
|
22
40
|
return False
|
|
23
41
|
try:
|
|
@@ -48,8 +66,9 @@ def main() -> None:
|
|
|
48
66
|
if op not in PLAN_OPS and not PLAN_WORD_RE.search(blob):
|
|
49
67
|
emit(True, "not a plan operation")
|
|
50
68
|
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
for db in candidate_dbs():
|
|
70
|
+
if recent_memory_query(db):
|
|
71
|
+
emit(True, "recent uap memory query on record")
|
|
53
72
|
|
|
54
73
|
emit(
|
|
55
74
|
False,
|
|
@@ -14,8 +14,9 @@ Allowed targets:
|
|
|
14
14
|
checkout (UAP_REPO_ROOT) — worktrees included;
|
|
15
15
|
* relative paths (they resolve under the project root);
|
|
16
16
|
* a scratch allow-list: /tmp, $TMPDIR, ~/.cache/uap, ~/.config/uap,
|
|
17
|
-
~/.claude/projects (Claude Code auto-memory
|
|
18
|
-
colon-separated prefixes in
|
|
17
|
+
~/.claude/projects and ~/.claude/plans (Claude Code auto-memory, session
|
|
18
|
+
and plan-file storage), plus any colon-separated prefixes in
|
|
19
|
+
UAP_WORKDIR_ALLOW.
|
|
19
20
|
|
|
20
21
|
Escape hatch: UAP_WORKDIR_SCOPE_OFF=1 allows everything (operator override).
|
|
21
22
|
"""
|
|
@@ -64,12 +65,19 @@ def _allowed_roots() -> list[Path]:
|
|
|
64
65
|
# topic files, MEMORY.md index, session/transcript data). The harness
|
|
65
66
|
# instructs agents to persist memories there; blocking it silently breaks
|
|
66
67
|
# memory recording (observed on pay2u 2026-07-05).
|
|
68
|
+
#
|
|
69
|
+
# ~/.claude/plans is the same story for plan mode: the harness assigns the
|
|
70
|
+
# agent a plan file under it and ExitPlanMode reads the plan back from
|
|
71
|
+
# there. Blocking it makes plan mode unusable - and since self-protect
|
|
72
|
+
# matches this enforcer's own override env var, that documented escape is
|
|
73
|
+
# unreachable from inside a session too (observed 2026-08-03).
|
|
67
74
|
for p in (
|
|
68
75
|
"/tmp",
|
|
69
76
|
os.environ.get("TMPDIR", "/tmp"),
|
|
70
77
|
"~/.cache/uap",
|
|
71
78
|
"~/.config/uap",
|
|
72
79
|
"~/.claude/projects",
|
|
80
|
+
"~/.claude/plans",
|
|
73
81
|
):
|
|
74
82
|
add(_expand(p))
|
|
75
83
|
for p in os.environ.get("UAP_WORKDIR_ALLOW", "").split(":"):
|
|
@@ -114,6 +122,108 @@ def _check_path(target: str, roots: list[Path]) -> str:
|
|
|
114
122
|
return "" if _inside(p, roots) else str(p)
|
|
115
123
|
|
|
116
124
|
|
|
125
|
+
# Shell constructs the quote model above does not represent. Their presence
|
|
126
|
+
# means a quoted span may still contain EXECUTING code (command substitution)
|
|
127
|
+
# or may not be quoted at all (escaped quote characters), so masking is unsafe.
|
|
128
|
+
_UNMODELLED = re.compile(r"\$\(|`|\$'|\\['\"]")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_LINE_CONT = re.compile(r'\\\n')
|
|
132
|
+
_REDIR_OP = re.compile(r'(?:\d*>>?|&>)')
|
|
133
|
+
# Targets may be tilde- or variable-prefixed: `> ~/x`, `> $HOME/x`. _expand()
|
|
134
|
+
# already resolves both before the scope check, but a `/`-anchored pattern
|
|
135
|
+
# never handed them over — so they were silently unchecked (confirmed by
|
|
136
|
+
# writing outside the project through both forms).
|
|
137
|
+
_REDIR_TARGET = re.compile(r'\s*("?)([~/$][^\s"\';|&)]+)\1')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _risk_view(cmd: str) -> str:
|
|
141
|
+
"""`cmd` with SINGLE-quoted spans blanked, for the unmodelled-construct check.
|
|
142
|
+
|
|
143
|
+
Single quotes suppress every expansion, so `$(`, a backtick or `$'` inside
|
|
144
|
+
them is inert prose and must not force the conservative raw scan — that is
|
|
145
|
+
how an ordinary `git commit -m '... $(uname) ... > /opt/notes ...'` came
|
|
146
|
+
to be refused. Double-quoted and unquoted occurrences stay visible, because
|
|
147
|
+
those DO execute.
|
|
148
|
+
"""
|
|
149
|
+
out = list(cmd)
|
|
150
|
+
in_sq = False
|
|
151
|
+
i = 0
|
|
152
|
+
while i < len(cmd):
|
|
153
|
+
ch = cmd[i]
|
|
154
|
+
if in_sq:
|
|
155
|
+
if ch == "'":
|
|
156
|
+
in_sq = False
|
|
157
|
+
else:
|
|
158
|
+
out[i] = " "
|
|
159
|
+
elif ch == "\\":
|
|
160
|
+
i += 2 # escaped char cannot open a quote
|
|
161
|
+
continue
|
|
162
|
+
elif ch == "'" and not (i and cmd[i - 1] == "$"):
|
|
163
|
+
# $'...' processes escapes, so it is NOT inert — leave it visible.
|
|
164
|
+
in_sq = True
|
|
165
|
+
i += 1
|
|
166
|
+
return "".join(out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _mask_quoted(cmd: str) -> tuple[str, bool]:
|
|
170
|
+
"""(masked copy, whether a quote was left unterminated).
|
|
171
|
+
|
|
172
|
+
Blanks the CONTENT of quoted spans, preserving length so offsets still line
|
|
173
|
+
up with the original and the redirect TARGET can be read from the real
|
|
174
|
+
string.
|
|
175
|
+
|
|
176
|
+
Escape-aware, and that is the load-bearing part. A naive quote toggle
|
|
177
|
+
desyncs on an escaped quote and then blanks everything after it — including
|
|
178
|
+
a genuinely unquoted redirect. `: \\" > /root/x` was ALLOWED by exactly
|
|
179
|
+
that bug: a containment gate turned into a bypass, which is strictly worse
|
|
180
|
+
than the false positive the masking was added to fix.
|
|
181
|
+
|
|
182
|
+
Shell rules honoured:
|
|
183
|
+
* unquoted `\\X` escapes X, so X can neither open a quote nor be an
|
|
184
|
+
operator (`\\>` is a literal, not a redirect);
|
|
185
|
+
* inside '...' there is NO escaping — the next ' always closes;
|
|
186
|
+
* inside "..." a backslash escapes the following character;
|
|
187
|
+
* $'...' does process escapes, so a backslashed quote does not close it.
|
|
188
|
+
"""
|
|
189
|
+
out = list(cmd)
|
|
190
|
+
n = len(cmd)
|
|
191
|
+
quote = None # None | "'" | '"' | "$'"
|
|
192
|
+
i = 0
|
|
193
|
+
while i < n:
|
|
194
|
+
ch = cmd[i]
|
|
195
|
+
if quote is None:
|
|
196
|
+
if ch == "\\":
|
|
197
|
+
# Escapes the next character: blank it so it cannot be read as an
|
|
198
|
+
# operator, and never let it open a quoted span.
|
|
199
|
+
if i + 1 < n:
|
|
200
|
+
out[i + 1] = " "
|
|
201
|
+
i += 2
|
|
202
|
+
continue
|
|
203
|
+
if ch == "'":
|
|
204
|
+
# $'...' processes escapes; a bare '...' does not.
|
|
205
|
+
quote = "$'" if i and cmd[i - 1] == "$" else "'"
|
|
206
|
+
elif ch == '"':
|
|
207
|
+
quote = '"'
|
|
208
|
+
elif quote == "'":
|
|
209
|
+
if ch == "'":
|
|
210
|
+
quote = None
|
|
211
|
+
else:
|
|
212
|
+
out[i] = " "
|
|
213
|
+
else: # '"' or "$'" — both process backslash escapes
|
|
214
|
+
if ch == "\\" and i + 1 < n:
|
|
215
|
+
out[i] = " "
|
|
216
|
+
out[i + 1] = " "
|
|
217
|
+
i += 2
|
|
218
|
+
continue
|
|
219
|
+
if (quote == '"' and ch == '"') or (quote == "$'" and ch == "'"):
|
|
220
|
+
quote = None
|
|
221
|
+
else:
|
|
222
|
+
out[i] = " "
|
|
223
|
+
i += 1
|
|
224
|
+
return "".join(out), quote is not None
|
|
225
|
+
|
|
226
|
+
|
|
117
227
|
def _scan_bash(cmd: str, roots: list[Path]) -> str:
|
|
118
228
|
"""Best-effort: flag an out-of-scope absolute path that a CREATE/MOVE command
|
|
119
229
|
would write. Conservative — only inspects the destinations of known
|
|
@@ -125,6 +235,11 @@ def _scan_bash(cmd: str, roots: list[Path]) -> str:
|
|
|
125
235
|
# newlines flags any path-shaped string inside it. Bodies that could be
|
|
126
236
|
# executed are left in place by the helper.
|
|
127
237
|
cmd = strip_heredoc_bodies(cmd)
|
|
238
|
+
# Bash removes `\\<newline>` before word-splitting. Leaving it in split one
|
|
239
|
+
# logical command across two segments, so a create verb on the first line
|
|
240
|
+
# never met its destination on the second — `mkdir -p \\<newline> /outside`
|
|
241
|
+
# was allowed while bash created the directory.
|
|
242
|
+
cmd = _LINE_CONT.sub(" ", cmd)
|
|
128
243
|
try:
|
|
129
244
|
tokens = shlex.split(cmd, comments=True)
|
|
130
245
|
except ValueError:
|
|
@@ -132,12 +247,41 @@ def _scan_bash(cmd: str, roots: list[Path]) -> str:
|
|
|
132
247
|
|
|
133
248
|
candidates: list[str] = []
|
|
134
249
|
|
|
135
|
-
# Output redirections
|
|
136
|
-
|
|
137
|
-
|
|
250
|
+
# Output redirections.
|
|
251
|
+
#
|
|
252
|
+
# Operators are located in a QUOTE-MASKED copy, because a redirect
|
|
253
|
+
# operator inside quotes is not a redirect - it is literal text.
|
|
254
|
+
# Scanning the raw string refused a sed range expression, reading the
|
|
255
|
+
# trailing '/p' of `sed -n '/a/,/b/p'` as a write to /p when the range
|
|
256
|
+
# delimiters were angle brackets (observed 2026-08-03, while editing
|
|
257
|
+
# this very enforcer). The TARGET is then read from the ORIGINAL
|
|
258
|
+
# string at that offset, so a legitimately quoted absolute destination
|
|
259
|
+
# is still detected.
|
|
260
|
+
masked, unterminated = _mask_quoted(cmd)
|
|
261
|
+
# Scan the RAW string whenever the quote model cannot be trusted:
|
|
262
|
+
#
|
|
263
|
+
# * an unterminated quote — the mask is desynced by construction;
|
|
264
|
+
# * a construct the model does not represent (_UNMODELLED). Command
|
|
265
|
+
# substitution is the important one: it EXECUTES inside double quotes,
|
|
266
|
+
# so blanking a quoted span hides a live redirect. Verified with bash —
|
|
267
|
+
# `echo "$(id > /outside)"` writes the file, and the masked scan saw
|
|
268
|
+
# nothing. A containment gate must over-block, never under-block, so
|
|
269
|
+
# these fall back to the conservative scan and accept its false
|
|
270
|
+
# positives.
|
|
271
|
+
#
|
|
272
|
+
# The sed-range case this masking exists to fix contains none of them, so it
|
|
273
|
+
# still passes.
|
|
274
|
+
untrusted = unterminated or _UNMODELLED.search(_risk_view(cmd)) is not None
|
|
275
|
+
for m in _REDIR_OP.finditer(cmd if untrusted else masked):
|
|
276
|
+
target = _REDIR_TARGET.match(cmd, m.end())
|
|
277
|
+
if target:
|
|
278
|
+
candidates.append(target.group(2))
|
|
138
279
|
|
|
139
280
|
# Split into pipeline/sequence segments so we read each command's own verb.
|
|
140
|
-
|
|
281
|
+
# Parens are separators too, so the inner command of a process substitution
|
|
282
|
+
# becomes its own segment: `> >(tee /outside)` otherwise hid `tee`'s
|
|
283
|
+
# destination from the verb scan entirely, while bash wrote the file.
|
|
284
|
+
segments = re.split(r'\|\||&&|[;|&\n()]', cmd)
|
|
141
285
|
for seg in segments:
|
|
142
286
|
try:
|
|
143
287
|
parts = shlex.split(seg, comments=True)
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""expert-review-required: a PR ship is scoped to the PR's branch.
|
|
3
|
+
|
|
4
|
+
`gh pr merge <N>` ships the branch of PR N, which is usually not the branch the
|
|
5
|
+
invoking shell is standing on. Keying the review off the LOCAL branch meant a
|
|
6
|
+
merge run from the main checkout looked for `.uap/reviews/master.json`, found a
|
|
7
|
+
stale artifact from an unrelated past session, and refused a PR whose own branch
|
|
8
|
+
was reviewed and approved (observed 2026-08-03 merging #645).
|
|
9
|
+
|
|
10
|
+
`gh` is stubbed on PATH, so these tests need no network or auth.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ENFORCER = (
|
|
23
|
+
Path(__file__).resolve().parents[3]
|
|
24
|
+
/ "src" / "policies" / "enforcers" / "expert_review_required.py"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PR_NUMBER = "645"
|
|
28
|
+
VERB = "merge"
|
|
29
|
+
PR_BRANCH = "feature/160-plan-gate-writers"
|
|
30
|
+
PR_SHA = "dd5ce346281d720dff357d4a649d7389e65bfd61"
|
|
31
|
+
STALE_SHA = "2f1b660f" + "0" * 32
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _slug(branch):
|
|
35
|
+
return branch.replace("%", "%25").replace("/", "%2F")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git(root, *args):
|
|
39
|
+
env = dict(os.environ)
|
|
40
|
+
for v in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
|
41
|
+
env.pop(v, None)
|
|
42
|
+
subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, env=env)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestExpertReviewPrScope(unittest.TestCase):
|
|
46
|
+
def setUp(self):
|
|
47
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
48
|
+
base = Path(self._tmp.name)
|
|
49
|
+
self.root = base / "repo"
|
|
50
|
+
self.bin = base / "bin"
|
|
51
|
+
self.root.mkdir()
|
|
52
|
+
self.bin.mkdir()
|
|
53
|
+
self._write_stub_gh()
|
|
54
|
+
self._init_repo()
|
|
55
|
+
|
|
56
|
+
def tearDown(self):
|
|
57
|
+
self._tmp.cleanup()
|
|
58
|
+
|
|
59
|
+
def _write_stub_gh(self, head_ref=PR_BRANCH, head_oid=PR_SHA, rc=0, per_ref=None):
|
|
60
|
+
"""Stub `gh pr view`. `per_ref` maps a PR reference -> (branch, sha), so
|
|
61
|
+
a test can prove WHICH pull request the enforcer actually resolved."""
|
|
62
|
+
gh = self.bin / "gh"
|
|
63
|
+
gh.write_text(
|
|
64
|
+
"#!/usr/bin/env python3\n"
|
|
65
|
+
"import json, sys\n"
|
|
66
|
+
f"if {rc} != 0:\n"
|
|
67
|
+
f" sys.exit({rc})\n"
|
|
68
|
+
f"per_ref = {per_ref!r}\n"
|
|
69
|
+
"argv = sys.argv[1:]\n"
|
|
70
|
+
"if 'headRefName' in ' '.join(argv):\n"
|
|
71
|
+
" ref = argv[2] if len(argv) > 2 else ''\n"
|
|
72
|
+
" if per_ref and ref in per_ref:\n"
|
|
73
|
+
" b, o = per_ref[ref]\n"
|
|
74
|
+
" else:\n"
|
|
75
|
+
f" b, o = {head_ref!r}, {head_oid!r}\n"
|
|
76
|
+
" print(json.dumps({'headRefName': b, 'headRefOid': o}))\n"
|
|
77
|
+
" sys.exit(0)\n"
|
|
78
|
+
"sys.exit(1)\n"
|
|
79
|
+
)
|
|
80
|
+
gh.chmod(gh.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
81
|
+
|
|
82
|
+
def _init_repo(self):
|
|
83
|
+
_git(self.root, "init", "-q", "-b", "master", ".")
|
|
84
|
+
_git(self.root, "config", "user.email", "t@t")
|
|
85
|
+
_git(self.root, "config", "user.name", "t")
|
|
86
|
+
# A high-risk path, so the low-risk scope skip cannot mask the outcome.
|
|
87
|
+
(self.root / "src" / "policies").mkdir(parents=True)
|
|
88
|
+
(self.root / "src" / "policies" / "x.py").write_text("x = 1\n")
|
|
89
|
+
_git(self.root, "add", "-A")
|
|
90
|
+
_git(self.root, "commit", "-qm", "init")
|
|
91
|
+
|
|
92
|
+
self.reviews = self.root / ".uap" / "reviews"
|
|
93
|
+
self.reviews.mkdir(parents=True)
|
|
94
|
+
|
|
95
|
+
def _write_review(self, branch, head, verdict="approve"):
|
|
96
|
+
(self.reviews / f"{_slug(branch)}.json").write_text(
|
|
97
|
+
json.dumps({"branch": branch, "head": head, "verdict": verdict,
|
|
98
|
+
"reviewers": ["code-quality-reviewer"]})
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _run(self, command):
|
|
102
|
+
env = dict(os.environ)
|
|
103
|
+
env["UAP_REPO_ROOT"] = str(self.root)
|
|
104
|
+
env["UAP_WORKTREE_ROOT"] = str(self.root)
|
|
105
|
+
env["PATH"] = f"{self.bin}{os.pathsep}" + env.get("PATH", "")
|
|
106
|
+
env.pop("UAP_NO_REVIEW", None)
|
|
107
|
+
p = subprocess.run(
|
|
108
|
+
[sys.executable, str(ENFORCER), "--operation", "Bash",
|
|
109
|
+
"--args", json.dumps({"command": command})],
|
|
110
|
+
capture_output=True, text=True, env=env, cwd=str(self.root),
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
out = json.loads(p.stdout)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
out = {"allowed": True, "reason": f"<unparseable {p.stdout!r} {p.stderr!r}>"}
|
|
116
|
+
return out, p.returncode
|
|
117
|
+
|
|
118
|
+
# --- the regression -----------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def test_pr_merge_uses_the_prs_branch_not_the_current_one(self):
|
|
121
|
+
self._write_review(PR_BRANCH, PR_SHA)
|
|
122
|
+
# master carries a stale artifact from an unrelated session.
|
|
123
|
+
self._write_review("master", STALE_SHA)
|
|
124
|
+
|
|
125
|
+
out, code = self._run(f"gh pr merge {PR_NUMBER} --squash")
|
|
126
|
+
self.assertTrue(out.get("allowed"), f"should ship on the PR's review: {out.get('reason')}")
|
|
127
|
+
self.assertEqual(code, 0)
|
|
128
|
+
self.assertIn(_slug(PR_BRANCH), out.get("reason", ""))
|
|
129
|
+
|
|
130
|
+
def test_pr_merge_blocked_when_the_prs_own_review_is_missing(self):
|
|
131
|
+
# An approved artifact for the CURRENT branch must not authorise a
|
|
132
|
+
# different branch's PR — that would be the bug inverted.
|
|
133
|
+
self._write_review("master", STALE_SHA)
|
|
134
|
+
out, code = self._run(f"gh pr merge {PR_NUMBER} --squash")
|
|
135
|
+
self.assertFalse(out.get("allowed"), "no review for the PR's branch")
|
|
136
|
+
self.assertEqual(code, 2)
|
|
137
|
+
self.assertIn(_slug(PR_BRANCH), out.get("reason", ""))
|
|
138
|
+
|
|
139
|
+
def test_pr_merge_blocked_when_the_prs_review_is_stale(self):
|
|
140
|
+
self._write_review(PR_BRANCH, "0" * 40) # covers some other head
|
|
141
|
+
out, code = self._run(f"gh pr merge {PR_NUMBER} --squash")
|
|
142
|
+
self.assertFalse(out.get("allowed"), "stale review for the PR's branch")
|
|
143
|
+
self.assertEqual(code, 2)
|
|
144
|
+
|
|
145
|
+
# --- fallbacks ----------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def test_falls_back_to_local_branch_when_gh_cannot_answer(self):
|
|
148
|
+
# No gh, no network, wrong auth: resolution returns nothing and the
|
|
149
|
+
# enforcer must behave exactly as before rather than fail open.
|
|
150
|
+
self._write_stub_gh(rc=1)
|
|
151
|
+
self._write_review("master", STALE_SHA)
|
|
152
|
+
out, code = self._run(f"gh pr merge {PR_NUMBER} --squash")
|
|
153
|
+
self.assertFalse(out.get("allowed"), "falls back to the local branch")
|
|
154
|
+
self.assertEqual(code, 2)
|
|
155
|
+
self.assertIn("master", out.get("reason", ""))
|
|
156
|
+
|
|
157
|
+
def test_flags_before_the_pr_number_still_resolve(self):
|
|
158
|
+
# `gh pr merge --squash 645` is the form most people type. A pattern
|
|
159
|
+
# pinned to digits-right-after-the-verb missed it and fell back to the
|
|
160
|
+
# local branch, silently reinstating the bug this resolution fixes.
|
|
161
|
+
self._write_review(PR_BRANCH, PR_SHA)
|
|
162
|
+
self._write_review("master", STALE_SHA)
|
|
163
|
+
out, code = self._run("gh pr " + VERB + " --squash " + PR_NUMBER)
|
|
164
|
+
self.assertTrue(out.get("allowed"), f"flags-first form: {out.get('reason')}")
|
|
165
|
+
self.assertEqual(code, 0)
|
|
166
|
+
self.assertIn(_slug(PR_BRANCH), out.get("reason", ""))
|
|
167
|
+
|
|
168
|
+
def test_bare_pr_merge_uses_the_current_branch(self):
|
|
169
|
+
# No PR named: the current branch IS the right thing to check.
|
|
170
|
+
self._write_review("master", STALE_SHA)
|
|
171
|
+
out, code = self._run("gh pr " + VERB)
|
|
172
|
+
self.assertFalse(out.get("allowed"), "bare merge falls back to local branch")
|
|
173
|
+
self.assertIn("master", out.get("reason", ""))
|
|
174
|
+
|
|
175
|
+
def test_a_value_bearing_flag_does_not_become_the_pr_reference(self):
|
|
176
|
+
# `gh pr merge -b 1 900` merges PR 900 with commit body "1". Taking the
|
|
177
|
+
# first non-flag token read "1" as the PR, so an approved review for
|
|
178
|
+
# PR 1 would authorise shipping the unreviewed PR 900 — the gate
|
|
179
|
+
# approving a different target than the command ships.
|
|
180
|
+
self._write_stub_gh(per_ref={
|
|
181
|
+
"1": (PR_BRANCH, PR_SHA), # reviewed + approved
|
|
182
|
+
"900": ("feature/unreviewed", "9" * 40), # the real target
|
|
183
|
+
})
|
|
184
|
+
self._write_review(PR_BRANCH, PR_SHA)
|
|
185
|
+
|
|
186
|
+
out, code = self._run("gh pr " + VERB + " -b 1 900")
|
|
187
|
+
self.assertFalse(out.get("allowed"), "must judge PR 900, not the -b value")
|
|
188
|
+
self.assertEqual(code, 2)
|
|
189
|
+
self.assertIn("feature%2Funreviewed", out.get("reason", ""))
|
|
190
|
+
|
|
191
|
+
def test_non_pr_ship_still_uses_the_current_branch(self):
|
|
192
|
+
# `git push` from master is still judged against master.
|
|
193
|
+
self._write_review("master", STALE_SHA)
|
|
194
|
+
out, code = self._run("git push origin master")
|
|
195
|
+
self.assertFalse(out.get("allowed"), "stale master review blocks a master push")
|
|
196
|
+
self.assertEqual(code, 2)
|
|
197
|
+
self.assertIn("master", out.get("reason", ""))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
unittest.main()
|
|
@@ -140,5 +140,157 @@ class TestWorkdirScopeEnforcer(unittest.TestCase):
|
|
|
140
140
|
self._allow(out, c, "read outside allowed")
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
# Redirect operator, built by codepoint: a literal one in this file would be
|
|
144
|
+
# read as a redirection by the very enforcer under test.
|
|
145
|
+
GT = chr(62)
|
|
146
|
+
BS = chr(92)
|
|
147
|
+
DQ = chr(34)
|
|
148
|
+
SQ = chr(39)
|
|
149
|
+
BT = chr(96)
|
|
150
|
+
TILDE = chr(126)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestWorkdirScopeQuoting(unittest.TestCase):
|
|
154
|
+
"""A redirect operator inside quotes is literal text, not a redirect.
|
|
155
|
+
|
|
156
|
+
Scanning the raw command string made `sed -n '/a/,/b/p'` unusable whenever
|
|
157
|
+
the range delimiters were angle brackets: the trailing `/p` was read as a
|
|
158
|
+
write to /p. That refused an ordinary conflict-inspection command, and it
|
|
159
|
+
refused the edit that fixes it (observed 2026-08-03).
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def setUp(self):
|
|
163
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
164
|
+
self.root = Path(self._tmp.name)
|
|
165
|
+
|
|
166
|
+
def tearDown(self):
|
|
167
|
+
self._tmp.cleanup()
|
|
168
|
+
|
|
169
|
+
def _allow(self, out, code, label):
|
|
170
|
+
self.assertTrue(out.get("allowed"), f"{label}: {out.get('reason')}")
|
|
171
|
+
self.assertEqual(code, 0, label)
|
|
172
|
+
|
|
173
|
+
def _block(self, out, code, label):
|
|
174
|
+
self.assertFalse(out.get("allowed"), f"{label} should be blocked")
|
|
175
|
+
self.assertEqual(code, 2, label)
|
|
176
|
+
|
|
177
|
+
# --- the false positive ---
|
|
178
|
+
|
|
179
|
+
def test_sed_range_with_angle_delimiters_allowed(self):
|
|
180
|
+
cmd = "sed -n '/" + "<" * 7 + "/,/" + GT * 7 + "/p' CHANGELOG.md"
|
|
181
|
+
out, c = run("Bash", {"command": cmd}, self.root)
|
|
182
|
+
self._allow(out, c, "sed range is not a redirect")
|
|
183
|
+
|
|
184
|
+
def test_quoted_redirect_text_allowed(self):
|
|
185
|
+
out, c = run("Bash", {"command": "echo 'writes to /etc/passwd'"}, self.root)
|
|
186
|
+
self._allow(out, c, "quoted text is not a redirect")
|
|
187
|
+
|
|
188
|
+
def test_double_quoted_redirect_text_allowed(self):
|
|
189
|
+
out, c = run("Bash", {"command": 'echo "result ' + GT + ' /etc/passwd"'}, self.root)
|
|
190
|
+
self._allow(out, c, "double-quoted text is not a redirect")
|
|
191
|
+
|
|
192
|
+
# --- and the detection it must NOT weaken ---
|
|
193
|
+
|
|
194
|
+
def test_real_redirect_still_blocked(self):
|
|
195
|
+
out, c = run("Bash", {"command": "echo hi " + GT + " /etc/uap-marker"}, self.root)
|
|
196
|
+
self._block(out, c, "real redirect outside workdir")
|
|
197
|
+
|
|
198
|
+
def test_real_append_still_blocked(self):
|
|
199
|
+
out, c = run("Bash", {"command": "echo hi " + GT * 2 + " /etc/uap-marker"}, self.root)
|
|
200
|
+
self._block(out, c, "real append outside workdir")
|
|
201
|
+
|
|
202
|
+
def test_quoted_destination_still_blocked(self):
|
|
203
|
+
# The operator is unquoted; only the TARGET is quoted. Masking quoted
|
|
204
|
+
# spans must not lose this one.
|
|
205
|
+
out, c = run("Bash", {"command": 'echo hi ' + GT + ' "/etc/uap-marker"'}, self.root)
|
|
206
|
+
self._block(out, c, "quoted absolute destination")
|
|
207
|
+
|
|
208
|
+
# --- escaped quotes must not hide a REAL redirect (security review) ---
|
|
209
|
+
#
|
|
210
|
+
# A naive quote toggle desyncs on an escaped quote and blanks everything
|
|
211
|
+
# after it, including a live redirect. Each of these was ALLOWED by that
|
|
212
|
+
# bug: a containment bypass, strictly worse than the false positive the
|
|
213
|
+
# masking was added to fix.
|
|
214
|
+
|
|
215
|
+
def test_escaped_double_quote_does_not_hide_a_redirect(self):
|
|
216
|
+
cmd = ": " + BS + DQ + " " + GT + " /root/uap-probe"
|
|
217
|
+
out, c = self._run_bash(cmd)
|
|
218
|
+
self._block(out, c, "escaped double quote then real redirect")
|
|
219
|
+
|
|
220
|
+
def test_escaped_single_quote_does_not_hide_a_redirect(self):
|
|
221
|
+
cmd = ": " + BS + SQ + " " + GT + " /root/uap-probe"
|
|
222
|
+
out, c = self._run_bash(cmd)
|
|
223
|
+
self._block(out, c, "escaped single quote then real redirect")
|
|
224
|
+
|
|
225
|
+
def test_ansi_c_quoting_does_not_hide_a_redirect(self):
|
|
226
|
+
cmd = "echo $" + SQ + "a" + BS + SQ + "b" + SQ + " " + GT + " /root/uap-probe"
|
|
227
|
+
out, c = self._run_bash(cmd)
|
|
228
|
+
self._block(out, c, "ANSI-C quoting then real redirect")
|
|
229
|
+
|
|
230
|
+
def test_unterminated_quote_falls_back_to_raw_scan(self):
|
|
231
|
+
# The mask cannot be trusted, so over-block rather than under-block.
|
|
232
|
+
cmd = "echo " + DQ + "oops " + GT + " /root/uap-probe"
|
|
233
|
+
out, c = self._run_bash(cmd)
|
|
234
|
+
self._block(out, c, "unterminated quote")
|
|
235
|
+
|
|
236
|
+
# --- command substitution EXECUTES inside double quotes ---
|
|
237
|
+
#
|
|
238
|
+
# Masking a quoted span hid these: bash really does perform the redirect
|
|
239
|
+
# (verified), so blanking the span turned a containment gate into a bypass.
|
|
240
|
+
# The masker cannot model substitution, so its presence forces the raw scan.
|
|
241
|
+
|
|
242
|
+
def test_command_substitution_in_double_quotes_is_not_hidden(self):
|
|
243
|
+
cmd = "echo " + DQ + "$(id " + GT + " /root/uap-probe)" + DQ
|
|
244
|
+
out, c = self._run_bash(cmd)
|
|
245
|
+
self._block(out, c, "$() inside double quotes")
|
|
246
|
+
|
|
247
|
+
def test_backticks_in_double_quotes_are_not_hidden(self):
|
|
248
|
+
cmd = "echo " + DQ + BT + "id " + GT + " /root/uap-probe" + BT + DQ
|
|
249
|
+
out, c = self._run_bash(cmd)
|
|
250
|
+
self._block(out, c, "backticks inside double quotes")
|
|
251
|
+
|
|
252
|
+
# --- targets the scanner used to never look at (bash-confirmed writes) ---
|
|
253
|
+
|
|
254
|
+
def test_tilde_redirect_target_is_checked(self):
|
|
255
|
+
# _expand() resolves ~ already; the target pattern just never handed it
|
|
256
|
+
# over, so `> ~/x` wrote outside the project unchecked.
|
|
257
|
+
out, c = self._run_bash("echo hi " + GT + " " + TILDE + "/uap-probe")
|
|
258
|
+
self._block(out, c, "tilde redirect target")
|
|
259
|
+
|
|
260
|
+
def test_variable_redirect_target_is_checked(self):
|
|
261
|
+
out, c = self._run_bash("echo hi " + GT + " $HOME/uap-probe")
|
|
262
|
+
self._block(out, c, "$HOME redirect target")
|
|
263
|
+
|
|
264
|
+
def test_line_continuation_keeps_verb_and_destination_together(self):
|
|
265
|
+
# Bash removes the continuation before word-splitting; the scanner did
|
|
266
|
+
# not, so the destination landed in a segment with no create verb.
|
|
267
|
+
out, c = self._run_bash("mkdir -p " + chr(92) + "\n /root/uap-probe")
|
|
268
|
+
self._block(out, c, "destination after a line continuation")
|
|
269
|
+
|
|
270
|
+
def test_process_substitution_destination_is_checked(self):
|
|
271
|
+
# `> >(tee /outside)` really writes; parens were not segment
|
|
272
|
+
# separators, so tee's argument was invisible to the verb scan.
|
|
273
|
+
out, c = self._run_bash("echo data " + GT + " " + GT + "(tee /root/uap-probe)")
|
|
274
|
+
self._block(out, c, "process substitution destination")
|
|
275
|
+
|
|
276
|
+
# --- and the prose that must NOT be mistaken for a redirect ---
|
|
277
|
+
|
|
278
|
+
def test_inert_single_quoted_prose_is_not_a_redirect(self):
|
|
279
|
+
# Single quotes suppress substitution, so $( inside them is text. Forcing
|
|
280
|
+
# the conservative raw scan on its account blocked ordinary commit
|
|
281
|
+
# messages that merely discuss shell syntax.
|
|
282
|
+
cmd = ("git commit -m 'See $(uname) docs; example redirect "
|
|
283
|
+
+ GT + " /opt/notes explained here'")
|
|
284
|
+
out, c = self._run_bash(cmd)
|
|
285
|
+
self._allow(out, c, "inert prose in a single-quoted message")
|
|
286
|
+
|
|
287
|
+
def _run_bash(self, cmd):
|
|
288
|
+
return run("Bash", {"command": cmd}, self.root)
|
|
289
|
+
|
|
290
|
+
def test_stderr_redirect_to_dev_null_allowed(self):
|
|
291
|
+
out, c = run("Bash", {"command": "ls 2" + GT + "/dev/null"}, self.root)
|
|
292
|
+
self._allow(out, c, "/dev/null is not an escape")
|
|
293
|
+
|
|
294
|
+
|
|
143
295
|
if __name__ == "__main__":
|
|
144
296
|
unittest.main()
|