@miller-tech/uap 1.186.0 → 1.186.2

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.
@@ -19,6 +19,7 @@ would reopen the hole (the model would just set it). Operator override is
19
19
  out-of-band only: UAP_SELF_PROTECT_OFF=1 in the trusted launch env.
20
20
  """
21
21
  import os
22
+ import posixpath
22
23
  import re
23
24
  import sys
24
25
  from pathlib import Path
@@ -26,7 +27,7 @@ from pathlib import Path
26
27
  sys.path.insert(0, str(Path(__file__).parent))
27
28
  from _common import ( # noqa: E402
28
29
  emit, parse_cli, repo_root, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
29
- hands_text_to_shell,
30
+ hands_text_to_shell, scannable_command,
30
31
  )
31
32
 
32
33
  EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
@@ -136,14 +137,26 @@ def _mentions_protected(text: str) -> bool:
136
137
  everything else invisible — appending a single innocuous line to a deletion
137
138
  list defeated the check completely (found by the review, reproduced).
138
139
  """
139
- for unit in text.translate(_QUOTES).lower().replace("./", "/").split():
140
+ for raw_unit in text.translate(_QUOTES).lower().split():
141
+ # Normalise BEFORE matching. `policies/waivers/../../.policy-tools` was
142
+ # exempt by substring while resolving to a protected path, and the old
143
+ # "./" -> "/" rewrite ran first and mangled the `..` segments so a later
144
+ # normpath could not undo it. normpath subsumes that rewrite: it maps
145
+ # "./.uap" -> ".uap", which is what the rewrite existed to do.
146
+ unit = posixpath.normpath(raw_unit) if "/" in raw_unit else raw_unit
140
147
  if any(ex in unit for ex in PROTECTED_EXEMPT):
141
148
  continue
142
149
  # The .uap DIRECTORY ITSELF: `rm -rf .uap` destroys the manifest, while
143
150
  # `echo 0 > .uap/verify-cadence` names a deeper path and is ordinary
144
151
  # tooling. Bare directory protected; a file under it is not.
145
152
  for m in re.finditer(re.escape(".uap"), unit):
146
- if unit[m.end():m.end() + 1] in ("", " ", '"', "'", "*"):
153
+ rest = unit[m.end():]
154
+ if rest[:1] in ("", " ", '"', "'", "*"):
155
+ return True
156
+ # `rm -rf .uap/` and `rm -rf .uap/*` name the DIRECTORY, and take
157
+ # evidence, reviews and interaction with it. A deeper path
158
+ # (`.uap/verify-cadence`) is ordinary tooling and stays writable.
159
+ if rest.strip("/") in ("", "*"):
147
160
  return True
148
161
  for target in PROTECTED_TARGETS:
149
162
  t = target.lower()
@@ -171,6 +184,30 @@ _ARGFILE = re.compile(r"--arg-file=([^\s;|&]+)|(?:^|\s)-a\s+([^\s;|&]+)")
171
184
  _STDIN_REDIR = re.compile(r"<\s*([^\s;|&<>]+)")
172
185
 
173
186
 
187
+
188
+ _LAUNCHERS = ("nohup", "timeout", "command", "builtin", "setsid", "sudo", "doas",
189
+ "nice", "ionice", "stdbuf", "time", "unbuffer")
190
+ # Producers whose output genuinely cannot be known from the command text.
191
+ _SEARCH_PRODUCERS = ("grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "fd",
192
+ "ls", "comm", "diff", "git", "locate", "which")
193
+
194
+
195
+ def _verb_of(token: str) -> str:
196
+ """The command a token invokes, with quoting and \\-escaping removed."""
197
+ return token.strip("\"'").lstrip("\\").rsplit("/", 1)[-1].lower()
198
+
199
+
200
+ def _has_unknown_producer(command: str) -> bool:
201
+ """True when a pipeline stage feeding a consumer is not a search tool."""
202
+ stages = [s.strip() for s in command.split("|") if s.strip()]
203
+ if len(stages) < 2:
204
+ return False
205
+ for stage in stages[:-1]:
206
+ toks = [t for t in stage.split() if not _ENV_ASSIGN.match(t)]
207
+ if toks and _verb_of(toks[0]) not in _SEARCH_PRODUCERS:
208
+ return True
209
+ return False
210
+
174
211
  def _read_source(tok: str) -> str:
175
212
  """A bounded prefix of `tok` if it is a readable regular file, else ""."""
176
213
  tok = (tok or "").strip("\"'`$()").rstrip(";|&")
@@ -257,9 +294,17 @@ def _direct_destructive(command: str) -> bool:
257
294
  if _mentions_protected(seg[m.end():]):
258
295
  return True
259
296
  tokens = [t for t in seg.split() if not _ENV_ASSIGN.match(t)]
297
+ # Step over launchers: `nohup rm -rf x`, `timeout 5 rm -rf x`,
298
+ # `command rm …`, `sudo rm …` all run rm, but the verb read as the
299
+ # launcher and the removal was invisible. Flags and their values are
300
+ # skipped with them.
301
+ while len(tokens) > 1 and _verb_of(tokens[0]) in _LAUNCHERS:
302
+ tokens = tokens[1:]
303
+ while tokens and (tokens[0].startswith("-") or tokens[0].isdigit()):
304
+ tokens = tokens[1:]
260
305
  if not tokens:
261
306
  continue
262
- verb = tokens[0].rsplit("/", 1)[-1].lower()
307
+ verb = _verb_of(tokens[0])
263
308
  # `cd .policy-tools && rm -f _common.py` put the protected path in one
