nexo-brain 7.1.6 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +13 -7
- package/src/classifier_local.py +6 -1
- package/src/cli.py +27 -1
- package/src/db/_email_accounts.py +20 -1
- package/src/db/_schema.py +71 -0
- package/src/email_config.py +9 -3
- package/src/hook_guardrails.py +258 -0
- package/src/hooks/session-start.sh +58 -0
- package/src/plugins/protocol.py +41 -0
- package/src/scripts/backfill_task_owner.py +86 -1
- package/src/scripts/deep-sleep/phase_protocol_debt_drain.py +259 -0
- package/src/scripts/nexo-email-monitor.py +51 -10
- package/src/scripts/nexo_personal_automation.py +85 -0
- package/src/scripts/runner-health-check.py +314 -0
- package/src/server.py +232 -0
- package/templates/core-prompts/email-monitor.md +2 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
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.
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3398
|
+
from user_context import DEFAULT_ASSISTANT_NAME
|
|
3399
|
+
name = DEFAULT_ASSISTANT_NAME
|
|
3394
3400
|
|
|
3395
3401
|
return (
|
|
3396
3402
|
template_text
|
package/src/classifier_local.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/email_config.py
CHANGED
|
@@ -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
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
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"],
|
package/src/hook_guardrails.py
CHANGED
|
@@ -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
|
|