nexo-brain 7.17.5 → 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.5",
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.5` is the current packaged-runtime line. Patch release over v7.17.4 - `nexo --version --json` now returns fast machine-readable update status (`installed`, `latest`, `hasUpdate`, `unknown`, `latestSource`) while the human output keeps the legacy `nexo vX` line plus a compact latest/installed status line for Desktop compatibility.
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.5",
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())
@@ -8,9 +8,10 @@ Legacy .catchup-state.json is now only a fallback for pre-wrapper history.
8
8
  import fcntl
9
9
  import json
10
10
  import os
11
+ import sqlite3
11
12
  import subprocess
12
13
  import sys
13
- from datetime import datetime
14
+ from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
16
17
 
@@ -93,6 +94,85 @@ def _resolve_runtime_command(script_type: str) -> str:
93
94
  return NEXO_PYTHON
94
95
 
95
96
 
97
+ def _cron_run_db_path() -> Path:
98
+ return paths.data_dir() / "nexo.db"
99
+
100
+
101
+ def _utc_timestamp() -> str:
102
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
103
+
104
+
105
+ def _summarize_output(stdout: str, stderr: str) -> tuple[str, str]:
106
+ combined = "\n".join(part for part in (stdout or "", stderr or "") if part)
107
+ lines = [line.strip() for line in combined.splitlines() if line.strip()]
108
+ summary = (lines[-1] if lines else "")[:500]
109
+ error = ""
110
+ for line in reversed(lines):
111
+ lowered = line.lower()
112
+ if any(marker in lowered for marker in ("error", "exception", "fail", "traceback")):
113
+ error = line[:500]
114
+ break
115
+ return summary, error
116
+
117
+
118
+ def _start_direct_cron_run(cron_id: str) -> dict | None:
119
+ """Record a catch-up run when the shell wrapper is unavailable.
120
+
121
+ Normal installs use nexo-cron-wrapper.sh as the single writer. This
122
+ fallback exists for legacy/partially-migrated runtimes where catch-up can
123
+ still execute a script directly; without it, the run only updates
124
+ .catchup-state.json and stays invisible to cron health.
125
+ """
126
+ db_path = _cron_run_db_path()
127
+ started_at = _utc_timestamp()
128
+ try:
129
+ conn = sqlite3.connect(str(db_path))
130
+ try:
131
+ exists = conn.execute(
132
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='cron_runs'"
133
+ ).fetchone()
134
+ if not exists:
135
+ return None
136
+ cur = conn.execute(
137
+ "INSERT INTO cron_runs (cron_id, started_at, ended_at) VALUES (?, ?, NULL)",
138
+ (cron_id, started_at),
139
+ )
140
+ conn.commit()
141
+ return {"id": cur.lastrowid, "started_at": started_at}
142
+ finally:
143
+ conn.close()
144
+ except Exception as e:
145
+ log(f" WARNING: could not start direct cron_runs record for {cron_id}: {e}")
146
+ return None
147
+
148
+
149
+ def _finish_direct_cron_run(record: dict | None, cron_id: str, exit_code: int, stdout: str = "", stderr: str = ""):
150
+ if not record:
151
+ return
152
+ ended_at = _utc_timestamp()
153
+ summary, error = _summarize_output(stdout, stderr)
154
+ if exit_code != 0 and not error:
155
+ error = (stderr or stdout or f"exit {exit_code}")[:500]
156
+ db_path = _cron_run_db_path()
157
+ try:
158
+ conn = sqlite3.connect(str(db_path))
159
+ try:
160
+ conn.execute(
161
+ """
162
+ UPDATE cron_runs
163
+ SET ended_at=?, exit_code=?, summary=?, error=?,
164
+ duration_secs=(julianday(?) - julianday(started_at)) * 86400.0
165
+ WHERE id=?
166
+ """,
167
+ (ended_at, int(exit_code), summary, error, ended_at, int(record["id"])),
168
+ )
169
+ conn.commit()
170
+ finally:
171
+ conn.close()
172
+ except Exception as e:
173
+ log(f" WARNING: could not finish direct cron_runs record for {cron_id}: {e}")
174
+
175
+
96
176
  def _acquire_lock():
97
177
  LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
98
178
  handle = LOCK_FILE.open("w")
@@ -150,12 +230,14 @@ def run_task(candidate: dict, state: dict) -> bool:
150
230
  command = [runtime_cmd, str(script_path)]
151
231
 
152
232
  log(f" RUNNING {name}: {script_name}")
233
+ direct_record = None if WRAPPER.exists() else _start_direct_cron_run(name)
153
234
  try:
154
235
  result = subprocess.run(
155
236
  command,
156
237
  capture_output=True, text=True, timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
157
238
  env={**os.environ, "HOME": str(HOME), "NEXO_CATCHUP": "1"}
158
239
  )
240
+ _finish_direct_cron_run(direct_record, name, result.returncode, result.stdout, result.stderr)
159
241
  if result.returncode == 0:
160
242
  log(f" OK {name} (exit 0)")
161
243
  state[name] = datetime.now().isoformat()
@@ -167,9 +249,11 @@ def run_task(candidate: dict, state: dict) -> bool:
167
249
  log(f" stderr: {result.stderr[:300]}")
168
250
  return False
169
251
  except subprocess.TimeoutExpired:
252
+ _finish_direct_cron_run(direct_record, name, 124, stderr=f"TIMEOUT after {AUTOMATION_SUBPROCESS_TIMEOUT}s")
170
253
  log(f" TIMEOUT {name} ({AUTOMATION_SUBPROCESS_TIMEOUT}s)")
171
254
  return False
172
255
  except Exception as e:
256
+ _finish_direct_cron_run(direct_record, name, 1, stderr=str(e))
173
257
  log(f" ERROR {name}: {e}")
174
258
  return False
175
259
 
@@ -25,8 +25,141 @@ 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")"
31
+
32
+ FAILED=0
33
+ FDA_REQUIRED=0
34
+ APPROVED_VERSIONS=0
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."
37
+
38
+ log_line() {
39
+ echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"
40
+ }
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
+
121
+ approve_service() {
122
+ local svc="$1"
123
+ local client="$2"
124
+ local output
125
+
126
+ if output=$(sqlite3 "$TCC_DB" "
127
+ INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version)
128
+ VALUES ('$svc', '$client', 1, 2, 4, 1);
129
+ " 2>&1); then
130
+ return 0
131
+ fi
132
+
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
137
+ return 1
138
+ }
139
+
140
+ approve_client() {
141
+ local label="$1"
142
+ local client="$2"
143
+ local marker="${3:-}"
144
+ local failed=0
145
+
146
+ log_line "Approving $label"
147
+
148
+ for svc in "${SERVICES[@]}"; do
149
+ if ! approve_service "$svc" "$client"; then
150
+ failed=$((failed + 1))
151
+ fi
152
+ done
153
+
154
+ if [ "$failed" -eq 0 ]; then
155
+ [ -n "$marker" ] && touch "$marker"
156
+ log_line "Done: $label — ${#SERVICES[@]} services approved"
157
+ return 0
158
+ fi
159
+
160
+ log_line "FAILED: $label — $failed/${#SERVICES[@]} services failed"
161
+ return 1
162
+ }
30
163
 
31
164
  # TCC services Claude Code needs
32
165
  SERVICES=(
@@ -49,17 +182,11 @@ if [ -d "$VERSIONS_DIR" ]; then
49
182
  # Skip if already approved
50
183
  [ -f "$marker" ] && continue
51
184
 
52
- echo "$(date '+%Y-%m-%d %H:%M:%S') Approving Claude $version" >> "$LOG"
53
-
54
- for svc in "${SERVICES[@]}"; do
55
- sqlite3 "$TCC_DB" "
56
- INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version)
57
- VALUES ('$svc', '$bin_path', 1, 2, 4, 1);
58
- " 2>/dev/null
59
- done
60
-
61
- touch "$marker"
62
- echo "$(date '+%Y-%m-%d %H:%M:%S') Done: Claude $version — ${#SERVICES[@]} services approved" >> "$LOG"
185
+ if approve_client "Claude $version" "$bin_path" "$marker"; then
186
+ APPROVED_VERSIONS=$((APPROVED_VERSIONS + 1))
187
+ else
188
+ FAILED=1
189
+ fi
63
190
  done
64
191
  fi
65
192
 
@@ -69,11 +196,28 @@ if [ -n "$NEXO_CODE" ]; then
69
196
  PYTHON_BIN="$(dirname "$NEXO_CODE")/.venv/bin/python"
70
197
  if [ -e "$PYTHON_BIN" ]; then
71
198
  PYTHON_REAL=$(readlink -f "$PYTHON_BIN" 2>/dev/null || echo "$PYTHON_BIN")
72
- for svc in "${SERVICES[@]}"; do
73
- sqlite3 "$TCC_DB" "
74
- INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version)
75
- VALUES ('$svc', '$PYTHON_REAL', 1, 2, 4, 1);
76
- " 2>/dev/null
77
- done
199
+ if approve_client "NEXO Python" "$PYTHON_REAL"; then
200
+ PYTHON_APPROVED=1
201
+ else
202
+ FAILED=1
203
+ fi
78
204
  fi
79
205
  fi
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
+
214
+ if [ "$FAILED" -ne 0 ]; then
215
+ echo "TCC auto-approve failed; see $LOG" >&2
216
+ exit 1
217
+ fi
218
+
219
+ if [ "$APPROVED_VERSIONS" -eq 0 ] && [ "$PYTHON_APPROVED" -eq 0 ]; then
220
+ echo "TCC auto-approve: nothing pending"
221
+ else
222
+ echo "TCC auto-approve: approved $APPROVED_VERSIONS Claude version(s)"
223
+ fi