@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +14 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/principles.d.ts +10 -0
- package/dist/cli/principles.d.ts.map +1 -0
- package/dist/cli/principles.js +138 -0
- package/dist/cli/principles.js.map +1 -0
- package/dist/config/policy-recommendations.d.ts.map +1 -1
- package/dist/config/policy-recommendations.js +17 -0
- package/dist/config/policy-recommendations.js.map +1 -1
- package/dist/config/settings-registry.d.ts +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +24 -0
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +15 -0
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +7 -1
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +32 -4
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/judge.d.ts.map +1 -1
- package/dist/delivery/judge.js +6 -0
- package/dist/delivery/judge.js.map +1 -1
- package/dist/delivery/task-orchestrator.d.ts.map +1 -1
- package/dist/delivery/task-orchestrator.js +8 -1
- package/dist/delivery/task-orchestrator.js.map +1 -1
- package/dist/principles/config.d.ts +10 -0
- package/dist/principles/config.d.ts.map +1 -0
- package/dist/principles/config.js +32 -0
- package/dist/principles/config.js.map +1 -0
- package/dist/principles/index.d.ts +16 -0
- package/dist/principles/index.d.ts.map +1 -0
- package/dist/principles/index.js +34 -0
- package/dist/principles/index.js.map +1 -0
- package/dist/principles/reactor-inject.d.ts +12 -0
- package/dist/principles/reactor-inject.d.ts.map +1 -0
- package/dist/principles/reactor-inject.js +69 -0
- package/dist/principles/reactor-inject.js.map +1 -0
- package/dist/principles/render.d.ts +14 -0
- package/dist/principles/render.d.ts.map +1 -0
- package/dist/principles/render.js +64 -0
- package/dist/principles/render.js.map +1 -0
- package/dist/principles/rules.d.ts +53 -0
- package/dist/principles/rules.d.ts.map +1 -0
- package/dist/principles/rules.js +101 -0
- package/dist/principles/rules.js.map +1 -0
- package/dist/principles/stance.d.ts +54 -0
- package/dist/principles/stance.d.ts.map +1 -0
- package/dist/principles/stance.js +154 -0
- package/dist/principles/stance.js.map +1 -0
- package/dist/types/config.d.ts +46 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +17 -0
- package/dist/types/config.js.map +1 -1
- package/docs/guides/POLICIES.md +43 -0
- package/docs/reference/CONFIGURATION_REFERENCE.md +221 -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/src/policies/schemas/policies/engineering-principles.md +93 -0
- 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
|
@@ -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)
|
|
@@ -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
|
+
```
|
|
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()
|