nexo-brain 7.31.4 → 7.31.7
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 +6 -2
- package/package.json +1 -1
- package/src/auto_update.py +50 -22
- package/src/cli.py +2 -0
- package/src/enforcement_engine.py +247 -0
- package/src/evidence_ledger.py +31 -0
- package/src/guardian_config.py +3 -1
- package/src/hooks/post_tool_use.py +228 -2
- package/src/hooks/stop.py +112 -0
- package/src/local_context/api.py +23 -18
- package/src/local_context/health.py +9 -4
- package/src/local_context/usage_events.py +85 -0
- package/src/mcp_write_queue.py +21 -1
- package/src/plugins/protocol.py +272 -1
- package/src/plugins/workflow.py +99 -2
- package/src/pre_answer_router.py +114 -3
- package/src/pre_answer_runtime.py +3 -0
- package/src/presets/guardian_default.json +7 -4
- package/src/provider_circuit_breaker.py +18 -0
- package/src/rules/core-rules.json +11 -3
- package/src/scripts/deep-sleep/collect.py +40 -0
- package/src/scripts/jargon_first_response.py +12 -9
- package/src/scripts/nexo-email-monitor.py +235 -56
- package/templates/CLAUDE.md.template +1 -0
- package/templates/CODEX.AGENTS.md.template +1 -0
- package/templates/core-prompts/r26-jargon-rewrite.md +1 -0
- package/templates/core-prompts/r34-capability-reality-check.md +1 -0
- package/templates/core-prompts/r35-execute-before-ask.md +1 -0
- package/templates/core-prompts/r36-production-change-log-required.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +2 -1
- package/tool-enforcement-map.json +4 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.31.
|
|
3
|
+
"version": "7.31.7",
|
|
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,9 +18,13 @@
|
|
|
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.31.
|
|
21
|
+
Version `7.31.7` is the current packaged-runtime line. Patch release over v7.31.6 - stateful answers now require evidence before claiming release, commit, branch, server, ticket, deployment, sent/uploaded, installed, verified, or closed status. Guardian defaults promote identity coherence to hard/core and add a hard/core pre-answer evidence gate, while closeout and Local Context telemetry now leave stronger proof trails.
|
|
22
22
|
|
|
23
|
-
Previously in `7.31.
|
|
23
|
+
Previously in `7.31.6`: patch release over v7.31.5 - headless/email-monitor notifications now respect the Desktop UI language for static ES/EN templates before falling back to profile/calibration language and English.
|
|
24
|
+
|
|
25
|
+
Previously in `7.31.5`: patch release over v7.31.4 - the breaker resume notice exists: when a notified engine pause recovers, the operator gets exactly one resume email in their language, signed by their agent.
|
|
26
|
+
|
|
27
|
+
Previously in `7.31.4`: patch release over v7.31.3 - memory recall honours absolute time ranges (ISO dates, start..end ranges, datetimes, epochs) and enforces the window in SQL, so asking about a specific past day returns that day.
|
|
24
28
|
|
|
25
29
|
Previously in `7.30.33`: patch release over v7.30.32 - personal agent/script status now keeps the newest real run between manual executions and cron history, so a successful manual agent run cannot be hidden behind an older scheduled failure.
|
|
26
30
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.31.
|
|
3
|
+
"version": "7.31.7",
|
|
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
|
@@ -4727,6 +4727,54 @@ def _runtime_flat_files(base_dir: Path) -> list[str]:
|
|
|
4727
4727
|
return ordered
|
|
4728
4728
|
|
|
4729
4729
|
|
|
4730
|
+
_RUNTIME_RESOURCE_DIRS = {"crons", "hooks", "managed_mcp", "presets"}
|
|
4731
|
+
_RUNTIME_PACKAGE_IGNORE_DIRS = {
|
|
4732
|
+
"__pycache__",
|
|
4733
|
+
"bin",
|
|
4734
|
+
"node_modules",
|
|
4735
|
+
"scripts",
|
|
4736
|
+
"skills",
|
|
4737
|
+
"skills-core",
|
|
4738
|
+
"skills-runtime",
|
|
4739
|
+
"templates",
|
|
4740
|
+
"test",
|
|
4741
|
+
"tests",
|
|
4742
|
+
"vendor",
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
|
|
4746
|
+
def _pyproject_top_level_packages(repo_dir: Path, src_dir: Path) -> set[str]:
|
|
4747
|
+
pyproject = repo_dir / "pyproject.toml"
|
|
4748
|
+
if not pyproject.is_file():
|
|
4749
|
+
return set()
|
|
4750
|
+
try:
|
|
4751
|
+
text = pyproject.read_text(encoding="utf-8", errors="ignore")
|
|
4752
|
+
except Exception:
|
|
4753
|
+
return set()
|
|
4754
|
+
packages: set[str] = set()
|
|
4755
|
+
for match in re.finditer(r'["\']([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)["\']', text):
|
|
4756
|
+
top = match.group(1).split(".", 1)[0]
|
|
4757
|
+
if top in _RUNTIME_PACKAGE_IGNORE_DIRS:
|
|
4758
|
+
continue
|
|
4759
|
+
if (src_dir / top).is_dir():
|
|
4760
|
+
packages.add(top)
|
|
4761
|
+
return packages
|
|
4762
|
+
|
|
4763
|
+
|
|
4764
|
+
def _runtime_package_dirs(src_dir: Path, repo_dir: Path | None = None) -> list[str]:
|
|
4765
|
+
packages: set[str] = set()
|
|
4766
|
+
if src_dir.is_dir():
|
|
4767
|
+
for child in sorted(src_dir.iterdir(), key=lambda path: path.name):
|
|
4768
|
+
if not child.is_dir() or child.name.startswith(".") or child.name in _RUNTIME_PACKAGE_IGNORE_DIRS:
|
|
4769
|
+
continue
|
|
4770
|
+
if (child / "__init__.py").is_file():
|
|
4771
|
+
packages.add(child.name)
|
|
4772
|
+
if repo_dir is not None:
|
|
4773
|
+
packages.update(_pyproject_top_level_packages(repo_dir, src_dir))
|
|
4774
|
+
packages.update(dirname for dirname in _RUNTIME_RESOURCE_DIRS if (src_dir / dirname).is_dir())
|
|
4775
|
+
return sorted(packages)
|
|
4776
|
+
|
|
4777
|
+
|
|
4730
4778
|
def _installed_scripts_classification(dest: Path) -> dict[str, str]:
|
|
4731
4779
|
scripts_dest = dest / "scripts"
|
|
4732
4780
|
if dest != NEXO_HOME or not scripts_dest.is_dir():
|
|
@@ -4753,17 +4801,9 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
|
|
|
4753
4801
|
backup_dir = paths.create_backup_dir("runtime-tree")
|
|
4754
4802
|
|
|
4755
4803
|
code_dirs = [
|
|
4756
|
-
|
|
4804
|
+
*_runtime_package_dirs(dest),
|
|
4757
4805
|
"plugins",
|
|
4758
|
-
"db",
|
|
4759
|
-
"cognitive",
|
|
4760
|
-
"dashboard",
|
|
4761
|
-
"local_context",
|
|
4762
|
-
"product_knowledge",
|
|
4763
|
-
"rules",
|
|
4764
|
-
"crons",
|
|
4765
4806
|
"scripts",
|
|
4766
|
-
"doctor",
|
|
4767
4807
|
"skills",
|
|
4768
4808
|
"skills-core",
|
|
4769
4809
|
"skills-runtime",
|
|
@@ -4839,19 +4879,7 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
|
|
|
4839
4879
|
def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
|
|
4840
4880
|
import shutil
|
|
4841
4881
|
|
|
4842
|
-
packages =
|
|
4843
|
-
"db",
|
|
4844
|
-
"cognitive",
|
|
4845
|
-
"doctor",
|
|
4846
|
-
"local_context",
|
|
4847
|
-
"managed_mcp",
|
|
4848
|
-
"product_knowledge",
|
|
4849
|
-
"dashboard",
|
|
4850
|
-
"rules",
|
|
4851
|
-
"crons",
|
|
4852
|
-
"hooks",
|
|
4853
|
-
"presets",
|
|
4854
|
-
]
|
|
4882
|
+
packages = _runtime_package_dirs(src_dir, repo_dir)
|
|
4855
4883
|
flat_files = _runtime_flat_files(src_dir)
|
|
4856
4884
|
copied_packages = 0
|
|
4857
4885
|
copied_files = 0
|
package/src/cli.py
CHANGED
|
@@ -2045,6 +2045,7 @@ def _local_context_query(args) -> int:
|
|
|
2045
2045
|
max_chars=int(getattr(args, "max_chars", 20000) or 0),
|
|
2046
2046
|
include_entities=bool(getattr(args, "include_entities", False)),
|
|
2047
2047
|
include_relations=bool(getattr(args, "include_relations", False)),
|
|
2048
|
+
record_query=not bool(getattr(args, "no_record", False)),
|
|
2048
2049
|
),
|
|
2049
2050
|
args,
|
|
2050
2051
|
)
|
|
@@ -4094,6 +4095,7 @@ def main():
|
|
|
4094
4095
|
local_context_query_p.add_argument("--include-entities", action="store_true", help="Include matched entities in the JSON payload.")
|
|
4095
4096
|
local_context_query_p.add_argument("--include-relations", action="store_true", help="Include graph relations in the JSON payload.")
|
|
4096
4097
|
local_context_query_p.add_argument("--no-evidence-required", action="store_true", help="Allow empty evidence results")
|
|
4098
|
+
local_context_query_p.add_argument("--no-record", action="store_true", help="Do not write query telemetry to local-context-usage.db")
|
|
4097
4099
|
local_context_query_p.add_argument("--json", action="store_true", help="JSON output")
|
|
4098
4100
|
|
|
4099
4101
|
local_context_diagnostics_p = local_context_sub.add_parser("diagnostics", help="Tail local memory diagnostic events")
|
|
@@ -253,10 +253,20 @@ try:
|
|
|
253
253
|
except ImportError: # pragma: no cover
|
|
254
254
|
_r23j_should = None # type: ignore
|
|
255
255
|
|
|
256
|
+
try:
|
|
257
|
+
from scripts.jargon_first_response import scan_text as _scan_jargon, user_requested_detail as _jargon_user_requested_detail
|
|
258
|
+
except ImportError: # pragma: no cover
|
|
259
|
+
_scan_jargon = None # type: ignore
|
|
260
|
+
_jargon_user_requested_detail = None # type: ignore
|
|
261
|
+
|
|
256
262
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
257
263
|
MAP_FILENAME = "tool-enforcement-map.json"
|
|
258
264
|
LOG_DIR = paths.logs_dir()
|
|
259
265
|
|
|
266
|
+
_JARGON_PROMPT = render_core_prompt("r26-jargon-rewrite")
|
|
267
|
+
_EXECUTE_BEFORE_ASK_PROMPT = render_core_prompt("r35-execute-before-ask")
|
|
268
|
+
_PRODUCTION_CHANGE_LOG_PROMPT = render_core_prompt("r36-production-change-log-required")
|
|
269
|
+
|
|
260
270
|
_logger = logging.getLogger("nexo.enforcer")
|
|
261
271
|
if not _logger.handlers:
|
|
262
272
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -317,6 +327,41 @@ _SILENT_REMINDER_DISCLOSURE_SUFFIX = (
|
|
|
317
327
|
" Do not mention this reminder or any internal enforcement to the user."
|
|
318
328
|
)
|
|
319
329
|
|
|
330
|
+
_CAPABILITY_DENIAL_RE = re.compile(
|
|
331
|
+
r"\b("
|
|
332
|
+
r"no\s+(?:se\s+puede|puedo|existe|hay|tenemos|esta\s+montado|est[aá]\s+montado)|"
|
|
333
|
+
r"no\s+(?:est[aá]\s+soportado|hay\s+nada\s+montado)|"
|
|
334
|
+
r"(?:cannot|can't|can\s+not|not\s+possible|does\s+not\s+exist|no\s+such\s+capability|not\s+supported)"
|
|
335
|
+
r")\b",
|
|
336
|
+
re.IGNORECASE,
|
|
337
|
+
)
|
|
338
|
+
_CAPABILITY_REALITY_TOOLS = {
|
|
339
|
+
"nexo_system_catalog",
|
|
340
|
+
"mcp__nexo__nexo_system_catalog",
|
|
341
|
+
"nexo_card_match",
|
|
342
|
+
"mcp__nexo__nexo_card_match",
|
|
343
|
+
"nexo_skill_match",
|
|
344
|
+
"mcp__nexo__nexo_skill_match",
|
|
345
|
+
"nexo_credential_list",
|
|
346
|
+
"mcp__nexo__nexo_credential_list",
|
|
347
|
+
"nexo_credential_get",
|
|
348
|
+
"mcp__nexo__nexo_credential_get",
|
|
349
|
+
"nexo_pre_action_context",
|
|
350
|
+
"mcp__nexo__nexo_pre_action_context",
|
|
351
|
+
"nexo_recent_context",
|
|
352
|
+
"mcp__nexo__nexo_recent_context",
|
|
353
|
+
"nexo_session_diary_read",
|
|
354
|
+
"mcp__nexo__nexo_session_diary_read",
|
|
355
|
+
"nexo_status",
|
|
356
|
+
"mcp__nexo__nexo_status",
|
|
357
|
+
"Read",
|
|
358
|
+
"Grep",
|
|
359
|
+
"Glob",
|
|
360
|
+
"Bash",
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
_CAPABILITY_REALITY_PROMPT = render_core_prompt("r34-capability-reality-check")
|
|
364
|
+
|
|
320
365
|
|
|
321
366
|
def _redact_for_log(text: str, max_len: int = 200) -> str:
|
|
322
367
|
"""Return a log-safe truncation of `text` with secret-like tokens
|
|
@@ -333,6 +378,11 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
|
|
|
333
378
|
return out
|
|
334
379
|
|
|
335
380
|
|
|
381
|
+
def _security_followup_id(seed: str) -> str:
|
|
382
|
+
digest = hashlib.sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()[:10].upper()
|
|
383
|
+
return f"NF-SECURITY-EXPOSED-CREDENTIAL-{digest}"
|
|
384
|
+
|
|
385
|
+
|
|
336
386
|
def _upgrade_silent_reminder_prompt(prompt: str) -> str:
|
|
337
387
|
"""Normalize old silent-reminder copy to the full turn-wide contract.
|
|
338
388
|
|
|
@@ -416,6 +466,7 @@ class HeadlessEnforcer:
|
|
|
416
466
|
# R25 — last user message is inspected for an explicit permit token
|
|
417
467
|
# ("force OK", "si borra", etc). Populated by on_user_message.
|
|
418
468
|
self._r25_last_user_text = ""
|
|
469
|
+
self._first_assistant_text_checked_for_jargon = False
|
|
419
470
|
# R17 promise-debt state. Opened on a detected promise, counts
|
|
420
471
|
# down on each tool call.
|
|
421
472
|
self._r17_window_remaining = 0
|
|
@@ -433,6 +484,8 @@ class HeadlessEnforcer:
|
|
|
433
484
|
# it as a bool avoids carrying stale push context across
|
|
434
485
|
# unrelated tool chains.
|
|
435
486
|
self._r23i_recent_push = False
|
|
487
|
+
self._production_mutation_tool_instance: int | None = None
|
|
488
|
+
self._production_mutation_evidence: str = ""
|
|
436
489
|
# R23m — circular buffer of outbound-message sends with
|
|
437
490
|
# {thread, body, ts}. Capped at 16 entries.
|
|
438
491
|
self._r23m_recent_messages: list[dict] = []
|
|
@@ -677,6 +730,7 @@ class HeadlessEnforcer:
|
|
|
677
730
|
# R15/R25 context MUST be updated regardless of R14 module availability
|
|
678
731
|
# (critical fix: R14 import failure was silently killing R15/R25 too).
|
|
679
732
|
self._r25_last_user_text = text or ""
|
|
733
|
+
self._first_assistant_text_checked_for_jargon = False
|
|
680
734
|
try:
|
|
681
735
|
self.on_user_message_r15(text or "")
|
|
682
736
|
except Exception as _r15_exc: # noqa: BLE001
|
|
@@ -925,6 +979,11 @@ class HeadlessEnforcer:
|
|
|
925
979
|
shadow → logs only. soft/hard → enqueues the reminder. Dedup 60s
|
|
926
980
|
via the standard _enqueue tag guard.
|
|
927
981
|
"""
|
|
982
|
+
if not self._first_assistant_text_checked_for_jargon:
|
|
983
|
+
self._first_assistant_text_checked_for_jargon = True
|
|
984
|
+
self._check_jargon_text(text, tag="r26:first-response-jargon")
|
|
985
|
+
self._check_execute_before_ask(text)
|
|
986
|
+
self._check_capability_denial_requires_reality(text)
|
|
928
987
|
if _detect_declared_done is None:
|
|
929
988
|
return
|
|
930
989
|
mode = self._guardian_rule_mode("R16_declared_done")
|
|
@@ -959,6 +1018,25 @@ class HeadlessEnforcer:
|
|
|
959
1018
|
except Exception:
|
|
960
1019
|
pass
|
|
961
1020
|
|
|
1021
|
+
def _check_capability_denial_requires_reality(self, text: str):
|
|
1022
|
+
"""Block unsupported capability denials until a live source was checked."""
|
|
1023
|
+
if not text or not _CAPABILITY_DENIAL_RE.search(text):
|
|
1024
|
+
return
|
|
1025
|
+
if self.tools_called.intersection(_CAPABILITY_REALITY_TOOLS):
|
|
1026
|
+
return
|
|
1027
|
+
mode = self._guardian_rule_mode("R34_capability_reality_check")
|
|
1028
|
+
if mode == "off":
|
|
1029
|
+
return
|
|
1030
|
+
if mode == "shadow":
|
|
1031
|
+
_logger.info("[R34 SHADOW] would inject capability reality check")
|
|
1032
|
+
return
|
|
1033
|
+
self._enqueue(
|
|
1034
|
+
_CAPABILITY_REALITY_PROMPT,
|
|
1035
|
+
"r34:capability-denial-without-reality-check",
|
|
1036
|
+
rule_id="R34_capability_reality_check",
|
|
1037
|
+
)
|
|
1038
|
+
_logger.info("[R34 %s] enqueued capability reality check", mode.upper())
|
|
1039
|
+
|
|
962
1040
|
def _r25_context(self) -> tuple[set[str], list[str]]:
|
|
963
1041
|
"""Resolve the (read_only_hosts, destructive_patterns) pair from
|
|
964
1042
|
the shared entities registry. Returns empty sets/list on any
|
|
@@ -1149,6 +1227,130 @@ class HeadlessEnforcer:
|
|
|
1149
1227
|
except Exception as exc: # noqa: BLE001
|
|
1150
1228
|
_logger.debug("R17 commitment resolution skipped: %s", exc)
|
|
1151
1229
|
|
|
1230
|
+
def _check_jargon_text(self, text: str, *, tag: str) -> None:
|
|
1231
|
+
if _scan_jargon is None:
|
|
1232
|
+
return
|
|
1233
|
+
clean = (text or "").strip()
|
|
1234
|
+
if not clean:
|
|
1235
|
+
return
|
|
1236
|
+
if _jargon_user_requested_detail is not None and _jargon_user_requested_detail(self._r25_last_user_text or ""):
|
|
1237
|
+
return
|
|
1238
|
+
try:
|
|
1239
|
+
matches = _scan_jargon(clean)
|
|
1240
|
+
except Exception as exc: # noqa: BLE001
|
|
1241
|
+
_logger.warning("jargon scan failed (%s); staying silent", exc)
|
|
1242
|
+
return
|
|
1243
|
+
if not matches:
|
|
1244
|
+
return
|
|
1245
|
+
mode = self._guardian_rule_mode("R26_jargon_filter")
|
|
1246
|
+
if mode == "off":
|
|
1247
|
+
return
|
|
1248
|
+
if mode == "shadow":
|
|
1249
|
+
_logger.info("[R26 SHADOW] would inject jargon rewrite: %s", [m.get("token") for m in matches[:5]])
|
|
1250
|
+
return
|
|
1251
|
+
self._enqueue(_JARGON_PROMPT, tag, rule_id="R26_jargon_filter")
|
|
1252
|
+
|
|
1253
|
+
def _check_execute_before_ask(self, text: str) -> None:
|
|
1254
|
+
user = (self._r25_last_user_text or "").lower()
|
|
1255
|
+
reply = (text or "").lower()
|
|
1256
|
+
if not user or not reply:
|
|
1257
|
+
return
|
|
1258
|
+
imperative = re.search(r"\b(hazlo|mira|reactiva|ejecuta|arregla|corrige|aplica|dale|haz|revisa|comprueba)\b", user)
|
|
1259
|
+
asking = (
|
|
1260
|
+
"?" in reply
|
|
1261
|
+
or "tengo dos decisiones" in reply
|
|
1262
|
+
or "elige" in reply
|
|
1263
|
+
or "quieres que" in reply
|
|
1264
|
+
or "confirmas" in reply
|
|
1265
|
+
or "necesito que decidas" in reply
|
|
1266
|
+
)
|
|
1267
|
+
hard_boundary = re.search(
|
|
1268
|
+
r"\b(credencial|contraseñ|password|pago|payment|destructiv|irreversible|borrar|delete|revocar|rotar|publicar|publish|dns|legal)\b",
|
|
1269
|
+
user + "\n" + reply,
|
|
1270
|
+
)
|
|
1271
|
+
if not imperative or not asking or hard_boundary:
|
|
1272
|
+
return
|
|
1273
|
+
mode = self._guardian_rule_mode("R35_execute_before_ask")
|
|
1274
|
+
if mode == "off":
|
|
1275
|
+
return
|
|
1276
|
+
if mode == "shadow":
|
|
1277
|
+
_logger.info("[R35 SHADOW] would inject execute-before-ask")
|
|
1278
|
+
return
|
|
1279
|
+
self._enqueue(_EXECUTE_BEFORE_ASK_PROMPT, "r35:execute-before-ask", rule_id="R35_execute_before_ask")
|
|
1280
|
+
|
|
1281
|
+
def _production_mutation_summary(self, tool_name: str, tool_input) -> str:
|
|
1282
|
+
if tool_name not in {"Bash", "mcp__nexo__Bash"} or not isinstance(tool_input, dict):
|
|
1283
|
+
return ""
|
|
1284
|
+
cmd = str(tool_input.get("command") or "")
|
|
1285
|
+
if not cmd:
|
|
1286
|
+
return ""
|
|
1287
|
+
patterns = (
|
|
1288
|
+
r"\bgit\s+push\b(?!.*--dry-run)(?=.*\b(?:main|master|release|stable)\b)",
|
|
1289
|
+
r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:\S+",
|
|
1290
|
+
r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot)\b",
|
|
1291
|
+
r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*['\"]",
|
|
1292
|
+
r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/)[^'\"]*['\"]",
|
|
1293
|
+
r"\bnpm\s+publish\b",
|
|
1294
|
+
r"\bupload-release\.sh\b",
|
|
1295
|
+
r"\bgcloud\s+builds\s+(?:submit|triggers\s+run)\b",
|
|
1296
|
+
r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
|
|
1297
|
+
r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
|
|
1298
|
+
r"\b(?:whmapi1|uapi|cpapi2)\b",
|
|
1299
|
+
r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
|
|
1300
|
+
r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
|
|
1301
|
+
)
|
|
1302
|
+
for pattern in patterns:
|
|
1303
|
+
if re.search(pattern, cmd, re.IGNORECASE | re.DOTALL):
|
|
1304
|
+
return cmd[:300]
|
|
1305
|
+
return ""
|
|
1306
|
+
|
|
1307
|
+
def _task_close_has_change_trace(self, tool_input) -> bool:
|
|
1308
|
+
payload = tool_input if isinstance(tool_input, dict) else {}
|
|
1309
|
+
fields = (
|
|
1310
|
+
"files_changed",
|
|
1311
|
+
"change_summary",
|
|
1312
|
+
"change_why",
|
|
1313
|
+
"change_verify",
|
|
1314
|
+
"evidence_refs",
|
|
1315
|
+
"evidence",
|
|
1316
|
+
"verification",
|
|
1317
|
+
)
|
|
1318
|
+
joined = "\n".join(str(payload.get(field) or "") for field in fields).lower()
|
|
1319
|
+
if "change_log:" in joined or "nexo_change_log" in joined:
|
|
1320
|
+
return True
|
|
1321
|
+
return bool(str(payload.get("files_changed") or "").strip() and str(payload.get("change_summary") or "").strip())
|
|
1322
|
+
|
|
1323
|
+
def _check_production_change_log_close(self, tool_name: str, tool_input) -> None:
|
|
1324
|
+
if tool_name in {"nexo_change_log", "mcp__nexo__nexo_change_log"}:
|
|
1325
|
+
self._production_mutation_tool_instance = None
|
|
1326
|
+
self._production_mutation_evidence = ""
|
|
1327
|
+
return
|
|
1328
|
+
summary = self._production_mutation_summary(tool_name, tool_input)
|
|
1329
|
+
if summary:
|
|
1330
|
+
self._production_mutation_tool_instance = self._tool_instance_counter
|
|
1331
|
+
self._production_mutation_evidence = summary
|
|
1332
|
+
return
|
|
1333
|
+
if tool_name not in {"nexo_task_close", "mcp__nexo__nexo_task_close"}:
|
|
1334
|
+
return
|
|
1335
|
+
if self._production_mutation_tool_instance is None:
|
|
1336
|
+
return
|
|
1337
|
+
if self._task_close_has_change_trace(tool_input):
|
|
1338
|
+
self._production_mutation_tool_instance = None
|
|
1339
|
+
self._production_mutation_evidence = ""
|
|
1340
|
+
return
|
|
1341
|
+
mode = self._guardian_rule_mode("R36_production_change_log")
|
|
1342
|
+
if mode == "off":
|
|
1343
|
+
return
|
|
1344
|
+
if mode == "shadow":
|
|
1345
|
+
_logger.info("[R36 SHADOW] would inject production change_log requirement")
|
|
1346
|
+
return
|
|
1347
|
+
self.injection_queue.append({
|
|
1348
|
+
"prompt": _PRODUCTION_CHANGE_LOG_PROMPT,
|
|
1349
|
+
"tag": "r36:production-change-log",
|
|
1350
|
+
"rule_id": "R36_production_change_log",
|
|
1351
|
+
})
|
|
1352
|
+
_logger.info("[R36 %s] enqueued production change_log requirement", mode.upper())
|
|
1353
|
+
|
|
1152
1354
|
def _advance_r17_window(self, tool_name: str):
|
|
1153
1355
|
if not self._r17_promise_seen_for_turn:
|
|
1154
1356
|
return
|
|
@@ -1713,10 +1915,45 @@ class HeadlessEnforcer:
|
|
|
1713
1915
|
return
|
|
1714
1916
|
if mode == "shadow":
|
|
1715
1917
|
_logger.info("[R23g SHADOW] would inject")
|
|
1918
|
+
self._ensure_exposed_credential_followup(tool_input, reason="R23g shadow")
|
|
1716
1919
|
return
|
|
1717
1920
|
self._enqueue(prompt, "R23g_secrets_in_output", rule_id="R23g_secrets_in_output")
|
|
1921
|
+
self._ensure_exposed_credential_followup(tool_input, reason="R23g detected secret exposure risk")
|
|
1718
1922
|
_logger.info("[R23g %s] enqueued", mode.upper())
|
|
1719
1923
|
|
|
1924
|
+
def _ensure_exposed_credential_followup(self, tool_input, *, reason: str) -> None:
|
|
1925
|
+
if not isinstance(tool_input, dict):
|
|
1926
|
+
return
|
|
1927
|
+
cmd = tool_input.get("command")
|
|
1928
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
1929
|
+
return
|
|
1930
|
+
safe_cmd = _redact_for_log(cmd, max_len=160)
|
|
1931
|
+
followup_id = _security_followup_id(f"{reason}:{safe_cmd}")
|
|
1932
|
+
try:
|
|
1933
|
+
from db import create_followup, get_followup # type: ignore
|
|
1934
|
+
|
|
1935
|
+
if get_followup(followup_id):
|
|
1936
|
+
return
|
|
1937
|
+
create_followup(
|
|
1938
|
+
followup_id,
|
|
1939
|
+
description=(
|
|
1940
|
+
"SEGURIDAD: credencial expuesta o en riesgo detectada por el guard. "
|
|
1941
|
+
f"Origen: {safe_cmd}. Rotar/revocar la credencial y sustituirla en el gestor seguro."
|
|
1942
|
+
),
|
|
1943
|
+
date=time.strftime("%Y-%m-%d"),
|
|
1944
|
+
verification=(
|
|
1945
|
+
"Cierre solo con evidencia de revocación efectiva: llamada/API/HTTP 401 para la credencial antigua "
|
|
1946
|
+
"o comprobación oficial equivalente, más nueva ubicación segura registrada."
|
|
1947
|
+
),
|
|
1948
|
+
reasoning=reason,
|
|
1949
|
+
priority="critical",
|
|
1950
|
+
internal=1,
|
|
1951
|
+
owner="agent",
|
|
1952
|
+
)
|
|
1953
|
+
_logger.info("[R23g] security followup created: %s", followup_id)
|
|
1954
|
+
except Exception as exc: # noqa: BLE001
|
|
1955
|
+
_logger.warning("R23g security followup create failed: %s", exc)
|
|
1956
|
+
|
|
1720
1957
|
def _check_r23i(self, tool_name: str, tool_input):
|
|
1721
1958
|
"""R23i — Edit after recent git push on auto_deploy project (soft)."""
|
|
1722
1959
|
if _r23i_should is None or _r23i_is_push is None:
|
|
@@ -2285,12 +2522,22 @@ class HeadlessEnforcer:
|
|
|
2285
2522
|
else:
|
|
2286
2523
|
self._conditional_counters[tool] = self._conditional_counters.get(tool, 0) + 1
|
|
2287
2524
|
|
|
2525
|
+
if name != "nexo_task_close":
|
|
2526
|
+
self._check_production_change_log_close(name, tool_input)
|
|
2527
|
+
|
|
2288
2528
|
# v7.6 task_close observed → rearm conditional for the companion
|
|
2289
2529
|
# open tool so the next task cycle re-opens the obligation.
|
|
2290
2530
|
if name == "nexo_task_close":
|
|
2531
|
+
if isinstance(tool_input, dict):
|
|
2532
|
+
close_text = "\n".join(
|
|
2533
|
+
str(tool_input.get(field) or "")
|
|
2534
|
+
for field in ("summary", "result", "evidence", "verification", "outcome_notes", "change_summary")
|
|
2535
|
+
)
|
|
2536
|
+
self._check_jargon_text(close_text, tag="r26:task-close-jargon")
|
|
2291
2537
|
self._last_task_close_user_message_count = int(self.user_message_count or 0)
|
|
2292
2538
|
self.reset_task_cycle("nexo_task_open")
|
|
2293
2539
|
self._start_post_close_cooldown()
|
|
2540
|
+
self._check_production_change_log_close(name, tool_input)
|
|
2294
2541
|
self._resolve_r17_commitments_from_task_close(tool_input)
|
|
2295
2542
|
|
|
2296
2543
|
if name == "nexo_stop":
|
package/src/evidence_ledger.py
CHANGED
|
@@ -892,6 +892,37 @@ def _collect_local_context(conn: sqlite3.Connection, limit: int, allowed: set[st
|
|
|
892
892
|
privacy_level="metadata",
|
|
893
893
|
)
|
|
894
894
|
)
|
|
895
|
+
try:
|
|
896
|
+
from local_context import usage_events
|
|
897
|
+
|
|
898
|
+
for row in usage_events.list_recent_query_events(limit=limit):
|
|
899
|
+
intent = str(row.get("intent") or "answer")
|
|
900
|
+
intent_label = intent.replace("_", " ").replace("-", " ")
|
|
901
|
+
entries.append(
|
|
902
|
+
_entry(
|
|
903
|
+
evidence_id=f"local_context_usage:{row.get('event_id')}",
|
|
904
|
+
source_type="local_context",
|
|
905
|
+
source_id=str(row.get("event_id") or ""),
|
|
906
|
+
created_at=row.get("created_at"),
|
|
907
|
+
client=row.get("client"),
|
|
908
|
+
object_type="local_context_query",
|
|
909
|
+
object_ref=row.get("query_hash"),
|
|
910
|
+
action=intent,
|
|
911
|
+
summary=(
|
|
912
|
+
f"local-context query intent={intent_label} "
|
|
913
|
+
f"result_count={row.get('result_count') or 0}"
|
|
914
|
+
),
|
|
915
|
+
refs=[],
|
|
916
|
+
confidence=0.75 if int(row.get("evidence_refs_count") or 0) > 0 else 0.0,
|
|
917
|
+
privacy_level="metadata",
|
|
918
|
+
metadata={
|
|
919
|
+
"store": "local-context-usage.db",
|
|
920
|
+
"route_stage": row.get("route_stage") or "",
|
|
921
|
+
},
|
|
922
|
+
)
|
|
923
|
+
)
|
|
924
|
+
except Exception:
|
|
925
|
+
pass
|
|
895
926
|
return entries
|
|
896
927
|
|
|
897
928
|
|
package/src/guardian_config.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Fase 2 spec items 0.5 + 0.19. Provides the canonical loader for the
|
|
4
4
|
Guardian configuration and the validator that enforces the core-rule
|
|
5
|
-
invariant: R13, R14, R16, R25, R30 can only be shadow / soft / hard.
|
|
5
|
+
invariant: R13, R14, R16, R25, R30, R34, R37 can only be shadow / soft / hard.
|
|
6
6
|
A mode of 'off' is rejected with a clear error so the operator cannot
|
|
7
7
|
accidentally disable a rule that Fase 2 declared non-negotiable.
|
|
8
8
|
|
|
@@ -43,6 +43,8 @@ CORE_RULES: frozenset[str] = frozenset({
|
|
|
43
43
|
"R16_declared_done",
|
|
44
44
|
"R25_nora_maria_read_only",
|
|
45
45
|
"R30_pre_done_evidence_system_prompt",
|
|
46
|
+
"R34_identity_coherence",
|
|
47
|
+
"R37_pre_answer_evidence_gate",
|
|
46
48
|
})
|
|
47
49
|
|
|
48
50
|
VALID_MODES: frozenset[str] = frozenset({"off", "shadow", "soft", "hard"})
|