nexo-brain 7.16.3 → 7.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.16.3",
3
+ "version": "7.17.0",
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,9 @@
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.16.3` is the current packaged-runtime line. Patch release over v7.16.2 - the headless runner guard opts out of the runtime-core blocking rule because actual writes on those paths are already blocked at the PreToolUse layer. The duplicated pre-emptive check no longer aborts sessions over plain mentions of core helper script paths.
21
+ Version `7.17.0` is the current packaged-runtime line. Minor release over v7.16.3 - the headless runner pre-emptive guard becomes advisory: it surfaces learnings/schemas to the agent and logs to `guard_checks`, but never returns `blocked=True`. The PreToolUse hook is the authoritative gate at write time. This closes the family of bugs where heuristic path matches in the prompt aborted email-monitor sessions, followup-runner cycles, Deep Sleep synth, and postmortem-consolidation. Also rolls in the directory-path hardening planned for 7.16.4.
22
+
23
+ Previously in `7.16.3`: patch release over v7.16.2 - the headless runner guard opts out of the runtime-core blocking rule because actual writes on those paths are already blocked at the PreToolUse layer.
22
24
 
23
25
  Previously in `7.16.0`: minor release over v7.15.2 - Brain adds Memory Observations v2: evidence-backed event capture, derived observations, update-safe backfill, MCP retrieval, dashboard visibility, and safer refusal when memory lacks evidence.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.16.3",
3
+ "version": "7.17.0",
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",
@@ -439,6 +439,11 @@ def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
439
439
 
440
440
  for match in re.findall(r"(?<![A-Za-z0-9_])(?:/[^\s'\"`<>]+|[A-Za-z]:\\[^\s'\"`<>]+)", text):
441
441
  cleaned = match.rstrip(".,);:]")
442
+ # Drop trailing slashes — `/tmp/` and friends are directories, not
443
+ # edit targets. Without this the followup-runner prompt's mention
444
+ # of `/tmp/` reached `handle_guard_check`, which tried to `open()`
445
+ # the directory and crashed the whole pre-emptive guard.
446
+ cleaned = cleaned.rstrip("/")
442
447
  if not cleaned or cleaned in exec_only_paths:
443
448
  continue
444
449
  found.add(cleaned)
@@ -461,17 +466,47 @@ def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
461
466
 
462
467
 
463
468
  def _run_headless_runner_guard(*, caller: str, cwd: Path, prompt: str, allowed_tools: str) -> dict:
469
+ """Pre-emptive runner guard — observational, never blocking.
470
+
471
+ The pre-emptive guard scans the *prompt text* for paths the agent might
472
+ edit. That is heuristic: it cannot tell whether a path will actually be
473
+ written, read, executed, or just mentioned in passing. Treating the
474
+ learnings/blocking-rules surfaced by that heuristic as hard blockers
475
+ has caused a year's worth of operational pain — every time a learning's
476
+ ``applies_to`` happens to match a path the prompt mentions for any
477
+ reason, every cron, every email-monitor session, every Deep Sleep
478
+ synth aborts with exit 2.
479
+
480
+ The authoritative gate is the PreToolUse hook
481
+ (``hook_guardrails._collect_runtime_core_write_blocks`` and the
482
+ learning-aware blocks alongside it). That layer fires only when the
483
+ agent *actually attempts* a Write/Edit, with severity ``error``, and
484
+ prevents the protected mutation. The pre-emptive guard's job is to
485
+ surface relevant learnings/schemas to the agent up front so it can
486
+ reason about them, plus log to ``guard_checks`` for observability —
487
+ not to abort the run on a regex match.
488
+
489
+ Therefore this function always returns ``blocked=False``. Errors from
490
+ ``handle_guard_check`` are also non-blocking; we let the run start so
491
+ the PreToolUse layer can do its job, and we log the unavailability for
492
+ diagnostics.
493
+ """
464
494
  if not _runner_mutating_tools_allowed(allowed_tools):
465
495
  return {"blocked": False, "skipped": "read_only_tools"}
466
496
  guard_paths = _extract_runner_guard_paths(prompt, cwd)
467
497
  if not guard_paths:
468
498
  return {"blocked": False, "skipped": "no_explicit_paths"}
499
+ summary = ""
469
500
  try:
470
501
  runtime_root = str(NEXO_HOME)
471
502
  if runtime_root and runtime_root not in sys.path:
472
503
  sys.path.insert(0, runtime_root)
473
504
  from plugins.guard import handle_guard_check # type: ignore
474
505
 
506
+ # We still pass ``enforce_runtime_core_block="false"`` because that
507
+ # opt-out predates the advisory-only switch and other callers may
508
+ # still rely on it. With the advisory contract in place this flag
509
+ # is effectively redundant, but kept for back-compat.
475
510
  output = handle_guard_check(
476
511
  files=",".join(guard_paths),
477
512
  area=f"runner:{caller or 'headless'}",
@@ -479,17 +514,16 @@ def _run_headless_runner_guard(*, caller: str, cwd: Path, prompt: str, allowed_t
479
514
  include_schemas="true",
480
515
  enforce_runtime_core_block="false",
481
516
  )
517
+ summary = str(output or "")
482
518
  except Exception as exc:
483
- return {
484
- "blocked": True,
485
- "summary": f"Runner guard unavailable: {exc}",
486
- "paths": guard_paths,
487
- }
488
- blocked = "BLOCKING RULES" in str(output or "")
519
+ # Guard unavailable is observational, not blocking. The PreToolUse
520
+ # hook will catch any actual protected write at execute time.
521
+ summary = f"Runner guard unavailable: {exc}"
489
522
  return {
490
- "blocked": blocked,
491
- "summary": str(output or ""),
523
+ "blocked": False,
524
+ "summary": summary,
492
525
  "paths": guard_paths,
526
+ "advisory": True,
493
527
  }
494
528
 
495
529
 
@@ -494,12 +494,24 @@ def handle_guard_check(
494
494
  all_tables = set()
495
495
  for filepath in file_list:
496
496
  try:
497
+ # Skip directories silently — the path extractor that calls
498
+ # us is permissive and can hand back paths that end in `/`
499
+ # (e.g. `/tmp/`) or that resolve to real directories. Opening
500
+ # one of those raises IsADirectoryError, which is an OSError
501
+ # subclass; the prior except only caught FileNotFoundError /
502
+ # PermissionError, so the exception propagated and the whole
503
+ # runner pre-emptive guard returned `blocked=True` with
504
+ # "Runner guard unavailable: [Errno 21] Is a directory".
505
+ # That is exactly what was killing the followup-runner every
506
+ # hour after 7.16.0.
507
+ if Path(filepath).is_dir():
508
+ continue
497
509
  with open(filepath, 'r', errors='ignore') as f:
498
510
  content = f.read()
499
511
  sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE TABLE']
500
512
  if any(kw in content.upper() for kw in sql_keywords):
501
513
  all_tables.update(_extract_table_names(content))
502
- except (FileNotFoundError, PermissionError):
514
+ except (FileNotFoundError, PermissionError, IsADirectoryError, OSError):
503
515
  continue
504
516
 
505
517
  cache = _load_schema_cache()