264
309
  # segment and the verb in another, so neither segment looked dangerous.
265
310
  if verb == "cd":
@@ -298,9 +343,26 @@ def _bash_destructive(command: str, _depth: int = 0) -> bool:
298
343
  for inner in _inner_commands(command):
299
344
  if _bash_destructive(inner, _depth + 1):
300
345
  return True
301
- if (hands_text_to_shell(command) or "$(" in command or "`" in command) \
302
- and _destructive_intent(command):
303
- return any(_mentions_protected(src) for src in _resolved_arguments(command))
346
+ if not (hands_text_to_shell(command) or "$(" in command or "`" in command):
347
+ return False
348
+ sources = _resolved_arguments(command)
349
+ # Intent can live in the SOURCE rather than the outer text: for
350
+ # `bash -c "$(cat f)"` the outer words are just bash, so judging intent on
351
+ # them alone found nothing while f held both the verb and the target.
352
+ for src in sources:
353
+ if _mentions_protected(src) and (
354
+ _destructive_intent(command) or _destructive_intent(src)):
355
+ return True
356
+ if not _destructive_intent(command):
357
+ return False
358
+ # Nothing resolvable. A SEARCH producer really is unknowable here, and
359
+ # guessing from its pattern text is what blocked ordinary refactors
360
+ # (`grep -rl policies/ docs/ | xargs sed -i …`). An arbitrary producer the
361
+ # agent chose is a different matter: `python3 -c 'print(".policy-tools/x")'
362
+ # | xargs rm` carries the path in plain sight. Fall back to the text only
363
+ # when no producer is a search tool.
364
+ if not sources and _has_unknown_producer(command):
365
+ return _mentions_protected(scannable_command(command))
304
366
  return False
305
367
 
306
368
  OVERRIDE = os.environ.get("UAP_SELF_PROTECT_OFF") == "1"
@@ -147,6 +147,65 @@ class TestIndirectionIsNotAnEscape(_Sandbox):
147
147
  self.assertBlocked("chmod 000 .policy-tools/x.py")
148
148
 
149
149
 
150
+ class TestNaiveDirectFormsAreCaught(_Sandbox):
151
+ """The re-review found these still open — in master too, not introduced.
152
+
153
+ They shipped uncovered because the round-3 fixes lived only in a dev harness
154
+ (`patches/169/verify3.py`), which CI never runs. A fix with no test in the
155
+ suite is how this regresses, so the cases move here.
156
+
157
+ None of these is exotic. They are the naive, direct form the enforcer exists
158
+ to catch, hidden behind one token of prefix or one character of suffix.
159
+ """
160
+
161
+ def test_launcher_prefixes_do_not_hide_the_verb(self):
162
+ # `verb` was read as tokens[0] with no launcher handling, so one word
163
+ # in front of the removal made it invisible.
164
+ for cmd in ("nohup rm -rf .policy-tools",
165
+ "timeout 5 rm -rf .policy-tools",
166
+ "sudo rm -rf .policy-tools",
167
+ "command rm -rf .policy-tools"):
168
+ self.assertBlocked(cmd)
169
+
170
+ def test_quoted_verb_is_still_a_verb(self):
171
+ self.assertBlocked("'rm' -rf .policy-tools")
172
+
173
+ def test_the_directory_itself_is_protected(self):
174
+ # A trailing slash was excluded from the boundary set so that
175
+ # `.uap/verify-cadence` stays writable — but `.uap/` names the whole
176
+ # directory, and takes evidence, reviews and interaction with it.
177
+ self.assertBlocked("rm -rf .uap/")
178
+ self.assertBlocked("rm -rf .uap/*")
179
+
180
+ def test_traversal_through_an_exempt_path(self):
181
+ # The exemption was a substring test, so routing through it made the
182
+ # real target invisible while the kernel still resolved the path.
183
+ self.assertBlocked("rm -rf policies/waivers/../../.policy-tools")
184
+
185
+ def test_verb_and_target_both_inside_the_source(self):
186
+ # Destructive intent was judged on the OUTER command text only, so when
187
+ # both lived in the resolved source nothing fired.
188
+ (self.root / "SCRIPT").write_text("rm .policy-tools/_common.py\n")
189
+ self.assertBlocked('bash -c "$(cat SCRIPT)"')
190
+ self.assertBlocked("cat SCRIPT | bash")
191
+
192
+ def test_agent_chosen_producer_is_not_free(self):
193
+ # An unresolvable producer is allowed for a SEARCH tool, because grep's
194
+ # output genuinely cannot be known here. A producer the agent picked is
195
+ # different: the path is sitting in the command text.
196
+ self.assertBlocked("python3 -c 'print(\".policy-tools/x\")' | xargs rm -f")
197
+
198
+ def test_search_producers_stay_allowed(self):
199
+ # The other half: guessing from a grep pattern is what made ordinary
200
+ # refactors unrunnable, and that must not come back.
201
+ self.assertAllowed("grep -rl policies/ docs/ | xargs sed -i s/a/b/")
202
+ self.assertAllowed("git ls-files | xargs wc -l")
203
+
204
+ def test_launchers_on_benign_commands_stay_allowed(self):
205
+ for cmd in ("timeout 30 npm test", "nohup npm run dev", "sudo systemctl restart nginx"):
206
+ self.assertAllowed(cmd)
207
+
208
+
150
209
  class TestOrdinaryWorkIsNotBlocked(_Sandbox):
151
210
  """Over-blocking is the expensive failure: it makes people disable the gate.
152
211