@miller-tech/uap 1.183.3 → 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.
Files changed (68) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +14 -0
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/principles.d.ts +10 -0
  5. package/dist/cli/principles.d.ts.map +1 -0
  6. package/dist/cli/principles.js +138 -0
  7. package/dist/cli/principles.js.map +1 -0
  8. package/dist/config/policy-recommendations.d.ts.map +1 -1
  9. package/dist/config/policy-recommendations.js +17 -0
  10. package/dist/config/policy-recommendations.js.map +1 -1
  11. package/dist/config/settings-registry.d.ts +1 -1
  12. package/dist/config/settings-registry.d.ts.map +1 -1
  13. package/dist/config/settings-registry.js +24 -0
  14. package/dist/config/settings-registry.js.map +1 -1
  15. package/dist/coordination/reactor.d.ts.map +1 -1
  16. package/dist/coordination/reactor.js +15 -0
  17. package/dist/coordination/reactor.js.map +1 -1
  18. package/dist/delivery/convergence-loop.d.ts +7 -1
  19. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  20. package/dist/delivery/convergence-loop.js +32 -4
  21. package/dist/delivery/convergence-loop.js.map +1 -1
  22. package/dist/delivery/judge.d.ts.map +1 -1
  23. package/dist/delivery/judge.js +6 -0
  24. package/dist/delivery/judge.js.map +1 -1
  25. package/dist/delivery/task-orchestrator.d.ts.map +1 -1
  26. package/dist/delivery/task-orchestrator.js +8 -1
  27. package/dist/delivery/task-orchestrator.js.map +1 -1
  28. package/dist/principles/config.d.ts +10 -0
  29. package/dist/principles/config.d.ts.map +1 -0
  30. package/dist/principles/config.js +32 -0
  31. package/dist/principles/config.js.map +1 -0
  32. package/dist/principles/index.d.ts +16 -0
  33. package/dist/principles/index.d.ts.map +1 -0
  34. package/dist/principles/index.js +34 -0
  35. package/dist/principles/index.js.map +1 -0
  36. package/dist/principles/reactor-inject.d.ts +12 -0
  37. package/dist/principles/reactor-inject.d.ts.map +1 -0
  38. package/dist/principles/reactor-inject.js +69 -0
  39. package/dist/principles/reactor-inject.js.map +1 -0
  40. package/dist/principles/render.d.ts +14 -0
  41. package/dist/principles/render.d.ts.map +1 -0
  42. package/dist/principles/render.js +64 -0
  43. package/dist/principles/render.js.map +1 -0
  44. package/dist/principles/rules.d.ts +53 -0
  45. package/dist/principles/rules.d.ts.map +1 -0
  46. package/dist/principles/rules.js +101 -0
  47. package/dist/principles/rules.js.map +1 -0
  48. package/dist/principles/stance.d.ts +54 -0
  49. package/dist/principles/stance.d.ts.map +1 -0
  50. package/dist/principles/stance.js +154 -0
  51. package/dist/principles/stance.js.map +1 -0
  52. package/dist/types/config.d.ts +46 -0
  53. package/dist/types/config.d.ts.map +1 -1
  54. package/dist/types/config.js +17 -0
  55. package/dist/types/config.js.map +1 -1
  56. package/docs/guides/POLICIES.md +43 -0
  57. package/docs/reference/CONFIGURATION_REFERENCE.md +221 -0
  58. package/package.json +2 -2
  59. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  60. package/src/policies/enforcers/codebase_read_before_plan.py +33 -1
  61. package/src/policies/enforcers/expert_review_required.py +90 -5
  62. package/src/policies/enforcers/memory_before_plan.py +24 -5
  63. package/src/policies/enforcers/workdir_scope.py +150 -6
  64. package/src/policies/schemas/policies/engineering-principles.md +93 -0
  65. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  66. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  67. package/tools/agents/tests/test_expert_review_pr_scope.py +201 -0
  68. package/tools/agents/tests/test_workdir_scope_enforcer.py +152 -0
