@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.
@@ -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.0",
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",
@@ -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 current branch AND covers the
6
- current HEAD. This makes the `parallel-expert-review` skill's "REQUIRED by
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
- branch = current_branch(root)
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
- changed = _changed_files(root)
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 recent_memory_query(root: Path) -> bool:
20
- db = root / "agents" / "data" / "memory" / "short_term.db"
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
- if recent_memory_query(repo_root()):
52
- emit(True, "recent uap memory query on record")
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/session storage), plus any
18
- colon-separated prefixes in UAP_WORKDIR_ALLOW.
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: > /abs, >> /abs (also 2>/abs).
136
- for m in re.finditer(r'(?:\d*>>?|&>)\s*("?)(/[^\s"\';|&)]+)\1', cmd):
137
- candidates.append(m.group(2))
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
- segments = re.split(r'\|\||&&|[;|&\n]', cmd)
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)
@@ -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()