@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.
Files changed (32) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cli/policy.d.ts.map +1 -1
  3. package/dist/cli/policy.js +56 -0
  4. package/dist/cli/policy.js.map +1 -1
  5. package/dist/config/settings-registry.d.ts.map +1 -1
  6. package/dist/config/settings-registry.js +5 -1
  7. package/dist/config/settings-registry.js.map +1 -1
  8. package/dist/integrity/enforcer-manifest.d.ts +46 -0
  9. package/dist/integrity/enforcer-manifest.d.ts.map +1 -0
  10. package/dist/integrity/enforcer-manifest.js +145 -0
  11. package/dist/integrity/enforcer-manifest.js.map +1 -0
  12. package/dist/policies/enforced-tool-router.d.ts +10 -2
  13. package/dist/policies/enforced-tool-router.d.ts.map +1 -1
  14. package/dist/policies/enforced-tool-router.js.map +1 -1
  15. package/dist/policies/policy-tools.d.ts.map +1 -1
  16. package/dist/policies/policy-tools.js +9 -0
  17. package/dist/policies/policy-tools.js.map +1 -1
  18. package/dist/types/config.d.ts +93 -93
  19. package/dist/types/config.d.ts.map +1 -1
  20. package/dist/types/config.js +12 -3
  21. package/dist/types/config.js.map +1 -1
  22. package/docs/getting-started/CONFIGURATION.md +1 -1
  23. package/docs/reference/CONFIGURATION.md +1 -1
  24. package/docs/reference/CONFIGURATION_REFERENCE.md +1 -1
  25. package/package.json +2 -2
  26. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  27. package/src/policies/enforcers/enforcement_self_protect.py +211 -24
  28. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  29. package/templates/hooks/uap-policy-gate.sh +99 -0
  30. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  31. package/tools/agents/tests/test_gate_failclosed_indirection.py +442 -0
  32. 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 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):
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
- # 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 ("", " ", '"', "'", "*"):
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
- def _bash_destructive(command: str) -> bool:
156
- """Destructive op against a protected path, judged per command segment."""
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 re.finditer(r">>?", seg):
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].rsplit("/", 1)[-1].lower()
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
 
@@ -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