@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/memory.d.ts.map +1 -1
- package/dist/cli/memory.js +16 -7
- package/dist/cli/memory.js.map +1 -1
- package/dist/memory/backends/qdrant-cloud.d.ts.map +1 -1
- package/dist/memory/backends/qdrant-cloud.js +10 -8
- package/dist/memory/backends/qdrant-cloud.js.map +1 -1
- package/dist/memory/qdrant-search.d.ts +60 -0
- package/dist/memory/qdrant-search.d.ts.map +1 -0
- package/dist/memory/qdrant-search.js +69 -0
- package/dist/memory/qdrant-search.js.map +1 -0
- package/dist/policies/enforced-tool-router.d.ts +10 -2
- package/dist/policies/enforced-tool-router.d.ts.map +1 -1
- package/dist/policies/enforced-tool-router.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +69 -7
- 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_gate_failclosed_indirection.py +59 -0
|
@@ -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
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
303
|
-
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|