nexo-brain 7.1.8 → 7.2.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.
@@ -220,6 +220,193 @@ def check_config_parse() -> DoctorCheck:
220
220
  )
221
221
 
222
222
 
223
+ def check_core_dev_packaged_install() -> DoctorCheck:
224
+ """Warn when ``~/.nexo/core-dev/`` exists on a packaged (non-dev) install.
225
+
226
+ Contract (see ``docs/f06-layout-contract.md`` §3): ``core-dev/`` is a
227
+ developer opt-in and MUST be absent on production installs. Its presence
228
+ on a packaged install is almost always a leftover from a dev environment
229
+ that was later repackaged, and silently keeps parallel code paths
230
+ discoverable through ``_classify_script_dir``. Doctor surfaces it so the
231
+ operator can confirm and remove.
232
+ """
233
+ import paths
234
+ core_dev = paths.core_dev_dir()
235
+ if not core_dev.exists():
236
+ return DoctorCheck(
237
+ id="boot.core_dev_absent_on_packaged",
238
+ tier="boot",
239
+ status="healthy",
240
+ severity="info",
241
+ summary="core-dev/ absent (expected on packaged installs)",
242
+ )
243
+ is_packaged = paths.core_dir().is_dir() and not (NEXO_HOME / "src").is_dir()
244
+ if not is_packaged:
245
+ return DoctorCheck(
246
+ id="boot.core_dev_absent_on_packaged",
247
+ tier="boot",
248
+ status="healthy",
249
+ severity="info",
250
+ summary="core-dev/ present on a dev install (contract allows this)",
251
+ )
252
+ try:
253
+ payload = [p.name for p in core_dev.iterdir()][:5]
254
+ except OSError:
255
+ payload = []
256
+ return DoctorCheck(
257
+ id="boot.core_dev_absent_on_packaged",
258
+ tier="boot",
259
+ status="degraded",
260
+ severity="warn",
261
+ summary="core-dev/ present on a packaged install — contract forbids this",
262
+ evidence=[f"Location: {core_dev}"] + [f"Entry: {n}" for n in payload],
263
+ repair_plan=[
264
+ f"Confirm with operator, then: rm -rf {core_dev}",
265
+ ],
266
+ )
267
+
268
+
269
+ def check_dashboard_desktop_contract() -> DoctorCheck:
270
+ """Flag Dashboard LaunchAgent contradicting Desktop product surface.
271
+
272
+ Contract (see ``docs/f06-layout-contract.md`` §4):
273
+ - Terminal-only install → ``com.nexo.dashboard`` loaded.
274
+ - Desktop-managed install → ``com.nexo.dashboard`` unloaded.
275
+ Both signals disagreeing with the chosen product mode is a warn.
276
+ """
277
+ if sys.platform != "darwin":
278
+ return DoctorCheck(
279
+ id="boot.dashboard_desktop_contract",
280
+ tier="boot",
281
+ status="healthy",
282
+ severity="info",
283
+ summary="Non-darwin host — dashboard LaunchAgent contract does not apply",
284
+ )
285
+ agent_path = Path.home() / "Library" / "LaunchAgents" / "com.nexo.dashboard.plist"
286
+ agent_installed = agent_path.exists()
287
+ try:
288
+ from product_mode import enforce_desktop_product_contract # type: ignore
289
+ desktop_contract = bool(enforce_desktop_product_contract())
290
+ except Exception:
291
+ desktop_contract = False
292
+
293
+ if desktop_contract and agent_installed:
294
+ return DoctorCheck(
295
+ id="boot.dashboard_desktop_contract",
296
+ tier="boot",
297
+ status="degraded",
298
+ severity="warn",
299
+ summary="Desktop product surface active but standalone dashboard LaunchAgent is installed",
300
+ evidence=[f"Plist: {agent_path}"],
301
+ repair_plan=[
302
+ f"launchctl unload {agent_path}",
303
+ f"rm {agent_path}",
304
+ ],
305
+ )
306
+ if not desktop_contract and not agent_installed:
307
+ return DoctorCheck(
308
+ id="boot.dashboard_desktop_contract",
309
+ tier="boot",
310
+ status="degraded",
311
+ severity="warn",
312
+ summary="Terminal-only install without a dashboard LaunchAgent",
313
+ evidence=["Expected plist missing: com.nexo.dashboard.plist"],
314
+ repair_plan=["nexo update # re-materialize com.nexo.dashboard"],
315
+ )
316
+ return DoctorCheck(
317
+ id="boot.dashboard_desktop_contract",
318
+ tier="boot",
319
+ status="healthy",
320
+ severity="info",
321
+ summary="Dashboard LaunchAgent state matches the product surface contract",
322
+ )
323
+
324
+
325
+ def check_f06_migration_consistency() -> DoctorCheck:
326
+ """Detect half-migrated F0.6 installs.
327
+
328
+ Contract (``docs/f06-layout-contract.md`` §6 rule 5):
329
+ - F0.6 marker + legacy runtime dirs populated → half-migration.
330
+ - No marker but canonical ``core/`` already populated → half-migration.
331
+ - Marker F0.6 with no legacy residue → healthy.
332
+ - No marker, no canonical ``core/``, pure legacy layout → healthy
333
+ (pre-F0.6 install waiting for ``nexo update``).
334
+
335
+ Half-migration is the scenario where ``paths.coordination_dir()`` (and
336
+ siblings) silently fall back to the legacy path on an install that
337
+ *should* be on F0.6. Doctor surfaces it so ``nexo update`` can be
338
+ asked to finish the job instead of the operator discovering later that
339
+ half their state lives in the wrong place.
340
+ """
341
+ import paths
342
+ marker = NEXO_HOME / ".structure-version"
343
+ marker_text = ""
344
+ if marker.is_file():
345
+ try:
346
+ marker_text = marker.read_text().strip().upper().split()[0]
347
+ except (OSError, IndexError):
348
+ marker_text = ""
349
+ is_f06_marked = marker_text.startswith("F0.6")
350
+
351
+ core_dir = paths.core_dir()
352
+ core_populated = core_dir.is_dir() and any(core_dir.iterdir()) if core_dir.exists() else False
353
+
354
+ # Legacy runtime dirs that MUST be gone (or be symlinks into canonical F0.6)
355
+ # once the migration has finished physically.
356
+ legacy_runtime_names = ("coordination", "data", "logs", "operations")
357
+ legacy_stragglers: list[str] = []
358
+ for name in legacy_runtime_names:
359
+ legacy_path = NEXO_HOME / name
360
+ if not legacy_path.exists():
361
+ continue
362
+ if legacy_path.is_symlink():
363
+ # A symlink pointing at the canonical runtime/<name> is the
364
+ # compat shim contract; that is acceptable.
365
+ continue
366
+ try:
367
+ has_content = any(legacy_path.iterdir())
368
+ except OSError:
369
+ has_content = False
370
+ if has_content:
371
+ legacy_stragglers.append(name)
372
+
373
+ if is_f06_marked and legacy_stragglers:
374
+ return DoctorCheck(
375
+ id="boot.f06_migration_consistency",
376
+ tier="boot",
377
+ status="critical",
378
+ severity="error",
379
+ summary="Half-migrated F0.6 install: marker present but legacy runtime dirs still populated",
380
+ evidence=[f"Marker: {marker_text}"] + [f"Legacy with content: {NEXO_HOME / n}" for n in legacy_stragglers],
381
+ repair_plan=[
382
+ "nexo update # finish the F0.6 migration",
383
+ "# if update refuses, inspect manifest and consider: nexo rollback f06",
384
+ ],
385
+ )
386
+ if (not is_f06_marked) and core_populated:
387
+ return DoctorCheck(
388
+ id="boot.f06_migration_consistency",
389
+ tier="boot",
390
+ status="critical",
391
+ severity="error",
392
+ summary="Half-migrated F0.6 install: core/ populated but marker absent",
393
+ evidence=[f"Marker: {marker_text or '(absent)'}", f"core/ path: {core_dir}"],
394
+ repair_plan=[
395
+ "nexo update # re-run migration to write the marker",
396
+ ],
397
+ )
398
+ return DoctorCheck(
399
+ id="boot.f06_migration_consistency",
400
+ tier="boot",
401
+ status="healthy",
402
+ severity="info",
403
+ summary=(
404
+ f"F0.6 marker consistent with layout (marker={marker_text or 'absent'}, "
405
+ f"legacy_stragglers={len(legacy_stragglers)})"
406
+ ),
407
+ )
408
+
409
+
223
410
  def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
