nexo-brain 7.1.7 → 7.1.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.7",
3
+ "version": "7.1.8",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.1.7` is the current packaged-runtime line. It hardens operator-facing email automation language: `email-monitor` now carries the calibrated operator language through its prompt contract and localizes direct `needs_interactive` escalation emails, so Spanish operators stop receiving fallback English monitor mail. No coordinated Desktop release was needed for this patch; the fix lives in Brain.
21
+ Version `7.1.8` is the current packaged-runtime line. It batches the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core). All new gates ship in shadow mode with env-flag rollout to `hard`. No coordinated Desktop release is required for this patch; Desktop consumers continue against the same CLI / MCP contract.
22
22
 
23
23
  Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.7",
3
+ "version": "7.1.8",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -3007,10 +3007,11 @@ def _check_npm_version() -> str | None:
3007
3007
  return None
3008
3008
  if latest != current and not current.endswith(latest):
3009
3009
  try:
3010
- from user_context import get_context
3011
- _name = get_context().assistant_name
3010
+ from user_context import get_context, DEFAULT_ASSISTANT_NAME
3011
+ _name = get_context().assistant_name or DEFAULT_ASSISTANT_NAME
3012
3012
  except Exception:
3013
- _name = "NEXO"
3013
+ from user_context import DEFAULT_ASSISTANT_NAME
3014
+ _name = DEFAULT_ASSISTANT_NAME
3014
3015
  return f"{_name} update available: {current} -> {latest}. Run: npm update -g {pkg_name}"
3015
3016
  except Exception:
3016
3017
  pass
@@ -3385,12 +3386,17 @@ def _find_user_claude_md() -> Path | None:
3385
3386
 
3386
3387
  def _resolve_placeholders(template_text: str) -> str:
3387
3388
  """Fill {{NAME}} and {{NEXO_HOME}} from the user's existing CLAUDE.md or config."""
3388
- # Read operator name from calibration/version
3389
+ # Read operator name from calibration/version. The fallback must never be a
3390
+ # reserved product identity ("NEXO", "NEXO Brain", "NEXO Desktop"); those
3391
+ # are rejected by desktop_bridge.RESERVED_ASSISTANT_NAME_VALUES and leaking
3392
+ # them into the managed CLAUDE.md would conflate the agent (Nova/Nero/etc.)
3393
+ # with the product itself.
3389
3394
  try:
3390
- from user_context import get_context
3391
- name = get_context().assistant_name
3395
+ from user_context import get_context, DEFAULT_ASSISTANT_NAME
3396
+ name = get_context().assistant_name or DEFAULT_ASSISTANT_NAME
3392
3397
  except Exception:
3393
- name = "NEXO"
3398
+ from user_context import DEFAULT_ASSISTANT_NAME
3399
+ name = DEFAULT_ASSISTANT_NAME
3394
3400
 
3395
3401
  return (
3396
3402
  template_text
@@ -11,7 +11,12 @@ Contract:
11
11
  "lo hemos dejado, ya estaría",
12
12
  labels=("done_claim", "status_update", "question", "noise"),
13
13
  )
14
- result == {"label": "done_claim", "confidence": 0.87, "scores": {...}}
14
+ # result is a ``ClassificationResult`` dataclass (see below):
15
+ # result.label == "done_claim"
16
+ # result.confidence == 0.87
17
+ # result.scores == {"done_claim": 0.87, ...}
18
+ # The dataclass keeps attribute access + ``asdict()`` compatibility
19
+ # for callers that previously consumed it as a dict.
15
20
 
16
21
  When transformers is not installed or the download fails (offline),
17
22
  `classify` returns `None` and `classify_fail_closed` returns a
package/src/cli.py CHANGED
@@ -1755,6 +1755,21 @@ def _terminal_client_label(client: str) -> str:
1755
1755
 
1756
1756
 
1757
1757
  def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> list[str]:
1758
+ """Return the terminal clients we should offer the operator, in priority order.
1759
+
1760
+ Policy:
1761
+ 1. Clients that are BOTH detected installed AND enabled in preferences
1762
+ keep their priority (``last_used`` → ``default`` → TERMINAL_CLIENT_ORDER).
1763
+ 2. If that primary list ends up with ≤1 choice, we also surface every
1764
+ other DETECTED-INSTALLED client even when it is not yet marked
1765
+ ``enabled`` in preferences. Rationale (bug Francisco 2026-04-22):
1766
+ operators with both Claude Code and Codex installed were being
1767
+ dropped straight into whichever one was last used (or the only one
1768
+ ever enabled) with no chance to pick. ``nexo chat`` must offer the
1769
+ picker whenever more than one interactive client is available on
1770
+ the machine; the preferences flag stays as a *priority* signal, not
1771
+ as a hard filter that hides a legitimately installed CLI.
1772
+ """
1758
1773
  enabled = preferences.get("interactive_clients", {})
1759
1774
  last_used = str(preferences.get("last_terminal_client", "")).strip()
1760
1775
  preferred = str(preferences.get("default_terminal_client", "")).strip()
@@ -1764,11 +1779,22 @@ def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> li
1764
1779
  if client in TERMINAL_CLIENT_ORDER and client not in ordered:
1765
1780
  ordered.append(client)
1766
1781
 
1767
- return [
1782
+ primary = [
1768
1783
  client
1769
1784
  for client in ordered
1770
1785
  if enabled.get(client, False) and detected.get(client, {}).get("installed", False)
1771
1786
  ]
1787
+ if len(primary) <= 1:
1788
+ # Surface additional installed clients so the operator gets to pick
1789
+ # whenever there is a genuine competing install on disk. We preserve
1790
+ # the priority order built above so the picker still highlights the
1791
+ # last-used/default client first.
1792
+ for client in ordered:
1793
+ if client in primary:
1794
+ continue
1795
+ if detected.get(client, {}).get("installed", False):
1796
+ primary.append(client)
1797
+ return primary
1772
1798
 
1773
1799
 
1774
1800
  def _preferred_terminal_client_label(preferences: dict, clients: list[str]) -> str:
@@ -78,6 +78,22 @@ def add_email_account(
78
78
  raise ValueError("label and email are required")
79
79
  if role not in VALID_ROLES:
80
80
  raise ValueError(f"role must be one of {VALID_ROLES}, got {role!r}")
81
+ # AUDITOR-3RDPASS §Risk 3: the SELECT (``get_email_account``) and the
82
+ # follow-up INSERT ... ON CONFLICT DO UPDATE below used to run as two
83
+ # independent statements on the shared connection. A concurrent writer
84
+ # (Desktop + cron overlap) could slip a metadata mutation between them
85
+ # and get silently overwritten by whatever ``existing.get("metadata")``
86
+ # we captured here. Wrap the read-modify-write in ``BEGIN IMMEDIATE``
87
+ # so the row is pinned against concurrent writers for the duration of
88
+ # the upsert. WAL mode still lets readers through, so no lock storm.
89
+ conn = _get_db()
90
+ try:
91
+ conn.execute("BEGIN IMMEDIATE")
92
+ _owns_txn = True
93
+ except Exception:
94
+ # Already inside a transaction (e.g. caller wrapped the whole flow);
95
+ # trust the caller's boundary instead of nesting an IMMEDIATE.
96
+ _owns_txn = False
81
97
  existing = get_email_account(label) or {}
82
98
  clean_account_type = str(account_type or existing.get("account_type") or "agent").strip().lower()
83
99
  if clean_account_type not in VALID_ACCOUNT_TYPES:
@@ -166,7 +182,10 @@ def add_email_account(
166
182
  now,
167
183
  ),
168
184
  )
169
- conn.commit()
185
+ # Only commit/close the transaction we opened ourselves. Callers that
186
+ # already wrapped the flow in their own transaction must keep control.
187
+ if _owns_txn:
188
+ conn.commit()
170
189
  return get_email_account(label) or {}
171
190
 
172
191
 
package/src/db/_schema.py CHANGED
@@ -1222,6 +1222,75 @@ def _m45_personal_scripts_origin(conn):
1222
1222
  _migrate_add_index(conn, "idx_personal_scripts_origin", "personal_scripts", "origin")
1223
1223
 
1224
1224
 
1225
+ def _m49_protocol_guard_ack_backfill(conn):
1226
+ """Backfill protocol guard-ack columns for installs that already marked
1227
+ migration v22 as applied before those columns were added to the migration
1228
+ body.
1229
+
1230
+ This must remain a standalone migration instead of reusing v22 because
1231
+ real runtimes can legitimately sit at schema version 48 with an older
1232
+ ``protocol_tasks`` shape. Re-running ``init_db()`` skips v22 once it is
1233
+ recorded in ``schema_migrations``, so the missing columns never land
1234
+ without a new version.
1235
+ """
1236
+ _migrate_add_column(conn, "protocol_tasks", "guard_acknowledged", "INTEGER NOT NULL DEFAULT 0")
1237
+ _migrate_add_column(conn, "protocol_tasks", "guard_acknowledged_at", "TEXT DEFAULT NULL")
1238
+
1239
+
1240
+ def _m50_dedupe_nexo_product_learning_pair(conn):
1241
+ """Block D.2 / G7-adjacent: dedupe the two learnings that encode the
1242
+ "NEXO Brain producto público vs instancia personal de Francisco"
1243
+ invariant as a physically separate pair.
1244
+
1245
+ Francisco's runtime has this concept stored twice (historical IDs 212
1246
+ and 224). Guard dedup already collapses them at display time, but
1247
+ the underlying rows stayed split, so list/search/update flows still
1248
+ saw two rows. Physically supersede the older one by pointing its
1249
+ ``supersedes_id`` at the newer duplicate and flipping its status to
1250
+ ``superseded``. Anything newer than both is left untouched.
1251
+
1252
+ Idempotent. Fresh installs that never created either row silently
1253
+ do nothing; installs where an operator has already set the relation
1254
+ manually do nothing. The migration matches on a text-normalised form
1255
+ of the title so synonymous wording on both rows is enough — we don't
1256
+ need identical strings, and we don't need the IDs to literally be
1257
+ 212 and 224.
1258
+ """
1259
+ try:
1260
+ rows = conn.execute(
1261
+ "SELECT id, title, content, status, supersedes_id FROM learnings "
1262
+ "WHERE status = 'active'"
1263
+ ).fetchall()
1264
+ except Exception:
1265
+ return
1266
+
1267
+ def _norm(text: str) -> str:
1268
+ # Collapse whitespace and strip punctuation/case so "NEXO Brain
1269
+ # producto público vs instancia personal" matches its twin no
1270
+ # matter how the operator rephrased it.
1271
+ import re as _re
1272
+ stripped = _re.sub(r"[\W_]+", " ", str(text or "")).strip().lower()
1273
+ return _re.sub(r"\s+", " ", stripped)
1274
+
1275
+ marker = "nexo brain producto"
1276
+ candidates = [r for r in rows if marker in _norm(r[1] or "")]
1277
+ # Need at least two rows for this migration to do anything.
1278
+ if len(candidates) < 2:
1279
+ return
1280
+ # Sort by id ascending; the highest id is the canonical survivor.
1281
+ candidates.sort(key=lambda r: int(r[0] or 0))
1282
+ survivor = candidates[-1]
1283
+ for older in candidates[:-1]:
1284
+ older_id = int(older[0] or 0)
1285
+ if int(older[4] or 0) == int(survivor[0] or 0):
1286
+ continue # already linked
1287
+ conn.execute(
1288
+ "UPDATE learnings SET supersedes_id = ?, status = 'superseded', "
1289
+ "updated_at = strftime('%s','now') WHERE id = ? AND status = 'active'",
1290
+ (int(survivor[0] or 0), older_id),
1291
+ )
1292
+
1293
+
1225
1294
  def _m44_entities_extended_schema(conn):
1226
1295
  """Plan Consolidado 0.3 — extend entities with aliases/metadata/source/confidence/access_mode.
1227
1296
 
@@ -1291,6 +1360,8 @@ MIGRATIONS = [
1291
1360
  (46, "email_accounts", _m46_email_accounts),
1292
1361
  (47, "email_operator_accounts", _m47_email_operator_accounts),
1293
1362
  (48, "email_agent_contract_backfill", _m48_email_agent_contract_backfill),
1363
+ (49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
1364
+ (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1294
1365
  ]
1295
1366
 
1296
1367
 
@@ -122,9 +122,15 @@ def _account_to_legacy_shape(
122
122
  "password": runtime_account["password"],
123
123
  "operator_email": default_operator_email,
124
124
  "operator_aliases": list(extra_operator_emails or []),
125
- # Transitional compatibility for older scripts that still read the
126
- # pre-F2 alias key directly.
127
- "francisco_emails": list(extra_operator_emails or []),
125
+ # Historical note: earlier versions exported the same list under the
126
+ # ``francisco_emails`` key for scripts that still referenced the
127
+ # operator's email by a personal name. The key leaked the operator
128
+ # identity into anything consuming the email config dict, so it has
129
+ # been removed. Callers must read ``operator_aliases`` instead. The
130
+ # legacy ``francisco_emails`` key inside ~/.nexo/nexo-email/config.json
131
+ # is still tolerated at ingest time (see _operator_aliases in
132
+ # automation_controls.py and nexo-email-monitor.py) so upgrades from
133
+ # old runtimes do not break; it just no longer round-trips here.
128
134
  "trusted_domains": runtime_account["trusted_domains"],
129
135
  "sender_policy": runtime_account["sender_policy"],
130
136
  "sent_folder": runtime_account["sent_folder"],
@@ -118,6 +118,44 @@ INLINE_WRITE_RE = re.compile(
118
118
  EMBEDDED_PATH_RE = re.compile(r"(~\/[^'\"\s,);]+|\/[^'\"\s,);]+)")
119
119
 
120
120
 
121
+ # Block K G3: destructive commands whose blast radius warrants a cortex
122
+ # decision before they execute. The list is deliberately tight — only
123
+ # the patterns that historically cause irrecoverable damage. Each regex
124
+ # is tested against the full Bash command string (post shlex split
125
+ # rebuild) so distinct spacings and option orderings do not slip past.
126
+ DESTRUCTIVE_COMMAND_PATTERNS: tuple[tuple[str, "re.Pattern[str]"], ...] = (
127
+ # Matches both combined ``rm -rf`` and split ``rm -r -f`` forms. The
128
+ # character class ``[rfRF]*`` after the dash tolerates any flag
129
+ # ordering while both ``r`` and ``f`` (case-insensitive) must be
130
+ # present for the match to fire.
131
+ ("rm_rf", re.compile(r"\brm\s+-[rfRF]*(?:[rR][rfRF]*[fF]|[fF][rfRF]*[rR])[rfRF]*\b", re.IGNORECASE)),
132
+ ("git_push_force", re.compile(r"\bgit\s+push\s+(?:.*\s)?(?:--force|-f)\b(?!.*-with-lease)", re.IGNORECASE)),
133
+ ("drop_table", re.compile(r"\bdrop\s+(?:table|database|schema)\b", re.IGNORECASE)),
134
+ ("truncate_table", re.compile(r"\btruncate\s+(?:table\s+)?\w+", re.IGNORECASE)),
135
+ ("curl_pipe_bash", re.compile(r"curl[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b", re.IGNORECASE)),
136
+ ("wget_pipe_bash", re.compile(r"wget[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b", re.IGNORECASE)),
137
+ # ``dd if=... of=/dev/sda`` — match any ordering of args; we flag the
138
+ # presence of ``of=/dev/...`` which points at a raw block/device.
139
+ ("dd_of_root", re.compile(r"\bdd\s+.*?\bof=/dev/\S+", re.IGNORECASE)),
140
+ ("chmod_777_recursive", re.compile(r"\bchmod\s+-R\s+(?:777|666|a\+rw)\b", re.IGNORECASE)),
141
+ )
142
+
143
+
144
+ def _classify_destructive_intent(command: str) -> str | None:
145
+ """Return the matching pattern name if ``command`` looks destructive.
146
+
147
+ None when none of the DESTRUCTIVE_COMMAND_PATTERNS match. Intentionally
148
+ strict: we would rather miss a novel attack shape and rely on the
149
+ existing ``strict``/``write_without_file_guard_check`` gates than
150
+ inject false positives on routine Bash usage.
151
+ """
152
+ cmd = str(command or "")
153
+ for name, pattern in DESTRUCTIVE_COMMAND_PATTERNS:
154
+ if pattern.search(cmd):
155
+ return name
156
+ return None
157
+
158
+
121
159
  def _operation_kind(tool_name: str) -> str:
122
160
  if tool_name in READ_LIKE_TOOLS:
123
161
  return "read"
@@ -291,6 +329,10 @@ def _extract_bash_touched_files(tool_input) -> list[str]:
291
329
  ".py", ".md", ".json", ".jsonl", ".sh", ".txt", ".toml", ".yaml", ".yml",
292
330
  ".js", ".ts", ".tsx", ".jsx", ".php", ".sql", ".rs", ".go", ".c", ".cpp",
293
331
  ".h", ".css", ".html",
332
+ # ``.plist`` is needed so that ``_collect_launchagent_write_blocks``
333
+ # can see managed LaunchAgent plists inside Bash commands such as
334
+ # ``chmod 755 ~/Library/LaunchAgents/com.nexo.runner-health-check.plist``.
335
+ ".plist",
294
336
  }
295
337
 
296
338
  def add(candidate: str) -> None:
@@ -746,6 +788,79 @@ def _collect_runtime_core_write_blocks(
746
788
  return blocks
747
789
 
748
790
 
791
+ # ``_normalize_path_token`` lower-cases the path, so the regex and substring
792
+ # checks here must also be lower-case. We intentionally omit the leading
793
+ # ``/`` so that both ``/Users/.../Library/...`` and ``~/Library/...`` shapes
794
+ # match — ``_normalize_file_path`` does not expand the user home.
795
+ _LAUNCHAGENT_PLIST_RE = re.compile(
796
+ r"library/launchagents/com\.nexo\.[^/]+\.plist$"
797
+ )
798
+
799
+
800
+ def _is_protected_launchagent_path(filepath: str) -> bool:
801
+ """True when ``filepath`` resolves to a NEXO-managed LaunchAgent plist.
802
+
803
+ Matches any absolute or tilde-prefixed path that ends with
804
+ ``Library/LaunchAgents/com.nexo.<name>.plist``. Other plists in the same
805
+ directory (e.g. third-party agents) are left untouched.
806
+ """
807
+ if not filepath:
808
+ return False
809
+ normalized = _normalize_file_path(filepath)
810
+ if "library/launchagents/com.nexo." not in normalized:
811
+ return False
812
+ return _LAUNCHAGENT_PLIST_RE.search(normalized) is not None
813
+
814
+
815
+ def _collect_launchagent_write_blocks(
816
+ conn,
817
+ *,
818
+ sid: str,
819
+ tool_name: str,
820
+ files: list[str],
821
+ ) -> list[dict]:
822
+ """Block agent-driven writes to NEXO-managed LaunchAgent plists.
823
+
824
+ Core flows (``auto_update.py`` re-generating plists, ``nexo_migrate``,
825
+ product controllers) set ``NEXO_CORE_WRITES_ALLOWED=1`` via
826
+ ``product_mode.core_writes_allowed()``, which bypasses this gate. Agentic
827
+ edits (an operator prompting Claude Code to "fix this LaunchAgent"
828
+ manually) go through the check and are rejected with a pointer to the
829
+ canonical surfaces: ``nexo scripts ensure-schedules``,
830
+ ``nexo core-schedules``, or the source repo release flow.
831
+ """
832
+ if core_writes_allowed():
833
+ return []
834
+ blocks: list[dict] = []
835
+ for filepath in files:
836
+ if not _is_protected_launchagent_path(filepath):
837
+ continue
838
+ debt = _ensure_protocol_debt(
839
+ conn,
840
+ session_id=sid,
841
+ task_id="",
842
+ debt_type="launchagent_plist_write_blocked",
843
+ severity="error",
844
+ evidence=(
845
+ f"{tool_name} attempted on managed LaunchAgent plist {filepath}. "
846
+ "NEXO-managed plists must be regenerated through "
847
+ "`nexo scripts ensure-schedules`, `nexo core-schedules`, or the "
848
+ "source repo release flow, not edited in place."
849
+ ),
850
+ file_token=filepath,
851
+ )
852
+ blocks.append(
853
+ {
854
+ "file": filepath,
855
+ "task_id": "",
856
+ "debt_id": debt.get("id"),
857
+ "debt_type": "launchagent_plist_write_blocked",
858
+ "reason_code": "launchagent_plist_protected",
859
+ }
860
+ )
861
+ return blocks
862
+
863
+
749
864
  def _read_claude_session_id_from_coordination() -> str:
750
865
  """Fallback claude_session_id when Claude Code's PreToolUse payload omits it.
751
866
 
@@ -790,6 +905,19 @@ def process_pre_tool_event(payload: dict) -> dict:
790
905
  if shell_op in {"write", "delete"}:
791
906
  op = shell_op
792
907
  shell_files = _extract_bash_touched_files(tool_input)
908
+ # Block K G3 prescreen: destructive Bash patterns (``git push
909
+ # --force``, ``drop table``, ``curl | bash``, ``rm -rf``, ``dd
910
+ # of=/dev/…``, ``chmod -R 777``) must go through the pre-tool gate
911
+ # even when ``_classify_bash_operation`` labels them ``other``.
912
+ # Without this prescreen the ``if op not in {'write', 'delete'}``
913
+ # early-return below would let them slip past — exactly the failure
914
+ # mode Francisco flagged in G3.
915
+ if tool_name == "Bash" and op not in {"write", "delete"}:
916
+ _g3_mode_prescreen = os.environ.get("NEXO_G3_ENFORCE_DESTRUCTIVE", "shadow").strip().lower()
917
+ if _g3_mode_prescreen in {"shadow", "hard"}:
918
+ _shell_cmd = _extract_bash_command(tool_input)
919
+ if _classify_destructive_intent(_shell_cmd):
920
+ op = "delete" # force the main gate to keep evaluating
793
921
  if op not in {"write", "delete"}:
794
922
  return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
795
923
 
@@ -855,6 +983,136 @@ def process_pre_tool_event(payload: dict) -> dict:
855
983
  "status": "blocked",
856
984
  }
857
985
 
986
+ launchagent_blocks = _collect_launchagent_write_blocks(
987
+ conn,
988
+ sid=sid,
989
+ tool_name=tool_name,
990
+ files=files,
991
+ )
992
+ if launchagent_blocks:
993
+ return {
994
+ "ok": True,
995
+ "session_id": sid,
996
+ "tool_name": tool_name,
997
+ "operation": op,
998
+ "strictness": strictness,
999
+ "blocks": launchagent_blocks,
1000
+ "status": "blocked",
1001
+ }
1002
+
1003
+ # Block K G3 (Francisco 2026-04-22): destructive commands require an
1004
+ # explicit cortex decision before they execute. Gated by
1005
+ # NEXO_G3_ENFORCE_DESTRUCTIVE (default "shadow"): shadow records a
1006
+ # warn-severity debt for observability; hard blocks the operation
1007
+ # with error severity; off disables the gate entirely.
1008
+ g3_mode = os.environ.get("NEXO_G3_ENFORCE_DESTRUCTIVE", "shadow").strip().lower()
1009
+ if g3_mode in {"shadow", "hard"} and tool_name == "Bash":
1010
+ shell_command = _extract_bash_command(tool_input)
1011
+ destructive_pattern = _classify_destructive_intent(shell_command)
1012
+ if destructive_pattern:
1013
+ severity = "error" if g3_mode == "hard" else "warn"
1014
+ debt = _ensure_protocol_debt(
1015
+ conn,
1016
+ session_id=sid,
1017
+ task_id="",
1018
+ debt_type="g3_destructive_command_requires_cortex",
1019
+ severity=severity,
1020
+ evidence=(
1021
+ f"Bash command matched destructive pattern '{destructive_pattern}'. "
1022
+ f"Command head: {shell_command[:120]}. "
1023
+ "Run nexo_cortex_decide and record evidence before retrying."
1024
+ ),
1025
+ file_token=destructive_pattern,
1026
+ )
1027
+ if g3_mode == "hard":
1028
+ return {
1029
+ "ok": True,
1030
+ "session_id": sid,
1031
+ "tool_name": tool_name,
1032
+ "operation": op,
1033
+ "strictness": strictness,
1034
+ "blocks": [
1035
+ {
1036
+ "file": "",
1037
+ "task_id": "",
1038
+ "debt_id": debt.get("id"),
1039
+ "debt_type": "g3_destructive_command_requires_cortex",
1040
+ "reason_code": "g3_destructive_blocked",
1041
+ "severity": "error",
1042
+ "pattern": destructive_pattern,
1043
+ "g3_mode": g3_mode,
1044
+ }
1045
+ ],
1046
+ "status": "blocked",
1047
+ "g3_mode": g3_mode,
1048
+ }
1049
+
1050
+ # Block K G4 (Francisco 2026-04-22): require nexo_guard_check to have
1051
+ # run within the session for every file about to be written. Opt-in
1052
+ # via NEXO_G4_ENFORCE_GUARD_CHECK (default "shadow"): ``shadow``
1053
+ # records a protocol_debt entry of severity ``warn`` but does NOT
1054
+ # block the write; ``hard`` blocks the write with severity ``error``
1055
+ # so the operator must run guard_check explicitly. Skipped entirely
1056
+ # in lenient mode or when there are no files, since the existing
1057
+ # 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()
1059
+ if g4_mode in {"shadow", "hard"} and files and sid:
1060
+ g4_blocks: list[dict] = []
1061
+ g4_warnings: list[dict] = []
1062
+ for filepath in files:
1063
+ if _session_has_guard_for_file(conn, sid, filepath):
1064
+ continue
1065
+ severity = "error" if g4_mode == "hard" else "warn"
1066
+ debt = _ensure_protocol_debt(
1067
+ conn,
1068
+ session_id=sid,
1069
+ task_id="",
1070
+ debt_type="g4_guard_check_required",
1071
+ severity=severity,
1072
+ evidence=(
1073
+ f"{tool_name} attempted on {filepath} without a prior "
1074
+ "nexo_guard_check covering that file. "
1075
+ "Run nexo_guard_check(files='{path}') first."
1076
+ ).format(path=filepath),
1077
+ file_token=filepath,
1078
+ )
1079
+ entry = {
1080
+ "file": filepath,
1081
+ "task_id": "",
1082
+ "debt_id": debt.get("id"),
1083
+ "debt_type": "g4_guard_check_required",
1084
+ "reason_code": "g4_guard_check_required",
1085
+ "severity": severity,
1086
+ "g4_mode": g4_mode,
1087
+ }
1088
+ if g4_mode == "hard":
1089
+ g4_blocks.append(entry)
1090
+ else:
1091
+ g4_warnings.append(entry)
1092
+ if g4_blocks:
1093
+ return {
1094
+ "ok": True,
1095
+ "session_id": sid,
1096
+ "tool_name": tool_name,
1097
+ "operation": op,
1098
+ "strictness": strictness,
1099
+ "blocks": g4_blocks,
1100
+ "status": "blocked",
1101
+ "g4_mode": g4_mode,
1102
+ }
1103
+ # Shadow-mode warnings piggyback on the existing return path so
1104
+ # the surface stays observable without hijacking the control flow.
1105
+ if g4_warnings:
1106
+ # Store on the payload so callers that care about shadow
1107
+ # telemetry can pick it up. Do NOT return yet — continue
1108
+ # through the existing strict/lenient gates.
1109
+ # Stash under a well-known key for the post-tool hook.
1110
+ _shadow_cache = getattr(process_pre_tool_event, "_g4_shadow", None)
1111
+ if _shadow_cache is None:
1112
+ _shadow_cache = {}
1113
+ process_pre_tool_event._g4_shadow = _shadow_cache
1114
+ _shadow_cache[sid] = g4_warnings
1115
+
858
1116
  if strictness == "lenient":
859
1117
  return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
860
1118
 
@@ -250,6 +250,64 @@ if os.path.exists(evolution_file):
250
250
  except Exception:
251
251
  pass
252
252
 
253
+ # Guardian health (Block K G8 — Francisco 2026-04-22): surface the
254
+ # metrics the Guardian already tracks so Francisco sees them every
255
+ # morning without asking. Soft query: missing tables or schema drift
256
+ # must not fail the briefing; fall back to silence.
257
+ try:
258
+ _conn = sqlite3.connect(db_path)
259
+ _health = {}
260
+ # protocol_debt open count + breakdown by debt_type (top 5)
261
+ try:
262
+ row = _conn.execute(
263
+ \"SELECT COUNT(*) FROM protocol_debt WHERE resolved_at IS NULL\"
264
+ ).fetchone()
265
+ _health['open_debts_total'] = int(row[0] or 0) if row else 0
266
+ breakdown = _conn.execute(
267
+ \"SELECT debt_type, COUNT(*) AS n FROM protocol_debt \"
268
+ \"WHERE resolved_at IS NULL GROUP BY debt_type ORDER BY n DESC LIMIT 5\"
269
+ ).fetchall()
270
+ _health['open_debts_by_type'] = [(r[0] or 'unknown', int(r[1] or 0)) for r in breakdown]
271
+ except Exception:
272
+ pass
273
+ # Guard-check activity in the last 24h
274
+ try:
275
+ row = _conn.execute(
276
+ \"SELECT COUNT(*) FROM guard_checks \"
277
+ \"WHERE ts > (strftime('%s','now') - 24*3600)\"
278
+ ).fetchone()
279
+ _health['guard_checks_24h'] = int(row[0] or 0) if row else 0
280
+ except Exception:
281
+ pass
282
+ # Failing hook runs in the last 24h (non-zero exit)
283
+ try:
284
+ row = _conn.execute(
285
+ \"SELECT COUNT(*) FROM hook_runs \"
286
+ \"WHERE exit_code != 0 AND started_at > datetime('now', '-1 day')\"
287
+ ).fetchone()
288
+ _health['failing_hooks_24h'] = int(row[0] or 0) if row else 0
289
+ except Exception:
290
+ pass
291
+ _conn.close()
292
+ if _health:
293
+ lines.append('## Guardian Health')
294
+ debts_total = _health.get('open_debts_total', 0)
295
+ lines.append(f\"Open debts: {debts_total}\")
296
+ if debts_total > 10:
297
+ lines.append(' -> ACTION NEEDED: >10 open debts — run nexo_protocol_debt_list and resolve error-severity entries.')
298
+ for debt_type, count in _health.get('open_debts_by_type', []):
299
+ lines.append(f\" - {debt_type}: {count}\")
300
+ if 'guard_checks_24h' in _health:
301
+ lines.append(f\"guard_checks last 24h: {_health['guard_checks_24h']}\")
302
+ if 'failing_hooks_24h' in _health:
303
+ fh = _health['failing_hooks_24h']
304
+ lines.append(f\"failing hooks last 24h: {fh}\")
305
+ if fh > 0:
306
+ lines.append(' -> check nexo_hook_runs for details.')
307
+ lines.append('')
308
+ except Exception:
309
+ pass
310
+
253
311
  print('\n'.join(lines))
254
312
  " > "$BRIEFING_FILE" 2>/dev/null
255
313