@miller-tech/uap 1.184.1 → 1.184.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -194,43 +194,52 @@ the next tool call.
194
194
 
195
195
  ## Changing an enforcer's code
196
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.
197
+ Three things trip people up here, and all of them fail *silently* — the source
198
+ looks fixed, `uap policy install` prints success, and the gate goes on
199
+ enforcing the old behaviour.
199
200
 
200
201
  **1. The gate does not run `src/policies/enforcers/*.py`.** It runs
201
202
  `.policy-tools/<policyId>_<toolName>.py`, a separate materialized copy (plus a
202
203
  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:
204
+ nothing on its own — the executable copy has to be refreshed by
205
+ `uap policy install <slug>`.
206
+
207
+ **2. `uap policy install` reads the enforcer from the RUNNING PACKAGE, not from
208
+ your repo.** `resolvePolicyDir()` resolves `src/policies/enforcers/` relative to
209
+ the installed package first, and only falls back to `process.cwd()` — deliberately,
210
+ so that `uap` works in projects that are not this repo. When `uap` is the global
211
+ install, editing this repo's enforcer and running `uap policy install` copies the
212
+ **global package's** version, and the message still says
213
+ "attached enforcer … from src/policies/enforcers/…".
214
+
215
+ So an enforcer change reaches the running gate only after the package the `uap`
216
+ binary comes from contains it:
205
217
 
206
218
  ```bash
219
+ which uap # /usr/local/bin/uap -> the global install?
220
+ uap --version # does it match this repo's package.json?
221
+
222
+ npm i -g . # from the repo, after the fix is merged
207
223
  uap policy install workdir-scope
208
224
  grep -l _my_new_function .policy-tools/*workdir_scope.py # verify it took
209
225
  ```
210
226
 
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.
227
+ That last `grep` is the only real confirmation. A byte-size comparison against
228
+ the enforcer source works too: a mismatch means the copy came from somewhere else.
221
229
 
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.
230
+ **3. Run the install from the MAIN checkout, not a worktree.** The gate anchors
231
+ runtime state `policies.db` and `.policy-tools/` to `MAIN_ROOT`, so every
232
+ worktree enforces the same policies. Run from inside a worktree, the install
233
+ writes a worktree-local `.policy-tools/` that the gate never reads.
227
234
 
228
- So an enforcer fix is two separate steps in two different directories:
235
+ Put together, an enforcer fix is three steps in two directories:
229
236
 
230
237
  - 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.
238
+ - after it merges, update the package the `uap` binary resolves to
239
+ (`npm i -g .` from the main checkout, or install the published version);
240
+ - run `uap policy install <slug>` **from the main checkout**, and verify the
241
+ materialized copy actually changed.
233
242
 
234
243
  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.
244
+ outright, with no model-reachable bypass. An agent cannot make any of these
245
+ changes — 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.1",
3
+ "version": "1.184.3",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,8 @@ from pathlib import Path
27
27
 
28
28
  sys.path.insert(0, str(Path(__file__).parent))
29
29
  from _common import ( # noqa: E402
30
- emit, parse_cli, worktree_root, run, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
30
+ emit, parse_cli, worktree_root, run, strip_heredoc_bodies,
31
+ REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
31
32
  )
32
33
 
33
34
  # Ship verbs are anchored to their tool prefix so that the bare tokens "merge"
@@ -39,6 +40,133 @@ SHIP_PATTERNS = (
39
40
  re.compile(r"\b(pr[-_ ]?ready|sign[-_ ]?off|ready[-_ ]for[-_ ]review)\b", re.I),
40
41
  )
41
42
 
43
+ # Additional, command-position detection: `git -C <path> push` is a real ship
44
+ # action that the patterns above never matched (they require git and the
45
+ # subcommand to be adjacent). Unioned with them, so it only ever ADDS coverage.
46
+ GIT_SHIP_SUBCOMMANDS = frozenset({"commit", "push", "merge"})
47
+ GIT_VALUE_OPTIONS = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace"})
48
+ WRAPPER_VERBS = frozenset({
49
+ "rtk", "env", "nohup", "sudo", "time", "command", "timeout", "stdbuf",
50
+ })
51
+ _SEGMENT_SPLIT = re.compile(r"(?:\|\||&&|[;\n|&])")
52
+ _ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*$")
53
+
54
+
55
+ # Verbs that PRINT or SEARCH their arguments rather than executing them.
56
+ # Quoted text is treated as prose only for these.
57
+ #
58
+ # An allowlist, deliberately. The inverse — masking by default and listing
59
+ # the executors to exempt — cannot be completed: bash -c, python -c, perl -e,
60
+ # su -c, script -qc, fish -c, parallel, expect ... every review round found
61
+ # another, and `script -qc 'git push' /dev/null` really does ship. An
62
+ # unrecognised verb here simply scans the raw text, which is the behaviour
63
+ # this enforcer always had, so being wrong about one costs nothing.
64
+ INERT_VERBS = frozenset({
65
+ "echo", "printf", "cat", "head", "tail", "less", "more",
66
+ "grep", "egrep", "fgrep", "rg", "ag", "ack",
67
+ "wc", "sort", "uniq", "comm", "diff", "cut", "column",
68
+ "ls", "basename", "dirname", "date", "jq", "true", "false",
69
+ })
70
+
71
+
72
+ def _mask_prose(text: str) -> str:
73
+ """`text` with the CONTENT of quoted spans blanked.
74
+
75
+ Quoted text is where prose lives: a commit message, an echoed string, a
76
+ grep pattern. Blanking it stops a mere MENTION of a ship verb from being
77
+ read as one.
78
+
79
+ Returns the text UNCHANGED when the quoting is not simple enough to model
80
+ (an unterminated quote). For this gate the safe direction is to
81
+ over-detect: a review demanded unnecessarily is a nuisance, a ship that
82
+ slips past ungated is the failure this enforcer exists to prevent.
83
+ """
84
+ out = list(text)
85
+ quote = None
86
+ i = 0
87
+ while i < len(text):
88
+ ch = text[i]
89
+ if quote is None:
90
+ if ch == "\\":
91
+ i += 2 # escaped char cannot open a quote
92
+ continue
93
+ if ch in ("'", '"'):
94
+ quote = ch
95
+ elif ch == quote:
96
+ quote = None
97
+ else:
98
+ out[i] = " "
99
+ i += 1
100
+ return text if quote is not None else "".join(out)
101
+
102
+
103
+ def _ships_at_command_position(text: str) -> bool:
104
+ """True when a segment's VERB is git with a ship subcommand.
105
+
106
+ Only used to add `git -C <path> push`; the patterns carry the rest.
107
+ """
108
+ for segment in _SEGMENT_SPLIT.split(text):
109
+ try:
110
+ tokens = shlex.split(segment.strip(), comments=True)
111
+ except ValueError:
112
+ tokens = segment.split()
113
+ while tokens and (_ENV_ASSIGN.match(tokens[0])
114
+ or os.path.basename(tokens[0]).lower() in WRAPPER_VERBS):
115
+ tokens = tokens[1:]
116
+ if not tokens or os.path.basename(tokens[0]).lower() != "git":
117
+ continue
118
+ rest, i = tokens[1:], 0
119
+ while i < len(rest) and rest[i].startswith("-"):
120
+ i += 2 if rest[i] in GIT_VALUE_OPTIONS else 1
121
+ if i < len(rest) and rest[i].lower() in GIT_SHIP_SUBCOMMANDS:
122
+ return True
123
+ return False
124
+
125
+
126
+ def _only_inert_verbs(text: str) -> bool:
127
+ """True when every segment's verb merely prints or searches its arguments.
128
+
129
+ Only then is quoted text safely prose. `hands_text_to_shell` still decides
130
+ the heredoc question upstream; this decides the quoting one.
131
+ """
132
+ saw = False
133
+ for segment in _SEGMENT_SPLIT.split(text):
134
+ try:
135
+ tokens = shlex.split(segment.strip(), comments=True)
136
+ except ValueError:
137
+ return False # unlexable: do not mask
138
+ while tokens and (_ENV_ASSIGN.match(tokens[0])
139
+ or os.path.basename(tokens[0]).lower() in WRAPPER_VERBS):
140
+ tokens = tokens[1:]
141
+ if not tokens:
142
+ continue
143
+ if os.path.basename(tokens[0]).lower() not in INERT_VERBS:
144
+ return False
145
+ saw = True
146
+ return saw
147
+
148
+
149
+ def is_ship_action(command: str) -> bool:
150
+ """True when the command line PERFORMS a ship action.
151
+
152
+ The patterns used to run against the raw string, so any text that merely
153
+ NAMED a ship verb tripped the gate — a quoted string, a grep pattern, a
154
+ heredoc body. It self-deadlocked too: writing this gate's own review
155
+ artifact was refused because the notes described the bug being fixed.
156
+
157
+ So the prose is removed before matching, rather than the matching being
158
+ replaced. `git commit -m 'mentions gh pr merge'` is still a ship action —
159
+ the commit is outside the quotes.
160
+ """
161
+ text = strip_heredoc_bodies(command or "")
162
+ # Quoted text counts as prose only under a verb that prints or searches it
163
+ # (`echo 'git push'`). Under anything else — a shell, an interpreter, or
164
+ # something nobody listed — the raw text is scanned, as it always was.
165
+ scan = _mask_prose(text) if _only_inert_verbs(text) else text
166
+ if any(p.search(scan) for p in SHIP_PATTERNS):
167
+ return True
168
+ return _ships_at_command_position(text)
169
+
42
170
  # A ship action that NAMES a pull request. The review that matters is the one
43
171
  # for that PR's head branch, which is usually not the branch the shell is on.
44
172
  PR_SHIP_VERBS = ("merge", "ready")
@@ -223,7 +351,7 @@ def main() -> None:
223
351
  # enforcement-self-protect also lists this flag among the bypasses the agent
224
352
  # may not set, so an inline attempt is refused with an explicit message
225
353
  # instead of appearing to work.
226
- if not any(p.search(cmd) for p in SHIP_PATTERNS):
354
+ if not is_ship_action(cmd):
227
355
  emit(True, "not a ship action")
228
356
 
229
357
  # Resolve against the WORKING TREE the operation runs in (the worktree when a
@@ -26,6 +26,7 @@ ENFORCER = (
26
26
 
27
27
  PR_NUMBER = "645"
28
28
  VERB = "merge"
29
+ DQ, SQ = chr(34), chr(39)
29
30
  PR_BRANCH = "feature/160-plan-gate-writers"
30
31
  PR_SHA = "dd5ce346281d720dff357d4a649d7389e65bfd61"
31
32
  STALE_SHA = "2f1b660f" + "0" * 32
@@ -197,5 +198,190 @@ class TestExpertReviewPrScope(unittest.TestCase):
197
198
  self.assertIn("master", out.get("reason", ""))
198
199
 
199
200
 
201
+ class TestShipDetectionIsCommandPosition(unittest.TestCase):
202
+ """A ship verb in PROSE is not a ship action.
203
+
204
+ The patterns used to be searched over the whole command string, so any text
205
+ that merely named a ship verb tripped the gate — a quoted string, a grep
206
+ pattern, a heredoc body. It self-deadlocked too: writing this gate's own
207
+ review artifact was refused because the notes described the bug being fixed.
208
+
209
+ The risk in the fix is UNDER-detection, so the ship cases below matter more
210
+ than the prose ones. `rtk git push` especially: this repo requires git to be
211
+ invoked through rtk, so a verb check that stopped at the wrapper would miss
212
+ every real ship command in the codebase.
213
+ """
214
+
215
+ def setUp(self):
216
+ self._tmp = tempfile.TemporaryDirectory()
217
+ base = Path(self._tmp.name)
218
+ self.root = base / "repo"
219
+ self.bin = base / "bin"
220
+ self.root.mkdir()
221
+ self.bin.mkdir()
222
+ self._write_stub_gh()
223
+ self._init_repo()
224
+ # Deliberately NO review artifact: a ship action must be refused, and a
225
+ # non-ship command must sail past with "not a ship action".
226
+
227
+ def tearDown(self):
228
+ self._tmp.cleanup()
229
+
230
+ _write_stub_gh = TestExpertReviewPrScope._write_stub_gh
231
+ _init_repo = TestExpertReviewPrScope._init_repo
232
+ _run = TestExpertReviewPrScope._run
233
+
234
+ def assert_ship(self, command):
235
+ out, code = self._run(command)
236
+ self.assertFalse(out.get("allowed"), f"{command!r} should be gated as a ship action")
237
+ self.assertEqual(code, 2, command)
238
+
239
+ def assert_not_ship(self, command):
240
+ out, code = self._run(command)
241
+ self.assertTrue(out.get("allowed"), f"{command!r} should not be a ship action")
242
+ self.assertIn("not a ship action", out.get("reason", ""), command)
243
+
244
+ # --- real ship actions must STILL be gated ---
245
+
246
+ def test_plain_git_push_is_a_ship_action(self):
247
+ self.assert_ship("git push origin master")
248
+
249
+ def test_rtk_wrapped_git_is_a_ship_action(self):
250
+ # The mandated form in this repo. Missing it would silently ungate
251
+ # every push and commit made here.
252
+ self.assert_ship("rtk git push origin master")
253
+
254
+ def test_ship_action_after_a_chained_command(self):
255
+ self.assert_ship("cd sub && git commit -m x")
256
+
257
+ def test_git_global_option_before_the_subcommand(self):
258
+ # `git -C <path> push` — not caught by the old pattern at all.
259
+ self.assert_ship("git -C /repo push")
260
+
261
+ def test_gh_pr_merge_is_a_ship_action(self):
262
+ self.assert_ship("gh pr merge 645 --squash")
263
+
264
+ # --- prose that merely NAMES a ship verb must not be ---
265
+
266
+ def test_quoted_ship_verb_is_prose(self):
267
+ self.assert_not_ship("echo 'gh pr merge 645'")
268
+
269
+ def test_grep_pattern_is_prose(self):
270
+ self.assert_not_ship("grep -r 'git push' docs/")
271
+
272
+ def test_heredoc_body_is_data_not_commands(self):
273
+ # The exact self-deadlock: writing the review artifact whose notes
274
+ # describe the gate being fixed.
275
+ self.assert_not_ship(
276
+ "python3 - <<'PY'\n"
277
+ "notes = 'refused because the notes mention gh pr merge'\n"
278
+ "print(notes)\n"
279
+ "PY"
280
+ )
281
+
282
+ # --- forms a command-position-only check LOSES ---
283
+ #
284
+ # A first attempt at this fix replaced the patterns with a verb-at-position-0
285
+ # check, copying self-protect. Measured against the old patterns it dropped
286
+ # 14 real detections, every one of them below: the loose patterns caught
287
+ # these by accident and the verb check does not. A gate that stops
288
+ # recognising a ship action stops gating, which is strictly worse than the
289
+ # false positives being fixed — so these are pinned.
290
+
291
+ def test_wrapper_with_its_own_argument_still_ships(self):
292
+ # The wrapper takes an argument, so the wrapped verb is not token 1.
293
+ self.assert_ship("timeout 30 git push")
294
+ self.assert_ship("sudo -u someone git push")
295
+
296
+ def test_subshell_and_brace_group_still_ship(self):
297
+ self.assert_ship("(git push)")
298
+ self.assert_ship("{ git push; }")
299
+
300
+ def test_negated_command_still_ships(self):
301
+ self.assert_ship("! git push")
302
+
303
+ def test_shell_exec_string_still_ships(self):
304
+ # The ship verb is inside quotes, but `bash -c` makes that text a
305
+ # command — which is exactly what separates it from `echo 'git push'`.
306
+ self.assert_ship("bash -c 'git push'")
307
+ self.assert_ship("sh -c 'git push'")
308
+
309
+ def test_xargs_and_find_exec_still_ship(self):
310
+ self.assert_ship("echo origin | xargs git push")
311
+ self.assert_ship("find . -exec git push ;")
312
+
313
+ def test_leading_redirect_still_ships(self):
314
+ # Bash allows a redirection before the command word.
315
+ self.assert_ship("> /tmp/uap-out.log git push")
316
+
317
+ def test_commit_message_naming_other_ship_verbs_still_ships(self):
318
+ # The quotes hold prose, but `git commit` is outside them.
319
+ self.assert_ship("git commit -m 'explain gh pr merge behaviour'")
320
+
321
+ # --- an interpreter's -c/-e payload is CODE, not prose ---
322
+ #
323
+ # Masking quoted spans assumed quoted text is inert. That holds for `echo`
324
+ # and fails for every interpreter: each command below really pushed in a
325
+ # throwaway repo (the bare origin gained the commit) while an earlier
326
+ # version of this fix blanked the payload and let it through.
327
+
328
+ def test_python_payload_that_shells_out_is_a_ship_action(self):
329
+ self.assert_ship(
330
+ "python3 -c " + DQ + "import os; os.system(" + SQ + "git push" + SQ + ")" + DQ
331
+ )
332
+
333
+ def test_perl_payload_that_shells_out_is_a_ship_action(self):
334
+ self.assert_ship("perl -e " + DQ + "system(" + SQ + "git push" + SQ + ")" + DQ)
335
+
336
+ def test_node_payload_that_shells_out_is_a_ship_action(self):
337
+ self.assert_ship(
338
+ "node -e " + DQ + "require(" + SQ + "child_process" + SQ
339
+ + ").execSync(" + SQ + "git push" + SQ + ")" + DQ
340
+ )
341
+
342
+ def test_an_inert_interpreter_payload_is_over_detected_on_purpose(self):
343
+ # `print('git push')` ships nothing, but is indistinguishable from
344
+ # `os.system('git push')` without running the interpreter. Gated on
345
+ # purpose: an unnecessary review is a nuisance, an ungated ship is not.
346
+ self.assert_ship("python3 -c " + DQ + "print(" + SQ + "git push" + SQ + ")" + DQ)
347
+
348
+ # --- executors nobody thought to list ---
349
+ #
350
+ # Quoting used to be masked by default, with an exemption list for things
351
+ # that execute: bash -c, then python/perl/node, and `script -qc` still got
352
+ # through — it really ships (a throwaway bare repo gained the commit). su,
353
+ # fish, expect, parallel and watch sat behind it. A denylist of executors
354
+ # cannot be completed, so masking is now allowlisted to verbs that print or
355
+ # search their arguments, and anything unrecognised scans the raw text.
356
+ #
357
+ # These cases exist to keep that inversion from being quietly undone: they
358
+ # pass because `script`/`su`/`fish` are NOT on the inert allowlist, not
359
+ # because anyone enumerated them.
360
+
361
+ def test_script_wrapped_ship_is_detected(self):
362
+ self.assert_ship("script -qc " + SQ + "git push" + SQ + " /dev/null")
363
+
364
+ def test_su_wrapped_ship_is_detected(self):
365
+ self.assert_ship("su -c " + SQ + "git push" + SQ)
366
+
367
+ def test_unlisted_shell_wrapped_ship_is_detected(self):
368
+ self.assert_ship("fish -c " + SQ + "git push" + SQ)
369
+ self.assert_ship("expect -c " + SQ + "git push" + SQ)
370
+
371
+ def test_remote_execution_is_detected(self):
372
+ # Ships on the far side; gated on purpose.
373
+ self.assert_ship("ssh somehost " + SQ + "git push" + SQ)
374
+
375
+ def test_inert_verbs_still_treat_quotes_as_prose(self):
376
+ # The allowlist half: these must stay unblocked, or the false positives
377
+ # this branch exists to fix come straight back.
378
+ self.assert_not_ship("printf " + SQ + "run gh pr merge later" + SQ)
379
+ self.assert_not_ship("cat docs/merge-strategy.md")
380
+
381
+ def test_read_only_git_is_not_a_ship_action(self):
382
+ self.assert_not_ship("git diff --merge-base main")
383
+ self.assert_not_ship("git status --short")
384
+
385
+
200
386
  if __name__ == "__main__":
201
387
  unittest.main()