nexo-brain 7.17.6 → 7.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.17.6",
3
+ "version": "7.17.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,7 +18,11 @@
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.17.6` is the current packaged-runtime line. Patch release over v7.17.5 - cron health diagnostics are clearer for macOS TCC approval, and catch-up fallback executions now stay visible in `cron_runs` even on legacy or partially migrated runtimes.
21
+ Version `7.17.7` is the current packaged-runtime line. Patch release over v7.17.6 - macOS TCC privacy denials now become a guided Full Disk Access permission state instead of an unexplained cron failure, with Desktop-ready status written for user action.
22
+
23
+ Previously in `7.17.6`: patch release over v7.17.5 - cron health diagnostics are clearer for macOS TCC approval, and catch-up fallback executions now stay visible in `cron_runs` even on legacy or partially migrated runtimes.
24
+
25
+ Previously in `7.17.5`: patch release over v7.17.4 - `nexo --version --json` now returns machine-readable update status so NEXO Desktop can populate the Updates panel without scraping slower human output.
22
26
 
23
27
  Previously in `7.17.4`: corrective patch over v7.17.3 - automation runners now keep full NEXO discipline for real background agents while strict JSON children stay clean, and runtime doctor/metrics expose caller coverage and Guardian injection telemetry instead of hiding blind spots.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.17.6",
3
+ "version": "7.17.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",
@@ -550,7 +550,12 @@ def _tail_has_permission_denial(log_file: Path) -> bool:
550
550
  size = fh.tell()
551
551
  fh.seek(max(size - 4096, 0))
552
552
  tail = fh.read().decode("utf-8", errors="ignore")
553
- return "Operation not permitted" in tail
553
+ lowered = tail.lower()
554
+ return (
555
+ "operation not permitted" in lowered
556
+ or "authorization denied" in lowered
557
+ or "unable to open database" in lowered and "com.apple.tcc/tcc.db" in lowered
558
+ )
554
559
  except Exception:
555
560
  return False
556
561
 
@@ -568,10 +573,12 @@ def detect_full_disk_access_reasons(*, system: str | None = None) -> list[str]:
568
573
 
569
574
  logs_dir = paths.logs_dir()
570
575
  if logs_dir.is_dir():
571
- for log_file in sorted(logs_dir.glob("*-stderr.log")):
576
+ log_files = list(logs_dir.glob("*-stderr.log"))
577
+ log_files.extend(logs_dir.glob("tcc-auto-approve.log"))
578
+ for log_file in sorted(log_files):
572
579
  if _tail_has_permission_denial(log_file):
573
580
  reasons.append(
574
- f"Recent background job stderr hit 'Operation not permitted' ({log_file.name})"
581
+ f"Recent background job hit a macOS privacy denial ({log_file.name})"
575
582
  )
576
583
  break
577
584
  return reasons
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """First-response jargon checker (Communication Guardrail enforcement).
3
+
4
+ Implements followup NF-DS-32D6E12E: a linter that scans the FIRST visible
5
+ message the agent emits in a turn for NEXO internal jargon that violates
6
+ the Communication Guardrail in CLAUDE.md.
7
+
8
+ Token list (case-insensitive substring match), taken verbatim from the
9
+ followup spec:
10
+
11
+ Learning #, protocol debt, cortex eval, runtime-core, guard_check,
12
+ heartbeat, pre-emptive guard, enforcer, task_open, task_close, NF-
13
+
14
+ Use as:
15
+
16
+ * Library:
17
+ from src.scripts.jargon_first_response import scan_text, register_debt_if_violations
18
+
19
+ * CLI (manual review of a recent reply):
20
+ echo "Learning #42 applied via task_open" \
21
+ | python3 src/scripts/jargon_first_response.py --stdin --session-id NEXO-SID
22
+
23
+ * Future hook integration (PostToolUse / Stop) loads the transcript with
24
+ `transcript_utils.load_transcript` and pipes the latest assistant
25
+ message through `register_debt_if_violations`. Hook wiring is left as
26
+ a separate change so the linter ships independently testable.
27
+
28
+ Exit codes (CLI):
29
+ 0 — no violations detected (or only allowed because user requested detail)
30
+ 1 — at least one prohibited token found
31
+ 2 — invocation error (missing input)
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import os
37
+ import re
38
+ import sys
39
+ from pathlib import Path
40
+ from typing import Iterable, List, Sequence
41
+
42
+ # Order matters only for stable output. Longer / multi-word phrases first
43
+ # so the snippet boundary is clearer in the matches view.
44
+ PROHIBITED_TOKENS: Sequence[str] = (
45
+ "Learning #",
46
+ "protocol debt",
47
+ "cortex eval",
48
+ "runtime-core",
49
+ "guard_check",
50
+ "pre-emptive guard",
51
+ "enforcer",
52
+ "task_open",
53
+ "task_close",
54
+ "heartbeat",
55
+ "NF-",
56
+ )
57
+
58
+ # Heuristic signals from the *user* message that mean "operator asked for
59
+ # technical detail, jargon is allowed in the reply". The first answer
60
+ # only counts as a guardrail violation when none of these are present.
61
+ _USER_DETAIL_PATTERNS = (
62
+ re.compile(r"\bdebugg(?:ing|er)?\b", re.IGNORECASE),
63
+ re.compile(r"\bstack\s*trace\b", re.IGNORECASE),
64
+ re.compile(r"\b(?:protocol\s+debt|guard_check|task_open|task_close|enforcer|heartbeat)\b", re.IGNORECASE),
65
+ re.compile(r"\b(?:nf-[a-z0-9-]+|learning\s+#?\d+)\b", re.IGNORECASE),
66
+ re.compile(r"\b(internal|runtime|architecture|cortex|drive)\b", re.IGNORECASE),
67
+ re.compile(r"\b(?:explica|explain|deep[\s-]*dive|d[ée]tailled|d[ée]tail)\b", re.IGNORECASE),
68
+ )
69
+
70
+
71
+ def _first_visible_paragraph(text: str) -> str:
72
+ """Return the first ~600 chars of operator-visible text.
73
+
74
+ Strips leading whitespace, blank lines, and ignores fenced code blocks
75
+ at the very top (release notes / code-only first messages are not
76
+ "first response prose" for the guardrail purpose).
77
+ """
78
+ if not text:
79
+ return ""
80
+ cleaned = text.lstrip()
81
+ if cleaned.startswith("```"):
82
+ end = cleaned.find("```", 3)
83
+ if end != -1:
84
+ cleaned = cleaned[end + 3 :].lstrip()
85
+ # First two paragraphs is usually plenty for the linter; longer
86
+ # replies have time to recover into plain language.
87
+ paragraphs = re.split(r"\n\s*\n", cleaned)
88
+ head = "\n\n".join(paragraphs[:2])
89
+ return head[:600]
90
+
91
+
92
+ def user_requested_detail(user_message: str) -> bool:
93
+ """True if the operator explicitly asked for technical / NEXO-internal detail."""
94
+ if not user_message:
95
+ return False
96
+ return any(pat.search(user_message) for pat in _USER_DETAIL_PATTERNS)
97
+
98
+
99
+ def scan_text(text: str, *, tokens: Iterable[str] = PROHIBITED_TOKENS) -> List[dict]:
100
+ """Return a list of `{token, index, snippet}` for each prohibited match."""
101
+ if not text:
102
+ return []
103
+ target = _first_visible_paragraph(text)
104
+ if not target:
105
+ return []
106
+ lowered = target.lower()
107
+ found: List[dict] = []
108
+ for token in tokens:
109
+ needle = token.lower()
110
+ start = 0
111
+ while True:
112
+ idx = lowered.find(needle, start)
113
+ if idx < 0:
114
+ break
115
+ snippet_start = max(0, idx - 25)
116
+ snippet_end = min(len(target), idx + len(token) + 25)
117
+ found.append({
118
+ "token": token,
119
+ "index": idx,
120
+ "snippet": target[snippet_start:snippet_end].replace("\n", " "),
121
+ })
122
+ start = idx + max(1, len(needle))
123
+ found.sort(key=lambda row: row["index"])
124
+ return found
125
+
126
+
127
+ def register_debt_if_violations(
128
+ session_id: str,
129
+ assistant_text: str,
130
+ *,
131
+ user_message: str = "",
132
+ task_id: str = "",
133
+ evidence_prefix: str = "",
134
+ ) -> dict:
135
+ """Register a protocol_debt of type `communication_guardrail` when the
136
+ first response uses prohibited NEXO jargon and the user did NOT ask
137
+ for technical detail.
138
+
139
+ Returns a result dict with keys:
140
+ - `violations`: list of matches from `scan_text`
141
+ - `skipped`: bool — true if checker did not register (no SID, user
142
+ requested detail, or no violations)
143
+ - `debt_id`: int|None — id of the created debt, when applicable
144
+ - `reason`: short human-readable status
145
+ """
146
+ matches = scan_text(assistant_text)
147
+ if not matches:
148
+ return {"violations": [], "skipped": True, "debt_id": None, "reason": "no_violations"}
149
+ if user_requested_detail(user_message):
150
+ return {"violations": matches, "skipped": True, "debt_id": None, "reason": "user_requested_detail"}
151
+ sid = (session_id or "").strip()
152
+ if not sid:
153
+ return {"violations": matches, "skipped": True, "debt_id": None, "reason": "missing_session_id"}
154
+ try:
155
+ # Local import keeps this module importable in isolated test
156
+ # contexts (no NEXO DB) when callers only need `scan_text`.
157
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
158
+ from db import create_protocol_debt # type: ignore
159
+ except Exception as err: # pragma: no cover — defensive in tests
160
+ return {"violations": matches, "skipped": True, "debt_id": None, "reason": f"db_unavailable:{err}"}
161
+ evidence_lines = [evidence_prefix] if evidence_prefix else []
162
+ for match in matches[:5]:
163
+ evidence_lines.append(f"- '{match['token']}' @ {match['index']}: ...{match['snippet']}...")
164
+ evidence = "\n".join(line for line in evidence_lines if line)[:4000]
165
+ debt = create_protocol_debt(
166
+ sid,
167
+ "communication_guardrail",
168
+ severity="warn",
169
+ task_id=task_id,
170
+ evidence=evidence,
171
+ )
172
+ return {
173
+ "violations": matches,
174
+ "skipped": False,
175
+ "debt_id": debt.get("id") if isinstance(debt, dict) else None,
176
+ "reason": "debt_registered",
177
+ }
178
+
179
+
180
+ def _read_text_from_args(args: argparse.Namespace) -> str:
181
+ if args.text is not None:
182
+ return args.text
183
+ if args.stdin:
184
+ return sys.stdin.read()
185
+ if args.file:
186
+ return Path(args.file).read_text(encoding="utf-8")
187
+ return ""
188
+
189
+
190
+ def main(argv: Sequence[str] | None = None) -> int:
191
+ parser = argparse.ArgumentParser(description="First-response jargon checker (NEXO Communication Guardrail).")
192
+ parser.add_argument("--text", help="Assistant text to scan (inline).")
193
+ parser.add_argument("--stdin", action="store_true", help="Read assistant text from stdin.")
194
+ parser.add_argument("--file", help="Read assistant text from file.")
195
+ parser.add_argument("--user-message", default="", help="Optional user message preceding the reply.")
196
+ parser.add_argument("--session-id", default=os.environ.get("NEXO_SID", ""), help="Session ID for protocol_debt registration.")
197
+ parser.add_argument("--task-id", default="", help="Optional task ID to attach to the debt.")
198
+ parser.add_argument("--register-debt", action="store_true", help="Register a protocol_debt when violations are found.")
199
+ parser.add_argument("--evidence-prefix", default="", help="Optional prefix for the evidence string.")
200
+ args = parser.parse_args(argv)
201
+
202
+ text = _read_text_from_args(args)
203
+ if not text:
204
+ parser.error("provide --text, --stdin, or --file")
205
+ return 2
206
+
207
+ if args.register_debt:
208
+ result = register_debt_if_violations(
209
+ args.session_id,
210
+ text,
211
+ user_message=args.user_message,
212
+ task_id=args.task_id,
213
+ evidence_prefix=args.evidence_prefix,
214
+ )
215
+ violations = result["violations"]
216
+ else:
217
+ violations = scan_text(text)
218
+ result = {
219
+ "violations": violations,
220
+ "skipped": True,
221
+ "debt_id": None,
222
+ "reason": "dry_run",
223
+ }
224
+
225
+ if not violations:
226
+ print("[jargon-checker] OK — no prohibited tokens in first response.")
227
+ return 0
228
+ print(f"[jargon-checker] {len(violations)} match(es) detected:")
229
+ for match in violations:
230
+ print(f" - {match['token']!r} @ {match['index']} …{match['snippet']}…")
231
+ if result.get("debt_id"):
232
+ print(f"[jargon-checker] protocol_debt registered id={result['debt_id']} reason=communication_guardrail")
233
+ elif args.register_debt:
234
+ print(f"[jargon-checker] no debt registered: {result.get('reason')}")
235
+ return 1
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
@@ -25,17 +25,99 @@ TCC_DB="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
25
25
  VERSIONS_DIR="$HOME/.local/share/claude/versions"
