@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.
@@ -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 the text names a protected path and no exempt sub-path."""
132
- low = text.translate(_QUOTES).lower()
133
- # Normalise ./ and leading slashes so `./.uap`, `/.uap` and `.uap` agree.
134
- low = low.replace("./", "/")
135
- if any(ex in low for ex in PROTECTED_EXEMPT):
136
- return False
137
- # The .uap DIRECTORY ITSELF: `rm -rf .uap` / `find .uap -delete` destroy the
138
- # manifest along with everything else, while `echo 0 > .uap/verify-cadence`
139
- # names a deeper path and is ordinary tooling. So the bare directory is
140
- # protected; a specific file under it is not (unless listed below).
141
- for m in re.finditer(re.escape(".uap"), low):
142
- if low[m.end():m.end() + 1] in ("", " ", '"', "'", "*"):
143
- return True
144
- for target in PROTECTED_TARGETS:
145
- t = target.lower()
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
- def _bash_destructive(command: str) -> bool:
156
- """Destructive op against a protected path, judged per command segment."""
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 re.finditer(r">>?", seg):
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
 
@@ -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