@@ -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,93 @@
1
+ # engineering-principles
2
+
3
+ **Category**: quality
4
+ **Level**: RECOMMENDED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: quality, simplicity, architecture, dependencies, prior-art
7
+
8
+ ## Rule
9
+
10
+ Write code to these principles. Rules 2-8 always apply. Rule 1 depends on a
11
+ stance resolved per project per session — run `uap principles show` to see the
12
+ form in force, and `uap principles ask` to answer it.
13
+
14
+ 1. **Backward compatibility — stance-dependent.**
15
+ - `remove`: delete obsolete paths instead of adding compatibility layers,
16
+ fallbacks, or migrations.
17
+ - `preserve`: keep existing paths working and migrate callers before
18
+ removing anything.
19
+
20
+ Under `remove`, these surfaces are still preserved and migrated, never
21
+ deleted, because their callers are not yours to update:
22
+ - the public CLI surface — command names, flags, and their output contracts
23
+ - MCP tool names and input schemas
24
+ - database, config, and on-disk state schemas, including their migrations
25
+ - exported types and public module entry points
26
+
27
+ 2. **Choose the simplest implementation that fully meets the current
28
+ requirements.** Avoid speculative abstractions, configuration, and
29
+ indirection.
30
+
31
+ 3. **Grow the system in layers.** Start from the smallest version that works end
32
+ to end, and add each new capability on top of a product that already works.
33
+ Never trade a working product for unfinished complexity.
34
+
35
+ 4. **Keep components modular and concerns clearly separated.**
36
+
37
+ 5. **Prefer established, well-maintained libraries** when they reduce overall
38
+ complexity or improve reliability. Do not reimplement common functionality
39
+ without a clear reason.
40
+
41
+ 6. **Lean on the dependencies already in the project** before writing your own
42
+ implementation or adding packages. Do not assume a library lacks a capability
43
+ without checking its documentation and types.
44
+
45
+ 7. **Make architectural decisions for the long term.** Do not accept a stopgap
46
+ that only works for now and is meant to be replaced later.
47
+
48
+ 8. **Study how established products solve the problem before designing a
49
+ solution.** Adopt their proven patterns and conventions rather than inventing
50
+ an approach from scratch.
51
+
52
+ ## Why
53
+
54
+ Adapted from an AGENTS.md distilled from roughly 60B tokens of agent-driven
55
+ coding (x.com/MarcosHernanz/status/2083954734487212511). Its author scopes it to
56
+ side projects — "don't use it in production if you don't want to destroy your
57
+ codebase" — and rule 1 is why: deleting obsolete paths is right when you own
58
+ every caller and destructive when you do not. Rather than adopt or drop rule 1
59
+ wholesale, UAP resolves it per project per session and carves out the surfaces
60
+ other people are bound to.
61
+
62
+ The remaining rules are direction-of-travel guidance, not gates. They are
63
+ RECOMMENDED because a machine cannot tell a speculative abstraction from a
64
+ necessary one, and a blocking check that guesses would cost more than it saves.
65
+
66
+ ## Enforcement
67
+
68
+ Advisory. There is no Python enforcer: these are judgment calls, and blocking
69
+ gates in this repo have repeatedly cost more in false positives than they
70
+ prevented. The principles reach the model three other ways:
71
+
72
+ - **Deliver prompts** — the compact form is injected into every convergence-loop
73
+ and orchestrated task prompt, so generated code is held to them rather than
74
+ only the agent's preamble.
75
+ - **Reactor** — while the rule-1 stance is unresolved, the agent is told to ask
76
+ the user once; after that it stays silent.
77
+ - **Judge** — competing candidates are scored on reuse-over-reimplementation and
78
+ absence of stopgaps.
79
+
80
+ Related: pattern P38 (Prior Art First) covers rules 5, 6 and 8 at plan time; the
81
+ `/simplify` and `distill` skills cover rule 2 on existing code.
82
+
83
+ ```rules
84
+ - title: "Simplest implementation that meets the requirement"
85
+ keywords: [implement, refactor, design, abstraction, config, indirection]
86
+ antiPatterns: [speculative-abstraction, premature-config, needless-indirection]
87
+ - title: "Reuse before reimplementation"
88
+ keywords: [library, dependency, package, util, helper, from scratch]
89
+ antiPatterns: [reimplemented-common-functionality, unchecked-library-capability]
90
+ - title: "No stopgaps"
91
+ keywords: [temporary, for now, placeholder, replace later, quick fix]
92
+ antiPatterns: [stopgap-architecture, deferred-rewrite]
93
+ ```
@@ -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()