224
411
  """Run all boot-tier checks."""
225
412
  checks = [
@@ -229,6 +416,9 @@ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
229
416
  safe_check(check_wrapper_scripts),
230
417
  safe_check(check_python_runtime),
231
418
  safe_check(check_config_parse),
419
+ safe_check(check_core_dev_packaged_install),
420
+ safe_check(check_dashboard_desktop_contract),
421
+ safe_check(check_f06_migration_consistency),
232
422
  ]
233
423
 
234
424
  if fix:
@@ -19,6 +19,10 @@ from core_prompts import render_core_prompt
19
19
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
20
20
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(NEXO_HOME)))
21
21
  NEXO_DB = paths.db_path()
22
+ # Evolution sandbox lives under the runtime root (equivalent to
23
+ # ``paths.runtime_dir() / "sandbox"``). Kept as ``NEXO_HOME / sandbox /
24
+ # workspace`` for backwards compatibility with existing installs that already
25
+ # have a populated sandbox at this path. Do NOT relocate without a migration.
22
26
  SANDBOX_DIR = NEXO_HOME / "sandbox" / "workspace"
23
27
  SNAPSHOTS_DIR = paths.snapshots_dir()
24
28
  RESTORE_LOG = paths.logs_dir() / "snapshot-restores.log"
@@ -0,0 +1,98 @@
1
+ """Guardian runtime config resolver — single source of truth for enforcer flags.
2
+
3
+ v7.2.0 introduces ``~/.nexo/personal/config/guardian-runtime-overrides.json``
4
+ as the persistent operator-owned default for Guardian gate modes:
5
+
6
+ {
7
+ "G1_ENFORCER_ACTIVE": "hard",
8
+ "G3_ENFORCE_DESTRUCTIVE": "hard",
9
+ "G3_SSH_ENFORCE_REMOTE_WRITE": "hard",
10
+ "G4_ENFORCE_GUARD_CHECK": "hard"
11
+ }
12
+
13
+ The JSON values match the ``NEXO_<FLAG>`` env-var semantics exactly
14
+ (``off`` / ``shadow`` / ``hard``). Env vars always win over the file so
15
+ an ad-hoc ``NEXO_G4_ENFORCE_GUARD_CHECK=shadow`` during debugging still
16
+ takes effect. The file is loaded lazily and cached per-process; callers
17
+ should not rely on edits to take effect without a restart.
18
+
19
+ Public API:
20
+ ``resolve_guardian_flag(name, default='shadow')`` -> normalized value.
21
+
22
+ ``name`` is the short key (``G1_ENFORCER_ACTIVE``) without the ``NEXO_``
23
+ prefix. Resolution order:
24
+ 1. Environment variable ``NEXO_<name>`` if set and non-empty.
25
+ 2. Override file entry.
26
+ 3. ``default``.
27
+
28
+ All returned values are lowercased and whitespace-stripped.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ from pathlib import Path
35
+
36
+
37
+ _CACHE: dict[str, str] | None = None
38
+
39
+
40
+ def _overrides_path() -> Path:
41
+ home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
42
+ return home / "personal" / "config" / "guardian-runtime-overrides.json"
43
+
44
+
45
+ def _load_overrides() -> dict[str, str]:
46
+ global _CACHE
47
+ if _CACHE is not None:
48
+ return _CACHE
49
+ path = _overrides_path()
50
+ if not path.is_file():
51
+ _CACHE = {}
52
+ return _CACHE
53
+ try:
54
+ raw = json.loads(path.read_text())
55
+ except Exception:
56
+ _CACHE = {}
57
+ return _CACHE
58
+ if not isinstance(raw, dict):
59
+ _CACHE = {}
60
+ return _CACHE
61
+ normalized: dict[str, str] = {}
62
+ for key, value in raw.items():
63
+ if not isinstance(value, str):
64
+ continue
65
+ normalized[str(key).strip().upper()] = value.strip().lower()
66
+ _CACHE = normalized
67
+ return _CACHE
68
+
69
+
70
+ def invalidate_cache() -> None:
71
+ """Drop the cached override file so the next call re-reads from disk.
72
+
73
+ Intended for tests and for the updater right after it writes a new
74
+ version of the file. Production code should not need to call this.
75
+ """
76
+ global _CACHE
77
+ _CACHE = None
78
+
79
+
80
+ def resolve_guardian_flag(name: str, default: str = "shadow") -> str:
81
+ """Resolve a Guardian gate mode (``off`` / ``shadow`` / ``hard``).
82
+
83
+ Env var ``NEXO_<name>`` has priority; falls back to the overrides
84
+ file; falls back to ``default`` last.
85
+ """
86
+ clean = str(name or "").strip().upper()
87
+ if not clean:
88
+ return str(default or "shadow").strip().lower()
89
+
90
+ env_value = os.environ.get(f"NEXO_{clean}", "").strip()
91
+ if env_value:
92
+ return env_value.lower()
93
+
94
+ file_value = _load_overrides().get(clean, "")
95
+ if file_value:
96
+ return file_value
97
+
98
+ return str(default or "shadow").strip().lower()
@@ -156,6 +156,82 @@ def _classify_destructive_intent(command: str) -> str | None:
156
156
  return None
157
157
 
158
158
 
159
+ # Block K G3 (SSH wrapper): remote-write patterns the local destructive
160
+ # gate never sees because they run inside ``ssh host '...'`` / rsync /
161
+ # scp / sftp. Matched against the raw Bash command string.
162
+ # Each regex aims to catch a well-known write primitive *inside* a remote
163
+ # invocation. ``ssh host 'ls'`` must not match — only write-verbs do.
164
+ _SSH_REMOTE_SHELL_RE = re.compile(
165
+ r"\bssh\b[^'\"`]*?(?:['\"`])(?P<remote>[^'\"`]+)(?:['\"`])",
166
+ re.IGNORECASE,
167
+ )
168
+ _SSH_REMOTE_WRITE_VERBS = (
169
+ re.compile(r"^\s*cat\s*>\s*\S", re.IGNORECASE), # cat > file
170
+ re.compile(r"^\s*cat\s*>>\s*\S", re.IGNORECASE), # cat >> file
171
+ re.compile(r"\btee\s+(?:-\S+\s+)*[^\s|&;]+", re.IGNORECASE), # tee [-a] file
172
+ re.compile(r"^\s*(?:echo|printf)\s+.*\s+>>?\s*\S", re.IGNORECASE), # echo ... > file
173
+ re.compile(r"\bsed\s+-i\b", re.IGNORECASE), # sed -i ...
174
+ re.compile(r"(?:^|\s)>\s*\S", re.IGNORECASE), # bare > file
175
+ re.compile(r"(?:^|\s)>>\s*\S", re.IGNORECASE), # bare >> file
176
+ re.compile(r"\brm\s+-\S*[rRfF]", re.IGNORECASE), # remote rm -rf
177
+ re.compile(r"\bmv\s+\S+\s+\S+", re.IGNORECASE), # remote mv
178
+ re.compile(r"\bcp\s+\S+\s+\S+", re.IGNORECASE), # remote cp
179
+ )
180
+ _SCP_WRITE_RE = re.compile(
181
+ r"\bscp\b[^|&;]*?\s\S+\s+[^:\s]+:[^\s]+",
182
+ re.IGNORECASE,
183
+ )
184
+ _RSYNC_WRITE_RE = re.compile(
185
+ r"\brsync\b[^|&;]*?\s\S+\s+[^:\s]+:[^\s]+",
186
+ re.IGNORECASE,
187
+ )
188
+ _SFTP_BATCH_RE = re.compile(
189
+ r"\bsftp\b(?:[^|&;]*\s)?-b\s+\S+",
190
+ re.IGNORECASE,
191
+ )
192
+
193
+
194
+ def _classify_ssh_remote_write(command: str) -> str | None:
195
+ """Return the matching pattern name when ``command`` writes to a remote host.
196
+
197
+ Covers four shapes:
198
+ 1. ``ssh host '<remote-shell-that-writes>'`` with or without
199
+ ``-o`` flags, using single/double/backtick quoting.
200
+ 2. ``scp LOCAL_PATH host:REMOTE_PATH`` (upload direction).
201
+ 3. ``rsync [opts] LOCAL_PATH host:REMOTE_PATH`` (upload direction).
202
+ 4. ``sftp -b batchfile host`` (any -b invocation is considered a
203
+ write candidate because the batch may mutate).
204
+
205
+ Ignores read-only invocations such as ``ssh host 'ls /etc'`` or
206
+ ``scp host:REMOTE /local/`` (download), which is the common case.
207
+ """
208
+ cmd = str(command or "")
209
+
210
+ if _SCP_WRITE_RE.search(cmd):
211
+ # Disambiguate download (host:remote local) vs upload (local host:remote).
212
+ # Simple rule: if the FIRST ``host:path`` argument is preceded by a
213
+ # local-looking arg, treat as upload.
214
+ download = re.search(r"\bscp\b[^|&;]*?\s[^:\s]+:\S+\s+\S+", cmd)
215
+ if not download:
216
+ return "scp_remote_write"
217
+ if _RSYNC_WRITE_RE.search(cmd):
218
+ download = re.search(r"\brsync\b[^|&;]*?\s[^:\s]+:\S+\s+\S+", cmd)
219
+ if not download:
220
+ return "rsync_remote_write"
221
+ if _SFTP_BATCH_RE.search(cmd):
222
+ return "sftp_batch_remote_write"
223
+
224
+ for match in _SSH_REMOTE_SHELL_RE.finditer(cmd):
225
+ remote = match.group("remote") or ""
226
+ # Strip leading "sudo ", "env VAR=x ", "cd dir &&" — they never mean
227
+ # a write by themselves, and may hide real writes behind them.
228
+ trimmed = re.sub(r"^\s*(?:sudo\s+|env\s+\S+=\S+\s+|cd\s+\S+\s*&&\s*)+", "", remote)
229
+ for pattern in _SSH_REMOTE_WRITE_VERBS:
230
+ if pattern.search(trimmed):
231
+ return "ssh_remote_shell_write"
232
+ return None
233
+
234
+
159
235
  def _operation_kind(tool_name: str) -> str:
160
236
  if tool_name in READ_LIKE_TOOLS:
161
237
  return "read"
@@ -701,12 +777,18 @@ def _collect_protocol_warnings(conn, *, sid: str, tool_name: str) -> list[dict]:
701
777
  if task.get("must_change_log")
702
778
  else ""
703
779
  )
780
+ closeout_note = (
781
+ " If this edit wave came from a user correction or you are leaving a blocker unresolved, "
782
+ "include `correction_happened=true` with a reusable learning, or `followup_needed=true`, "
783
+ "when you call `nexo_task_close(...)`."
784
+ )
704
785
  _append_protocol_warning(
705
786
  warnings,
706
787
  render_core_prompt(
707
788
  "hook-protocol-warning-task-close-evidence",
708
789
  task_id=task_id,
709
790
  change_note=change_note,
791
+ closeout_note=closeout_note,
710
792
  ),
711
793
  )
712
794
 
@@ -1005,7 +1087,11 @@ def process_pre_tool_event(payload: dict) -> dict:
1005
1087
  # NEXO_G3_ENFORCE_DESTRUCTIVE (default "shadow"): shadow records a
1006
1088
  # warn-severity debt for observability; hard blocks the operation
1007
1089
  # with error severity; off disables the gate entirely.
1008
- g3_mode = os.environ.get("NEXO_G3_ENFORCE_DESTRUCTIVE", "shadow").strip().lower()
1090
+ try:
1091
+ from guardian_runtime_config import resolve_guardian_flag
1092
+ g3_mode = resolve_guardian_flag("G3_ENFORCE_DESTRUCTIVE", default="shadow")
1093
+ except Exception:
1094
+ g3_mode = os.environ.get("NEXO_G3_ENFORCE_DESTRUCTIVE", "shadow").strip().lower()
1009
1095
  if g3_mode in {"shadow", "hard"} and tool_name == "Bash":
1010
1096
  shell_command = _extract_bash_command(tool_input)
1011
1097
  destructive_pattern = _classify_destructive_intent(shell_command)
@@ -1047,6 +1133,62 @@ def process_pre_tool_event(payload: dict) -> dict:
1047
1133
  "g3_mode": g3_mode,
1048
1134
  }
1049
1135
 
1136
+ # Block K G3 SSH wrapper (Francisco 2026-04-22 v7.2.0): remote-write
1137
+ # commands routed through ssh/rsync/scp/sftp never reach the local
1138
+ # destructive gate. Gated by NEXO_G3_SSH_ENFORCE_REMOTE_WRITE (default
1139
+ # "shadow") mirroring the destructive-local flag shape. Shadow logs a
1140
+ # warn debt row; hard blocks with error severity; off disables.
1141
+ try:
1142
+ from guardian_runtime_config import resolve_guardian_flag
1143
+ g3_ssh_mode = resolve_guardian_flag(
1144
+ "G3_SSH_ENFORCE_REMOTE_WRITE", default="shadow"
1145
+ )
1146
+ except Exception:
1147
+ g3_ssh_mode = os.environ.get(
1148
+ "NEXO_G3_SSH_ENFORCE_REMOTE_WRITE", "shadow"
1149
+ ).strip().lower()
1150
+ if g3_ssh_mode in {"shadow", "hard"} and tool_name == "Bash":
1151
+ shell_command = _extract_bash_command(tool_input)
1152
+ ssh_pattern = _classify_ssh_remote_write(shell_command)
1153
+ if ssh_pattern:
1154
+ severity = "error" if g3_ssh_mode == "hard" else "warn"
1155
+ debt = _ensure_protocol_debt(
1156
+ conn,
1157
+ session_id=sid,
1158
+ task_id="",
1159
+ debt_type="g3_ssh_remote_write_requires_cortex",
1160
+ severity=severity,
1161
+ evidence=(
1162
+ f"Bash command matched SSH remote-write pattern '{ssh_pattern}'. "
1163
+ f"Command head: {shell_command[:160]}. "
1164
+ "Run nexo_cortex_decide (or nexo_task_open for the session) "
1165
+ "and record evidence before retrying."
1166
+ ),
1167
+ file_token=ssh_pattern,
1168
+ )
1169
+ if g3_ssh_mode == "hard":
1170
+ return {
1171
+ "ok": True,
1172
+ "session_id": sid,
1173
+ "tool_name": tool_name,
1174
+ "operation": op,
1175
+ "strictness": strictness,
1176
+ "blocks": [
1177
+ {
1178
+ "file": "",
1179
+ "task_id": "",
1180
+ "debt_id": debt.get("id"),
1181
+ "debt_type": "g3_ssh_remote_write_requires_cortex",
1182
+ "reason_code": "g3_ssh_remote_write_blocked",
1183
+ "severity": "error",
1184
+ "pattern": ssh_pattern,
1185
+ "g3_ssh_mode": g3_ssh_mode,
1186
+ }
1187
+ ],
1188
+ "status": "blocked",
1189
+ "g3_ssh_mode": g3_ssh_mode,
1190
+ }
1191
+
1050
1192
  # Block K G4 (Francisco 2026-04-22): require nexo_guard_check to have
1051
1193
  # run within the session for every file about to be written. Opt-in
1052
1194
  # via NEXO_G4_ENFORCE_GUARD_CHECK (default "shadow"): ``shadow``
@@ -1055,7 +1197,11 @@ def process_pre_tool_event(payload: dict) -> dict:
1055
1197
  # so the operator must run guard_check explicitly. Skipped entirely
1056
1198
  # in lenient mode or when there are no files, since the existing
1057
1199
  # strict-mode path already covers those cases with its own gating.
1058
- g4_mode = os.environ.get("NEXO_G4_ENFORCE_GUARD_CHECK", "shadow").strip().lower()
1200
+ try:
1201
+ from guardian_runtime_config import resolve_guardian_flag
1202
+ g4_mode = resolve_guardian_flag("G4_ENFORCE_GUARD_CHECK", default="shadow")
1203
+ except Exception:
1204
+ g4_mode = os.environ.get("NEXO_G4_ENFORCE_GUARD_CHECK", "shadow").strip().lower()
1059
1205
  if g4_mode in {"shadow", "hard"} and files and sid:
1060
1206
  g4_blocks: list[dict] = []
1061
1207
  g4_warnings: list[dict] = []