@miller-tech/uap 1.185.1 → 1.186.0
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/policy.d.ts.map +1 -1
- package/dist/cli/policy.js +56 -0
- package/dist/cli/policy.js.map +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +5 -1
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/integrity/enforcer-manifest.d.ts +46 -0
- package/dist/integrity/enforcer-manifest.d.ts.map +1 -0
- package/dist/integrity/enforcer-manifest.js +145 -0
- package/dist/integrity/enforcer-manifest.js.map +1 -0
- package/dist/policies/policy-tools.d.ts.map +1 -1
- package/dist/policies/policy-tools.js +9 -0
- package/dist/policies/policy-tools.js.map +1 -1
- package/dist/types/config.d.ts +93 -93
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +12 -3
- package/dist/types/config.js.map +1 -1
- package/docs/getting-started/CONFIGURATION.md +1 -1
- package/docs/reference/CONFIGURATION.md +1 -1
- package/docs/reference/CONFIGURATION_REFERENCE.md +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +149 -24
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +99 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_gate_failclosed_indirection.py +383 -0
- package/tools/agents/tests/test_gate_integrity.py +180 -0
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
27
27
|
from _common import ( # noqa: E402
|
|
28
28
|
emit, parse_cli, repo_root, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
|
|
29
|
+
hands_text_to_shell,
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
@@ -128,56 +129,180 @@ _QUOTES = str.maketrans("", "", "\"'")
|
|
|
128
129
|
|
|
129
130
|
|
|
130
131
|
def _mentions_protected(text: str) -> bool:
|
|
131
|
-
"""True when
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# Word-ish boundary so `.uap` matches `.uap`, `.uap/x` and `.uap.bak`
|
|
147
|
-
# but not an unrelated longer name like `.uapkeep`.
|
|
148
|
-
for m in re.finditer(re.escape(t), low):
|
|
149
|
-
tail = low[m.end():m.end() + 1]
|
|
150
|
-
if tail in ("", "/", ".", " ", '"', "'", "*"):
|
|
132
|
+
"""True when any UNIT of `text` names a protected path.
|
|
133
|
+
|
|
134
|
+
Judged unit by unit, not over the whole blob. The exemption used to be
|
|
135
|
+
evaluated across the entire text, so one `policies/waivers` anywhere made
|
|
136
|
+
everything else invisible — appending a single innocuous line to a deletion
|
|
137
|
+
list defeated the check completely (found by the review, reproduced).
|
|
138
|
+
"""
|
|
139
|
+
for unit in text.translate(_QUOTES).lower().replace("./", "/").split():
|
|
140
|
+
if any(ex in unit for ex in PROTECTED_EXEMPT):
|
|
141
|
+
continue
|
|
142
|
+
# The .uap DIRECTORY ITSELF: `rm -rf .uap` destroys the manifest, while
|
|
143
|
+
# `echo 0 > .uap/verify-cadence` names a deeper path and is ordinary
|
|
144
|
+
# tooling. Bare directory protected; a file under it is not.
|
|
145
|
+
for m in re.finditer(re.escape(".uap"), unit):
|
|
146
|
+
if unit[m.end():m.end() + 1] in ("", " ", '"', "'", "*"):
|
|
151
147
|
return True
|
|
148
|
+
for target in PROTECTED_TARGETS:
|
|
149
|
+
t = target.lower()
|
|
150
|
+
for m in re.finditer(re.escape(t), unit):
|
|
151
|
+
if unit[m.end():m.end() + 1] in ("", "/", ".", " ", '"', "'", "*"):
|
|
152
|
+
return True
|
|
152
153
|
return False
|
|
153
154
|
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
# An argument list is a list of paths; it is never a megabyte. The old 1 MiB cap
|
|
157
|
+
# also SKIPPED anything larger, so padding a list file past the cap was a
|
|
158
|
+
# one-line bypass. Read a bounded prefix instead of skipping.
|
|
159
|
+
_MAX_SRC_BYTES = 1 << 16
|
|
160
|
+
_MAX_SOURCES = 32
|
|
161
|
+
# Commands whose arguments are literal paths we can read now.
|
|
162
|
+
_FILE_READERS = ("cat", "head", "tail", "sort", "uniq", "cut", "tr", "nl", "rev")
|
|
163
|
+
_LITERAL_EMITTERS = ("echo", "printf")
|
|
164
|
+
# Wrappers that hand their remaining words back to a shell as a command.
|
|
165
|
+
_WRAPPERS = ("eval", "env", "exec", "source", ".")
|
|
166
|
+
_SHELL_C = re.compile(
|
|
167
|
+
r"(?:^|[\s;|&(])(?:ba|z|k|da|a)?sh\s+(?:-\w+\s+)*-\w*c\s+(['\"])(.*?)\1", re.S)
|
|
168
|
+
_REDIRECT = re.compile(r">>?")
|
|
169
|
+
_SUBST_FILE = re.compile(r"\$\(\s*(?:cat\s+)?<?\s*([^\s)]+)[^)]*\)|`\s*cat\s+([^`]+)`")
|
|
170
|
+
_ARGFILE = re.compile(r"--arg-file=([^\s;|&]+)|(?:^|\s)-a\s+([^\s;|&]+)")
|
|
171
|
+
_STDIN_REDIR = re.compile(r"<\s*([^\s;|&<>]+)")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read_source(tok: str) -> str:
|
|
175
|
+
"""A bounded prefix of `tok` if it is a readable regular file, else ""."""
|
|
176
|
+
tok = (tok or "").strip("\"'`$()").rstrip(";|&")
|
|
177
|
+
if not tok or tok.startswith("-"):
|
|
178
|
+
return ""
|
|
179
|
+
try:
|
|
180
|
+
p = Path(tok)
|
|
181
|
+
if not p.is_file(): # excludes FIFOs and devices: no blocking read
|
|
182
|
+
return ""
|
|
183
|
+
with p.open(errors="replace") as fh:
|
|
184
|
+
return fh.read(_MAX_SRC_BYTES)
|
|
185
|
+
except (OSError, ValueError):
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolved_arguments(command: str) -> list[str]:
|
|
190
|
+
"""Text that will actually REACH a command as arguments.
|
|
191
|
+
|
|
192
|
+
Only sources knowable right now: a `< file` redirect, xargs --arg-file/-a,
|
|
193
|
+
$(cat f)/`cat f`, and an upstream pipe stage that is a literal emitter
|
|
194
|
+
(echo/printf) or a file reader (cat/head/tail/...).
|
|
195
|
+
|
|
196
|
+
Deliberately NOT resolved: `grep … | xargs sed`. grep's output is unknown
|
|
197
|
+
here, and inferring it from the pattern text is exactly what made ordinary
|
|
198
|
+
refactors unrunnable. Unknowable means allow — the same call made for a path
|
|
199
|
+
held in a shell variable.
|
|
200
|
+
"""
|
|
201
|
+
out: list[str] = []
|
|
202
|
+
|
|
203
|
+
def add(text: str) -> None:
|
|
204
|
+
if text and len(out) < _MAX_SOURCES:
|
|
205
|
+
out.append(text)
|
|
206
|
+
|
|
207
|
+
for m in _STDIN_REDIR.finditer(command):
|
|
208
|
+
add(_read_source(m.group(1)))
|
|
209
|
+
for m in _ARGFILE.finditer(command):
|
|
210
|
+
add(_read_source(m.group(1) or m.group(2)))
|
|
211
|
+
for m in _SUBST_FILE.finditer(command):
|
|
212
|
+
add(_read_source(m.group(1) or m.group(2)))
|
|
213
|
+
|
|
214
|
+
stages = [s.strip() for s in command.split("|") if s.strip()]
|
|
215
|
+
for stage in stages[:-1]: # producers only
|
|
216
|
+
toks = [t for t in stage.split() if not _ENV_ASSIGN.match(t)]
|
|
217
|
+
if not toks:
|
|
218
|
+
continue
|
|
219
|
+
verb = toks[0].rsplit("/", 1)[-1].lower()
|
|
220
|
+
if verb in _LITERAL_EMITTERS:
|
|
221
|
+
add(" ".join(toks[1:]))
|
|
222
|
+
elif verb in _FILE_READERS:
|
|
223
|
+
for t in toks[1:]:
|
|
224
|
+
if not t.startswith("-"):
|
|
225
|
+
add(_read_source(t))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _inner_commands(command: str) -> list[str]:
|
|
230
|
+
"""Command strings this command hands back to a shell to execute."""
|
|
231
|
+
out = [m.group(2) for m in _SHELL_C.finditer(command or "")]
|
|
232
|
+
for segment in _SEGMENT_SPLIT.split(command or ""):
|
|
233
|
+
toks = [t for t in segment.split() if not _ENV_ASSIGN.match(t)]
|
|
234
|
+
if toks and toks[0].rsplit("/", 1)[-1].lower() in _WRAPPERS:
|
|
235
|
+
rest = " ".join(toks[1:]).strip().strip("\"'")
|
|
236
|
+
if rest:
|
|
237
|
+
out.append(rest)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _destructive_intent(command: str) -> bool:
|
|
242
|
+
"""A destructive verb or a redirect appears somewhere in `command`."""
|
|
243
|
+
toks = {t.rsplit("/", 1)[-1].lower().strip("\"'") for t in command.split()}
|
|
244
|
+
return bool(toks & set(DESTRUCTIVE_VERBS)) or bool(_REDIRECT.search(command))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _direct_destructive(command: str) -> bool:
|
|
248
|
+
"""Destructive op naming a protected path, judged per command segment."""
|
|
249
|
+
cd_into_protected = False
|
|
157
250
|
for segment in _SEGMENT_SPLIT.split(command or ""):
|
|
158
251
|
seg = segment.strip()
|
|
159
252
|
if not seg:
|
|
160
253
|
continue
|
|
161
254
|
# A redirect writes just as destructively as `rm`; the target may be
|
|
162
255
|
# quoted, ./-prefixed, or fd-numbered (`1> .uap/x`).
|
|
163
|
-
for m in
|
|
256
|
+
for m in _REDIRECT.finditer(seg):
|
|
164
257
|
if _mentions_protected(seg[m.end():]):
|
|
165
258
|
return True
|
|
166
259
|
tokens = [t for t in seg.split() if not _ENV_ASSIGN.match(t)]
|
|
167
260
|
if not tokens:
|
|
168
261
|
continue
|
|
169
262
|
verb = tokens[0].rsplit("/", 1)[-1].lower()
|
|
263
|
+
# `cd .policy-tools && rm -f _common.py` put the protected path in one
|
|
264
|
+
# segment and the verb in another, so neither segment looked dangerous.
|
|
265
|
+
if verb == "cd":
|
|
266
|
+
cd_into_protected = _mentions_protected(" ".join(tokens[1:]))
|
|
267
|
+
continue
|
|
170
268
|
if verb == "git" and len(tokens) > 1:
|
|
171
269
|
verb = f"git {tokens[1].lower()}"
|
|
172
270
|
if verb not in ("git clean", "git checkout"):
|
|
173
271
|
continue
|
|
174
272
|
elif verb not in DESTRUCTIVE_VERBS:
|
|
175
273
|
continue
|
|
176
|
-
if _mentions_protected(" ".join(tokens[1:])):
|
|
274
|
+
if cd_into_protected or _mentions_protected(" ".join(tokens[1:])):
|
|
177
275
|
return True
|
|
178
276
|
return False
|
|
179
277
|
|
|
180
278
|
|
|
279
|
+
def _bash_destructive(command: str, _depth: int = 0) -> bool:
|
|
280
|
+
"""Destructive op against the protected surface, however the target arrives.
|
|
281
|
+
|
|
282
|
+
Three ways a target reaches a verb, all of them observed:
|
|
283
|
+
1. on the command line -> _direct_destructive
|
|
284
|
+
2. through a shell wrapper -> _inner_commands (bash -c, eval, env)
|
|
285
|
+
3. as resolved arguments -> _resolved_arguments (xargs, $(cat))
|
|
286
|
+
|
|
287
|
+
HONEST LIMIT: shell state this process cannot see still wins. `P=.policy-
|
|
288
|
+
tools; rm $P/x` expands inside the shell, and no scan of command TEXT can
|
|
289
|
+
resolve a VALUE. Refusing every destructive command containing a variable
|
|
290
|
+
would block ordinary work for no real gain, so the residual is accepted and
|
|
291
|
+
covered by the gate's fail-closed and the _common.py self-heal.
|
|
292
|
+
"""
|
|
293
|
+
if not command:
|
|
294
|
+
return False
|
|
295
|
+
if _direct_destructive(command):
|
|
296
|
+
return True
|
|
297
|
+
if _depth < 2: # bounded: `bash -c "bash -c ..."`
|
|
298
|
+
for inner in _inner_commands(command):
|
|
299
|
+
if _bash_destructive(inner, _depth + 1):
|
|
300
|
+
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))
|
|
304
|
+
return False
|
|
305
|
+
|
|
181
306
|
OVERRIDE = os.environ.get("UAP_SELF_PROTECT_OFF") == "1"
|
|
182
307
|
|
|
183
308
|
|
|
Binary file
|
|
@@ -117,8 +117,56 @@ markers = ("/.policy-tools/", "/src/policies/", "/policies/", "/.uap.json",
|
|
|
117
117
|
"uap-reactor-prompt.sh", "pre-tool-use")
|
|
118
118
|
target = a.get("file_path") or a.get("path") or a.get("target") or ""
|
|
119
119
|
cmd = a.get("command") or ""
|
|
120
|
+
# Scan the COMMAND as well as file_path. Only file_path was checked, so for
|
|
121
|
+
# every Bash call SEC_SENSITIVE was 0 unless the command set a bypass var --
|
|
122
|
+
# leaving the fail-closed branch below blind to the entire shell surface. A
|
|
123
|
+
# shell deletion under .policy-tools/ then fell through to fail-OPEN the moment
|
|
124
|
+
# the enforcer could not run. Observed live: deleting .policy-tools/_common.py
|
|
125
|
+
# broke every enforcer at import, and the next `>> .uap/evidence/...` was
|
|
126
|
+
# allowed. Widening this only ever TIGHTENS: SEC_SENSITIVE gates the fail-
|
|
127
|
+
# closed path and the fastpath skip, never an allow.
|
|
128
|
+
#
|
|
129
|
+
# Each token gets a leading "/" for the same reason the target does: the markers
|
|
130
|
+
# are slash-anchored ("/.policy-tools/"), so a bare relative path in a command
|
|
131
|
+
# ("rm .policy-tools/x") would not match without it. Concatenating the raw
|
|
132
|
+
# command silently missed exactly the deletions this fix is about -- caught by
|
|
133
|
+
# measuring old-vs-new, not by reading the diff.
|
|
120
134
|
low = ("/" + str(target)).lower()
|
|
121
135
|
hit = any(m in low for m in markers)
|
|
136
|
+
# The command side needs its own pass. Markers are slash-terminated
|
|
137
|
+
# ("/.policy-tools/"), so a plain substring test missed the DIRECTORY forms --
|
|
138
|
+
# `rm -rf .policy-tools` scored 0, i.e. the single most destructive command
|
|
139
|
+
# against the surface did not arm the fail-closed net. Quoted paths missed too.
|
|
140
|
+
# Match on a path-segment boundary instead, per token, quotes stripped.
|
|
141
|
+
if not hit and cmd:
|
|
142
|
+
words = str(cmd).lower().replace("./", "/").split()
|
|
143
|
+
lead = ""
|
|
144
|
+
for w in words:
|
|
145
|
+
if "=" in w and not w.startswith("/"):
|
|
146
|
+
continue
|
|
147
|
+
lead = w.rsplit("/", 1)[-1]
|
|
148
|
+
break
|
|
149
|
+
# A read-only command cannot weaken anything, and arming fail-closed for it
|
|
150
|
+
# turns `cat .uap.json` into a hard block on any checkout where self-protect
|
|
151
|
+
# is not attached -- a state this repo has actually been in.
|
|
152
|
+
readonly = ("cat", "ls", "grep", "rg", "head", "tail", "wc", "jq", "less",
|
|
153
|
+
"stat", "file", "which", "wc")
|
|
154
|
+
if lead not in readonly:
|
|
155
|
+
for w in words:
|
|
156
|
+
u = "/" + w.strip("\"" + chr(39) + "").lstrip("/")
|
|
157
|
+
for mk in markers:
|
|
158
|
+
base = mk.rstrip("/")
|
|
159
|
+
start = u.find(base)
|
|
160
|
+
while start != -1:
|
|
161
|
+
tail = u[start + len(base):start + len(base) + 1]
|
|
162
|
+
if tail in ("", "/", ".", "*"):
|
|
163
|
+
hit = True
|
|
164
|
+
break
|
|
165
|
+
start = u.find(base, start + 1)
|
|
166
|
+
if hit:
|
|
167
|
+
break
|
|
168
|
+
if hit:
|
|
169
|
+
break
|
|
122
170
|
bypass = re.search(
|
|
123
171
|
r"UAP_DELIVER_BYPASS\s*=\s*[\x27\"]?1|UAP_ENFORCE_DELIVERY\s*=\s*[\x27\"]?(advisory|off|0|false|no)"
|
|
124
172
|
r"|UAP_SELF_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_NO_WORKTREE\s*=\s*[\x27\"]?1|UAP_WORKDIR_SCOPE_OFF\s*=\s*[\x27\"]?1|UAP_USER_VALIDATION\s*=\s*[\x27\"]?0",
|
|
@@ -225,6 +273,57 @@ record_execution() {
|
|
|
225
273
|
AND (SELECT COUNT(*) FROM policy_executions) > 2000;" 2>/dev/null || true
|
|
226
274
|
}
|
|
227
275
|
|
|
276
|
+
# INTEGRITY: the gate runs COPIES in .policy-tools/. Verify them against the
|
|
277
|
+
# manifest written at materialization and restore anything changed or missing,
|
|
278
|
+
# BEFORE any enforcer runs. This is the durable control: a text scan over shell
|
|
279
|
+
# commands cannot stop `python3 -c` from rewriting an enforcer (that is allowed
|
|
280
|
+
# by design), but it does not need to if the surface repairs itself on the next
|
|
281
|
+
# call. Covers the two observed failures — a stale copy leaving a merged fix
|
|
282
|
+
# inert, and deleting _common.py to kill all 29 enforcers at import.
|
|
283
|
+
#
|
|
284
|
+
# Cost is one hash pass over ~30 small files. `sha256sum` on Linux, `shasum` on
|
|
285
|
+
# macOS; with neither, verification is skipped rather than blocking work.
|
|
286
|
+
_PT="$MAIN_ROOT/.policy-tools"
|
|
287
|
+
if [[ -f "$_PT/.integrity.sha256" ]]; then
|
|
288
|
+
_SUM=""
|
|
289
|
+
command -v sha256sum >/dev/null 2>&1 && _SUM="sha256sum"
|
|
290
|
+
[[ -z "$_SUM" ]] && command -v shasum >/dev/null 2>&1 && _SUM="shasum -a 256"
|
|
291
|
+
if [[ -n "$_SUM" ]]; then
|
|
292
|
+
# `|| true` is load-bearing: sha256sum exits non-zero when a check fails,
|
|
293
|
+
# and under `set -euo pipefail` that status propagates out of the command
|
|
294
|
+
# substitution and kills the gate — turning a repairable drift into a dead
|
|
295
|
+
# hook. Match only the FAILED lines: they cover BOTH a changed file
|
|
296
|
+
# ("x.py: FAILED") and a missing one ("x.py: FAILED open or read"), while
|
|
297
|
+
# sha256sum's own stderr ("sha256sum: x.py: No such file...") would
|
|
298
|
+
# otherwise be captured with its prefix and restore a file called
|
|
299
|
+
# "sha256sum: x.py".
|
|
300
|
+
_BAD="$( cd "$_PT" && { $_SUM --quiet -c .integrity.sha256 2>/dev/null \
|
|
301
|
+
| sed -n 's/^\(.*\): FAILED.*$/\1/p'; } || true )"
|
|
302
|
+
if [[ -n "$_BAD" ]]; then
|
|
303
|
+
_SRC=""
|
|
304
|
+
[[ -f "$_PT/.integrity.source" ]] && _SRC="$(cat "$_PT/.integrity.source" 2>/dev/null)"
|
|
305
|
+
[[ -d "$_SRC" ]] || _SRC="$MAIN_ROOT/src/policies/enforcers"
|
|
306
|
+
while IFS= read -r _f; do
|
|
307
|
+
[[ -z "$_f" ]] && continue
|
|
308
|
+
# copies are <uuid>_<tool>.py; sources are <tool>.py
|
|
309
|
+
_base="${_f#*_}"; [[ "$_f" == "_common.py" ]] && _base="_common.py"
|
|
310
|
+
[[ -f "$_SRC/$_base" ]] && cp "$_SRC/$_base" "$_PT/$_f" 2>/dev/null || true
|
|
311
|
+
done <<< "$_BAD"
|
|
312
|
+
printf '%s\t%s\n' "$(date +%s)" "restored: $(echo "$_BAD" | tr '\n' ' ')" \
|
|
313
|
+
>> "$MAIN_ROOT/.uap/evidence/integrity.log" 2>/dev/null || true
|
|
314
|
+
fi
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
# Helper fallback for surfaces that predate the manifest.
|
|
318
|
+
if [[ ! -f "$_PT/_common.py" \
|
|
319
|
+
&& -f "$MAIN_ROOT/src/policies/enforcers/_common.py" ]]; then
|
|
320
|
+
cp "$MAIN_ROOT/src/policies/enforcers/_common.py" \
|
|
321
|
+
"$_PT/_common.py" 2>/dev/null || true
|
|
322
|
+
fi
|
|
323
|
+
if [[ ! -f "$MAIN_ROOT/.policy-tools/_common.py" && -d "$MAIN_ROOT/.policy-tools" ]]; then
|
|
324
|
+
[[ "$SEC_SENSITIVE" == "1" ]] && fail_closed "enforcer helper _common.py missing"
|
|
325
|
+
fi
|
|
326
|
+
|
|
228
327
|
# Did the self-protect enforcer actually run and make a decision this call?
|
|
229
328
|
sec_enforcer_ran=0
|
|
230
329
|
|