@miller-tech/uap 1.185.1 → 1.186.1
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/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/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 +211 -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 +442 -0
- package/tools/agents/tests/test_gate_integrity.py +180 -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,6 +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,
|
|
30
|
+
hands_text_to_shell, scannable_command,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
@@ -128,56 +130,241 @@ _QUOTES = str.maketrans("", "", "\"'")
|
|
|
128
130
|
|
|
129
131
|
|
|
130
132
|
def _mentions_protected(text: str) -> bool:
|
|
131
|
-
"""True when
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
"""True when any UNIT of `text` names a protected path.
|
|
134
|
+
|
|
135
|
+
Judged unit by unit, not over the whole blob. The exemption used to be
|
|
136
|
+
evaluated across the entire text, so one `policies/waivers` anywhere made
|
|
137
|
+
everything else invisible — appending a single innocuous line to a deletion
|
|
138
|
+
list defeated the check completely (found by the review, reproduced).
|
|
139
|
+
"""
|
|
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
|
|
147
|
+
if any(ex in unit for ex in PROTECTED_EXEMPT):
|
|
148
|
+
continue
|
|
149
|
+
# The .uap DIRECTORY ITSELF: `rm -rf .uap` destroys the manifest, while
|
|
150
|
+
# `echo 0 > .uap/verify-cadence` names a deeper path and is ordinary
|
|
151
|
+
# tooling. Bare directory protected; a file under it is not.
|
|
152
|
+
for m in re.finditer(re.escape(".uap"), unit):
|
|
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 ("", "*"):
|
|
160
|
+
return True
|
|
161
|
+
for target in PROTECTED_TARGETS:
|
|
162
|
+
t = target.lower()
|
|
163
|
+
for m in re.finditer(re.escape(t), unit):
|
|
164
|
+
if unit[m.end():m.end() + 1] in ("", "/", ".", " ", '"', "'", "*"):
|
|
165
|
+
return True
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# An argument list is a list of paths; it is never a megabyte. The old 1 MiB cap
|
|
170
|
+
# also SKIPPED anything larger, so padding a list file past the cap was a
|
|
171
|
+
# one-line bypass. Read a bounded prefix instead of skipping.
|
|
172
|
+
_MAX_SRC_BYTES = 1 << 16
|
|
173
|
+
_MAX_SOURCES = 32
|
|
174
|
+
# Commands whose arguments are literal paths we can read now.
|
|
175
|
+
_FILE_READERS = ("cat", "head", "tail", "sort", "uniq", "cut", "tr", "nl", "rev")
|
|
176
|
+
_LITERAL_EMITTERS = ("echo", "printf")
|
|
177
|
+
# Wrappers that hand their remaining words back to a shell as a command.
|
|
178
|
+
_WRAPPERS = ("eval", "env", "exec", "source", ".")
|
|
179
|
+
_SHELL_C = re.compile(
|
|
180
|
+
r"(?:^|[\s;|&(])(?:ba|z|k|da|a)?sh\s+(?:-\w+\s+)*-\w*c\s+(['\"])(.*?)\1", re.S)
|
|
181
|
+
_REDIRECT = re.compile(r">>?")
|
|
182
|
+
_SUBST_FILE = re.compile(r"\$\(\s*(?:cat\s+)?<?\s*([^\s)]+)[^)]*\)|`\s*cat\s+([^`]+)`")
|
|
183
|
+
_ARGFILE = re.compile(r"--arg-file=([^\s;|&]+)|(?:^|\s)-a\s+([^\s;|&]+)")
|
|
184
|
+
_STDIN_REDIR = re.compile(r"<\s*([^\s;|&<>]+)")
|
|
185
|
+
|
|
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:
|
|
136
204
|
return False
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 ("", " ", '"', "'", "*"):
|
|
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:
|
|
143
208
|
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 ("", "/", ".", " ", '"', "'", "*"):
|
|
151
|
-
return True
|
|
152
209
|
return False
|
|
153
210
|
|
|
211
|
+
def _read_source(tok: str) -> str:
|
|
212
|
+
"""A bounded prefix of `tok` if it is a readable regular file, else ""."""
|
|
213
|
+
tok = (tok or "").strip("\"'`$()").rstrip(";|&")
|
|
214
|
+
if not tok or tok.startswith("-"):
|
|
215
|
+
return ""
|
|
216
|
+
try:
|
|
217
|
+
p = Path(tok)
|
|
218
|
+
if not p.is_file(): # excludes FIFOs and devices: no blocking read
|
|
219
|
+
return ""
|
|
220
|
+
with p.open(errors="replace") as fh:
|
|
221
|
+
return fh.read(_MAX_SRC_BYTES)
|
|
222
|
+
except (OSError, ValueError):
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolved_arguments(command: str) -> list[str]:
|
|
227
|
+
"""Text that will actually REACH a command as arguments.
|
|
228
|
+
|
|
229
|
+
Only sources knowable right now: a `< file` redirect, xargs --arg-file/-a,
|
|
230
|
+
$(cat f)/`cat f`, and an upstream pipe stage that is a literal emitter
|
|
231
|
+
(echo/printf) or a file reader (cat/head/tail/...).
|
|
232
|
+
|
|
233
|
+
Deliberately NOT resolved: `grep … | xargs sed`. grep's output is unknown
|
|
234
|
+
here, and inferring it from the pattern text is exactly what made ordinary
|
|
235
|
+
refactors unrunnable. Unknowable means allow — the same call made for a path
|
|
236
|
+
held in a shell variable.
|
|
237
|
+
"""
|
|
238
|
+
out: list[str] = []
|
|
239
|
+
|
|
240
|
+
def add(text: str) -> None:
|
|
241
|
+
if text and len(out) < _MAX_SOURCES:
|
|
242
|
+
out.append(text)
|
|
154
243
|
|
|
155
|
-
|
|
156
|
-
|
|
244
|
+
for m in _STDIN_REDIR.finditer(command):
|
|
245
|
+
add(_read_source(m.group(1)))
|
|
246
|
+
for m in _ARGFILE.finditer(command):
|
|
247
|
+
add(_read_source(m.group(1) or m.group(2)))
|
|
248
|
+
for m in _SUBST_FILE.finditer(command):
|
|
249
|
+
add(_read_source(m.group(1) or m.group(2)))
|
|
250
|
+
|
|
251
|
+
stages = [s.strip() for s in command.split("|") if s.strip()]
|
|
252
|
+
for stage in stages[:-1]: # producers only
|
|
253
|
+
toks = [t for t in stage.split() if not _ENV_ASSIGN.match(t)]
|
|
254
|
+
if not toks:
|
|
255
|
+
continue
|
|
256
|
+
verb = toks[0].rsplit("/", 1)[-1].lower()
|
|
257
|
+
if verb in _LITERAL_EMITTERS:
|
|
258
|
+
add(" ".join(toks[1:]))
|
|
259
|
+
elif verb in _FILE_READERS:
|
|
260
|
+
for t in toks[1:]:
|
|
261
|
+
if not t.startswith("-"):
|
|
262
|
+
add(_read_source(t))
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _inner_commands(command: str) -> list[str]:
|
|
267
|
+
"""Command strings this command hands back to a shell to execute."""
|
|
268
|
+
out = [m.group(2) for m in _SHELL_C.finditer(command or "")]
|
|
269
|
+
for segment in _SEGMENT_SPLIT.split(command or ""):
|
|
270
|
+
toks = [t for t in segment.split() if not _ENV_ASSIGN.match(t)]
|
|
271
|
+
if toks and toks[0].rsplit("/", 1)[-1].lower() in _WRAPPERS:
|
|
272
|
+
rest = " ".join(toks[1:]).strip().strip("\"'")
|
|
273
|
+
if rest:
|
|
274
|
+
out.append(rest)
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _destructive_intent(command: str) -> bool:
|
|
279
|
+
"""A destructive verb or a redirect appears somewhere in `command`."""
|
|
280
|
+
toks = {t.rsplit("/", 1)[-1].lower().strip("\"'") for t in command.split()}
|
|
281
|
+
return bool(toks & set(DESTRUCTIVE_VERBS)) or bool(_REDIRECT.search(command))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _direct_destructive(command: str) -> bool:
|
|
285
|
+
"""Destructive op naming a protected path, judged per command segment."""
|
|
286
|
+
cd_into_protected = False
|
|
157
287
|
for segment in _SEGMENT_SPLIT.split(command or ""):
|
|
158
288
|
seg = segment.strip()
|
|
159
289
|
if not seg:
|
|
160
290
|
continue
|
|
161
291
|
# A redirect writes just as destructively as `rm`; the target may be
|
|
162
292
|
# quoted, ./-prefixed, or fd-numbered (`1> .uap/x`).
|
|
163
|
-
for m in
|
|
293
|
+
for m in _REDIRECT.finditer(seg):
|
|
164
294
|
if _mentions_protected(seg[m.end():]):
|
|
165
295
|
return True
|
|
166
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:]
|
|
167
305
|
if not tokens:
|
|
168
306
|
continue
|
|
169
|
-
verb = tokens[0]
|
|
307
|
+
verb = _verb_of(tokens[0])
|
|
308
|
+
# `cd .policy-tools && rm -f _common.py` put the protected path in one
|
|
309
|
+
# segment and the verb in another, so neither segment looked dangerous.
|
|
310
|
+
if verb == "cd":
|
|
311
|
+
cd_into_protected = _mentions_protected(" ".join(tokens[1:]))
|
|
312
|
+
continue
|
|
170
313
|
if verb == "git" and len(tokens) > 1:
|
|
171
314
|
verb = f"git {tokens[1].lower()}"
|
|
172
315
|
if verb not in ("git clean", "git checkout"):
|
|
173
316
|
continue
|
|
174
317
|
elif verb not in DESTRUCTIVE_VERBS:
|
|
175
318
|
continue
|
|
176
|
-
if _mentions_protected(" ".join(tokens[1:])):
|
|
319
|
+
if cd_into_protected or _mentions_protected(" ".join(tokens[1:])):
|
|
177
320
|
return True
|
|
178
321
|
return False
|
|
179
322
|
|
|
180
323
|
|
|
324
|
+
def _bash_destructive(command: str, _depth: int = 0) -> bool:
|
|
325
|
+
"""Destructive op against the protected surface, however the target arrives.
|
|
326
|
+
|
|
327
|
+
Three ways a target reaches a verb, all of them observed:
|
|
328
|
+
1. on the command line -> _direct_destructive
|
|
329
|
+
2. through a shell wrapper -> _inner_commands (bash -c, eval, env)
|
|
330
|
+
3. as resolved arguments -> _resolved_arguments (xargs, $(cat))
|
|
331
|
+
|
|
332
|
+
HONEST LIMIT: shell state this process cannot see still wins. `P=.policy-
|
|
333
|
+
tools; rm $P/x` expands inside the shell, and no scan of command TEXT can
|
|
334
|
+
resolve a VALUE. Refusing every destructive command containing a variable
|
|
335
|
+
would block ordinary work for no real gain, so the residual is accepted and
|
|
336
|
+
covered by the gate's fail-closed and the _common.py self-heal.
|
|
337
|
+
"""
|
|
338
|
+
if not command:
|
|
339
|
+
return False
|
|
340
|
+
if _direct_destructive(command):
|
|
341
|
+
return True
|
|
342
|
+
if _depth < 2: # bounded: `bash -c "bash -c ..."`
|
|
343
|
+
for inner in _inner_commands(command):
|
|
344
|
+
if _bash_destructive(inner, _depth + 1):
|
|
345
|
+
return True
|
|
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))
|
|
366
|
+
return False
|
|
367
|
+
|
|
181
368
|
OVERRIDE = os.environ.get("UAP_SELF_PROTECT_OFF") == "1"
|
|
182
369
|
|
|
183
370
|
|
|
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
|
|