26
26
  MARKER_DIR="$NEXO_HOME/runtime/data/.tcc-approved"
27
27
  LOG="$NEXO_HOME/runtime/logs/tcc-auto-approve.log"
28
+ FDA_STATE="$NEXO_HOME/runtime/state/full-disk-access-required.json"
28
29
 
29
- mkdir -p "$MARKER_DIR" "$(dirname "$LOG")"
30
+ mkdir -p "$MARKER_DIR" "$(dirname "$LOG")" "$(dirname "$FDA_STATE")"
30
31
 
31
32
  FAILED=0
33
+ FDA_REQUIRED=0
32
34
  APPROVED_VERSIONS=0
33
35
  PYTHON_APPROVED=0
36
+ FDA_REASON="macOS blocked tcc-approve from opening the user TCC database. Grant Full Disk Access to /bin/bash and NEXO Desktop, then retry background permission setup."
34
37
 
35
38
  log_line() {
36
39
  echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"
37
40
  }
38
41
 
42
+ is_fda_error() {
43
+ local text
44
+ text="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
45
+ [[ "$text" == *"authorization denied"* ]] && return 0
46
+ [[ "$text" == *"operation not permitted"* ]] && return 0
47
+ [[ "$text" == *"unable to open database"* && "$text" == *"com.apple.tcc/tcc.db"* ]] && return 0
48
+ [[ "$text" == *"privacy"* && "$text" == *"com.apple.tcc"* ]] && return 0
49
+ return 1
50
+ }
51
+
52
+ python_for_state() {
53
+ local candidate
54
+ if [ -n "${NEXO_CODE:-}" ]; then
55
+ candidate="$(dirname "$NEXO_CODE")/.venv/bin/python"
56
+ [ -x "$candidate" ] && { echo "$candidate"; return 0; }
57
+ candidate="$(dirname "$NEXO_CODE")/.venv/bin/python3"
58
+ [ -x "$candidate" ] && { echo "$candidate"; return 0; }
59
+ fi
60
+ command -v python3 2>/dev/null || true
61
+ }
62
+
63
+ record_full_disk_access_required() {
64
+ local py
65
+ py="$(python_for_state)"
66
+
67
+ cat > "$FDA_STATE" <<EOF
68
+ {
69
+ "status": "later",
70
+ "source": "tcc-approve",
71
+ "updated_at": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
72
+ "reasons": [
73
+ "$FDA_REASON"
74
+ ]
75
+ }
76
+ EOF
77
+
78
+ [ -n "$py" ] || return 0
79
+ NEXO_FDA_REASON="$FDA_REASON" NEXO_HOME="$NEXO_HOME" "$py" <<'PY'
80
+ import json
81
+ import os
82
+ from pathlib import Path
83
+
84
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
85
+ reason = os.environ.get("NEXO_FDA_REASON", "").strip()
86
+ targets = [nexo_home / "personal" / "config" / "schedule.json"]
87
+ legacy = nexo_home / "config" / "schedule.json"
88
+ if legacy.exists():
89
+ targets.append(legacy)
90
+
91
+ seen = set()
92
+ for target in targets:
93
+ key = str(target.resolve(strict=False))
94
+ if key in seen:
95
+ continue
96
+ seen.add(key)
97
+ try:
98
+ target.parent.mkdir(parents=True, exist_ok=True)
99
+ data = {}
100
+ if target.exists():
101
+ try:
102
+ parsed = json.loads(target.read_text(encoding="utf-8"))
103
+ if isinstance(parsed, dict):
104
+ data = parsed
105
+ except Exception:
106
+ data = {}
107
+ reasons = data.get("full_disk_access_reasons")
108
+ if not isinstance(reasons, list):
109
+ reasons = []
110
+ if reason and reason not in reasons:
111
+ reasons.append(reason)
112
+ data["full_disk_access_status"] = "later"
113
+ data["full_disk_access_status_version"] = 1
114
+ data["full_disk_access_reasons"] = reasons
115
+ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
116
+ except Exception:
117
+ pass
118
+ PY
119
+ }
120
+
39
121
  approve_service() {
40
122
  local svc="$1"
41
123
  local client="$2"
@@ -49,6 +131,9 @@ approve_service() {
49
131
  fi
50
132
 
51
133
  log_line "WARN: failed TCC approval service=$svc client=$client: ${output:-sqlite3 failed}"
134
+ if is_fda_error "${output:-}"; then
135
+ FDA_REQUIRED=1
136
+ fi
52
137
  return 1
53
138
  }
54
139
 
@@ -119,6 +204,13 @@ if [ -n "$NEXO_CODE" ]; then
119
204
  fi
120
205
  fi
121
206
 
207
+ if [ "$FAILED" -ne 0 ] && [ "$FDA_REQUIRED" -ne 0 ]; then
208
+ record_full_disk_access_required
209
+ log_line "Full Disk Access required: $FDA_REASON"
210
+ echo "TCC auto-approve: Full Disk Access required; Desktop will prompt the user"
211
+ exit 0
212
+ fi
213
+
122
214
  if [ "$FAILED" -ne 0 ]; then
123
215
  echo "TCC auto-approve failed; see $LOG" >&2
124
216
  exit 1