nexo-brain 6.1.0 → 6.3.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/classifier_local.py +176 -0
- package/src/cli.py +17 -4
- package/src/cognitive/_core.py +36 -0
- package/src/cognitive/_trust.py +95 -10
- package/src/db/_core.py +5 -0
- package/src/db/_schema.py +38 -0
- package/src/enforcement_classifier.py +31 -6
- package/src/enforcement_engine.py +159 -0
- package/src/fase_f_loops.py +194 -0
- package/src/hook_guardrails.py +14 -0
- package/src/hooks/auto_capture.py +67 -0
- package/src/nexo_migrate.py +158 -0
- package/src/plugin_loader.py +86 -0
- package/src/plugins/cognitive_memory.py +3 -0
- package/src/presets/entities_universal.json +41 -0
- package/src/presets/guardian_default.json +2 -1
- package/src/r34_identity_coherence.py +132 -0
- package/src/r_catalog.py +72 -0
- package/src/scripts/phase_guardian_analysis.py +114 -0
- package/src/server.py +31 -1
- package/src/system_catalog.py +54 -0
- package/src/t4_llm_gate.py +174 -0
- package/src/tools_email_guard.py +88 -0
- package/src/tools_guardian.py +183 -0
- package/templates/CLAUDE.md.template +9 -0
- package/templates/CODEX.AGENTS.md.template +7 -0
|
@@ -63,6 +63,16 @@ except ImportError: # pragma: no cover
|
|
|
63
63
|
_R17_PROMPT = "" # type: ignore
|
|
64
64
|
_R17_WINDOW = 2
|
|
65
65
|
|
|
66
|
+
try:
|
|
67
|
+
from r_catalog import should_inject_r_catalog as _r_catalog_should
|
|
68
|
+
except ImportError: # pragma: no cover
|
|
69
|
+
_r_catalog_should = None # type: ignore
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
from r34_identity_coherence import should_inject_r34 as _r34_should
|
|
73
|
+
except ImportError: # pragma: no cover
|
|
74
|
+
_r34_should = None # type: ignore
|
|
75
|
+
|
|
66
76
|
try:
|
|
67
77
|
from r20_constant_change import (
|
|
68
78
|
should_inject_r20 as _r20_should,
|
|
@@ -812,6 +822,15 @@ class HeadlessEnforcer:
|
|
|
812
822
|
decision = _r15_should(text or "", project_list, records)
|
|
813
823
|
if not decision:
|
|
814
824
|
return
|
|
825
|
+
# T4.2 — LLM gate: if the classifier says the turn is
|
|
826
|
+
# conversational / off-topic, skip the injection. Regex wins on
|
|
827
|
+
# "unknown" so legitimate R15 hits still fire without a working
|
|
828
|
+
# classifier.
|
|
829
|
+
if self._t4_gate_says_no("R15", span=(text or "")[:400]):
|
|
830
|
+
_logger.info(
|
|
831
|
+
"[R15 T4] gate=no, skipping project=%s", decision["project"]
|
|
832
|
+
)
|
|
833
|
+
return
|
|
815
834
|
prompt = _R15_PROMPT.format(project=decision["project"])
|
|
816
835
|
if mode == "shadow":
|
|
817
836
|
_logger.info("[R15 SHADOW] would inject: project=%s", decision["project"])
|
|
@@ -819,6 +838,70 @@ class HeadlessEnforcer:
|
|
|
819
838
|
self._enqueue(prompt, decision["tag"], rule_id="R15_project_context")
|
|
820
839
|
_logger.info("[R15 %s] enqueued project=%s", mode.upper(), decision["project"])
|
|
821
840
|
|
|
841
|
+
# ------------------------------------------------------------------
|
|
842
|
+
# T4 LLM gate — central helper (Plan Consolidado T4.2-T4.6).
|
|
843
|
+
# ------------------------------------------------------------------
|
|
844
|
+
def _t4_gate_says_no(self, rule_id: str, *, span: str, context: str = "") -> bool:
|
|
845
|
+
"""Return True ONLY when the T4 classifier explicitly votes "no"
|
|
846
|
+
for this rule hit. "yes" or "unknown" (classifier unavailable,
|
|
847
|
+
import error, rate limit, parse failure) fall through to regex
|
|
848
|
+
behaviour — never silently suppress a rule on infra flakiness.
|
|
849
|
+
|
|
850
|
+
Every unavailable-path logs a WARNING once per (rule_id, reason)
|
|
851
|
+
via ``_t4_gate_warned`` so degradations surface in the console
|
|
852
|
+
without flooding it.
|
|
853
|
+
"""
|
|
854
|
+
if not hasattr(self, "_t4_gate_warned"):
|
|
855
|
+
self._t4_gate_warned = set()
|
|
856
|
+
try:
|
|
857
|
+
from t4_llm_gate import build_prompt, classify_with_llm
|
|
858
|
+
from enforcement_classifier import classify as _classifier_raw
|
|
859
|
+
except Exception as exc:
|
|
860
|
+
key = (rule_id, f"import:{exc.__class__.__name__}")
|
|
861
|
+
if key not in self._t4_gate_warned:
|
|
862
|
+
self._t4_gate_warned.add(key)
|
|
863
|
+
_logger.warning("[T4 gate] import failed for %s: %s", rule_id, exc)
|
|
864
|
+
return False
|
|
865
|
+
|
|
866
|
+
# Auditor H1 fix: the legacy bool contract of `classify` collapses
|
|
867
|
+
# "classifier said no" and "classifier response unparseable after
|
|
868
|
+
# two retries — conservative fallback" into the same False, which
|
|
869
|
+
# would silently suppress destructive rules (R23e/R23f/R23h) when
|
|
870
|
+
# the backend responds with garbage. Force the tristate path so
|
|
871
|
+
# "unknown" falls through to regex behaviour instead of becoming
|
|
872
|
+
# a silent rule disable.
|
|
873
|
+
def _classifier_tristate(q: str, ctx: str) -> str:
|
|
874
|
+
return _classifier_raw(q, ctx, tristate=True)
|
|
875
|
+
|
|
876
|
+
prompt = build_prompt(rule_id, span=span, context=context)
|
|
877
|
+
if not prompt:
|
|
878
|
+
key = (rule_id, "no-prompt")
|
|
879
|
+
if key not in self._t4_gate_warned:
|
|
880
|
+
self._t4_gate_warned.add(key)
|
|
881
|
+
_logger.warning(
|
|
882
|
+
"[T4 gate] no prompt template for rule_id=%s (check PROMPTS)",
|
|
883
|
+
rule_id,
|
|
884
|
+
)
|
|
885
|
+
return False
|
|
886
|
+
try:
|
|
887
|
+
verdict = classify_with_llm(
|
|
888
|
+
rule_id,
|
|
889
|
+
prompt=prompt,
|
|
890
|
+
context=context,
|
|
891
|
+
classifier=_classifier_tristate,
|
|
892
|
+
)
|
|
893
|
+
except Exception as exc:
|
|
894
|
+
key = (rule_id, f"classify:{exc.__class__.__name__}")
|
|
895
|
+
if key not in self._t4_gate_warned:
|
|
896
|
+
self._t4_gate_warned.add(key)
|
|
897
|
+
_logger.warning(
|
|
898
|
+
"[T4 gate] classify failed for %s: %s — regex fallback active",
|
|
899
|
+
rule_id,
|
|
900
|
+
exc,
|
|
901
|
+
)
|
|
902
|
+
return False
|
|
903
|
+
return verdict == "no"
|
|
904
|
+
|
|
822
905
|
def _check_r23(self, tool_name: str, tool_input):
|
|
823
906
|
"""R23 — ssh/scp/rsync/curl towards an unregistered host."""
|
|
824
907
|
if _r23_should is None:
|
|
@@ -986,6 +1069,10 @@ class HeadlessEnforcer:
|
|
|
986
1069
|
should, prompt = _r23e_should(tool_name, tool_input)
|
|
987
1070
|
if not should:
|
|
988
1071
|
return
|
|
1072
|
+
span = (tool_input or {}).get("command", "") if isinstance(tool_input, dict) else ""
|
|
1073
|
+
if self._t4_gate_says_no("R23e", span=span):
|
|
1074
|
+
_logger.info("[R23e T4] gate=no, skipping")
|
|
1075
|
+
return
|
|
989
1076
|
if mode == "shadow":
|
|
990
1077
|
_logger.info("[R23e SHADOW] would inject")
|
|
991
1078
|
return
|
|
@@ -1003,6 +1090,10 @@ class HeadlessEnforcer:
|
|
|
1003
1090
|
should, prompt = _r23f_should(tool_name, tool_input, production_markers=markers or None)
|
|
1004
1091
|
if not should:
|
|
1005
1092
|
return
|
|
1093
|
+
span = (tool_input or {}).get("command", "") if isinstance(tool_input, dict) else ""
|
|
1094
|
+
if self._t4_gate_says_no("R23f", span=span):
|
|
1095
|
+
_logger.info("[R23f T4] gate=no, skipping")
|
|
1096
|
+
return
|
|
1006
1097
|
if mode == "shadow":
|
|
1007
1098
|
_logger.info("[R23f SHADOW] would inject")
|
|
1008
1099
|
return
|
|
@@ -1252,6 +1343,12 @@ class HeadlessEnforcer:
|
|
|
1252
1343
|
should, prompt = _r23h_should(tool_name, tool_input)
|
|
1253
1344
|
if not should:
|
|
1254
1345
|
return
|
|
1346
|
+
span = ""
|
|
1347
|
+
if isinstance(tool_input, dict):
|
|
1348
|
+
span = tool_input.get("content") or tool_input.get("new_string") or ""
|
|
1349
|
+
if self._t4_gate_says_no("R23h", span=str(span)[:500]):
|
|
1350
|
+
_logger.info("[R23h T4] gate=no, skipping")
|
|
1351
|
+
return
|
|
1255
1352
|
if mode == "shadow":
|
|
1256
1353
|
_logger.info("[R23h SHADOW] would inject")
|
|
1257
1354
|
return
|
|
@@ -1341,6 +1438,30 @@ class HeadlessEnforcer:
|
|
|
1341
1438
|
self._enqueue(prompt, decision["tag"], rule_id="R22_personal_script")
|
|
1342
1439
|
_logger.info("[R22 %s] enqueued path=%s missing=%s", mode.upper(), decision["path"], decision["missing"])
|
|
1343
1440
|
|
|
1441
|
+
def _check_r_catalog(self, tool_name: str):
|
|
1442
|
+
"""R-CATALOG (Plan Consolidado 0.X.2) — pre-create discovery probe."""
|
|
1443
|
+
if _r_catalog_should is None:
|
|
1444
|
+
return
|
|
1445
|
+
mode = self._guardian_rule_mode("R_CATALOG_before_artifact_create")
|
|
1446
|
+
if mode == "off":
|
|
1447
|
+
return
|
|
1448
|
+
# The trigger tool was just appended to recent_tool_records so we
|
|
1449
|
+
# inspect the preceding window (strip the current call).
|
|
1450
|
+
window = 60.0
|
|
1451
|
+
now = time.time()
|
|
1452
|
+
names = [
|
|
1453
|
+
r.tool for r in self.recent_tool_records[:-1]
|
|
1454
|
+
if (now - getattr(r, "ts", now)) <= window
|
|
1455
|
+
]
|
|
1456
|
+
should, prompt = _r_catalog_should(tool_name, recent_tool_names=names)
|
|
1457
|
+
if not should:
|
|
1458
|
+
return
|
|
1459
|
+
if mode == "shadow":
|
|
1460
|
+
_logger.info("[R_CATALOG SHADOW] would inject for %s", tool_name)
|
|
1461
|
+
return
|
|
1462
|
+
self._enqueue(prompt, f"R_CATALOG:{tool_name}", rule_id="R_CATALOG_before_artifact_create")
|
|
1463
|
+
_logger.info("[R_CATALOG %s] enqueued tool=%s", mode.upper(), tool_name)
|
|
1464
|
+
|
|
1344
1465
|
def _check_r18(self, tool_name: str, tool_input):
|
|
1345
1466
|
"""R18 — suggest followup_complete on closure-class actions."""
|
|
1346
1467
|
if _r18_should is None or _r18_format is None:
|
|
@@ -1361,6 +1482,40 @@ class HeadlessEnforcer:
|
|
|
1361
1482
|
self._enqueue(prompt, decision["tag"], rule_id="R18_followup_autocomplete")
|
|
1362
1483
|
_logger.info("[R18 %s] enqueued %d matches", mode.upper(), decision["count"])
|
|
1363
1484
|
|
|
1485
|
+
def on_assistant_message(self, text: str, *, classifier=None):
|
|
1486
|
+
"""R34 entry point — called when an assistant message is complete.
|
|
1487
|
+
|
|
1488
|
+
Plan Consolidado T5. If the message is a past-tense denial of an
|
|
1489
|
+
action (ES/EN patterns) and no shared-brain tool was called in the
|
|
1490
|
+
current turn, the rule fires a reminder to consult the shared brain
|
|
1491
|
+
before asserting what happened.
|
|
1492
|
+
|
|
1493
|
+
Args:
|
|
1494
|
+
text: assistant output text.
|
|
1495
|
+
classifier: optional LLM yes/no callable used to disambiguate
|
|
1496
|
+
regex matches. Tests pass a fake.
|
|
1497
|
+
"""
|
|
1498
|
+
if _r34_should is None or not text:
|
|
1499
|
+
return
|
|
1500
|
+
mode = self._guardian_rule_mode("R34_identity_coherence")
|
|
1501
|
+
if mode == "off":
|
|
1502
|
+
return
|
|
1503
|
+
recent_names = [r.tool for r in self.recent_tool_records]
|
|
1504
|
+
try:
|
|
1505
|
+
inject, prompt, matched = _r34_should(
|
|
1506
|
+
text, recent_tool_names=recent_names, classifier=classifier,
|
|
1507
|
+
)
|
|
1508
|
+
except Exception as exc: # noqa: BLE001
|
|
1509
|
+
_logger.warning("R34 probe failed (%s); staying silent", exc)
|
|
1510
|
+
return
|
|
1511
|
+
if not inject:
|
|
1512
|
+
return
|
|
1513
|
+
if mode == "shadow":
|
|
1514
|
+
_logger.info("[R34 SHADOW] would inject matched=%r", matched)
|
|
1515
|
+
return
|
|
1516
|
+
self._enqueue(prompt, f"R34:{matched[:40]}", rule_id="R34_identity_coherence")
|
|
1517
|
+
_logger.info("[R34 %s] enqueued matched=%r", mode.upper(), matched)
|
|
1518
|
+
|
|
1364
1519
|
def notify_stale_memory_cited(self):
|
|
1365
1520
|
"""External hook for R24 — caller (handle_cognitive_retrieve post-
|
|
1366
1521
|
processing) flags when a stale memory entered the context. Opens
|
|
@@ -1510,6 +1665,10 @@ class HeadlessEnforcer:
|
|
|
1510
1665
|
# R22 — personal script create without prior context probes.
|
|
1511
1666
|
self._check_r22(name, tool_input)
|
|
1512
1667
|
|
|
1668
|
+
# R-CATALOG (Plan 0.X.2) — nudge if we are about to create/open/add
|
|
1669
|
+
# without having consulted the live inventory in the last 60 s.
|
|
1670
|
+
self._check_r_catalog(name)
|
|
1671
|
+
|
|
1513
1672
|
# R18 — retroactive followup-complete suggestion on closure actions.
|
|
1514
1673
|
self._check_r18(name, tool_input)
|
|
1515
1674
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Plan Consolidado F.2/F.5/F.6 — Fase F telemetry loops.
|
|
2
|
+
|
|
3
|
+
Consumes `guardian-telemetry.ndjson` (item 0.18) and produces:
|
|
4
|
+
- per-rule aggregate metrics (F.2)
|
|
5
|
+
- false-positive grouping (F.5)
|
|
6
|
+
- false-negative candidates for new-rule promotion (F.6)
|
|
7
|
+
|
|
8
|
+
These are pure functions with no side effects on the live runtime —
|
|
9
|
+
`src/scripts/phase_guardian_analysis.py` (Deep Sleep phase) calls them
|
|
10
|
+
and persists summaries to `~/.nexo/reports/guardian-fase-f-*.json`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_TELEMETRY_PATH = Path.home() / ".nexo" / "logs" / "guardian-telemetry.ndjson"
|
|
23
|
+
DEFAULT_FP_GROUP_MIN_OCCURRENCES = 3
|
|
24
|
+
DEFAULT_FN_PROMOTION_THRESHOLD = 3
|
|
25
|
+
DEFAULT_FN_WINDOW_DAYS = 14
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_telemetry_events(path: Path | str = DEFAULT_TELEMETRY_PATH) -> list[dict]:
|
|
29
|
+
"""Return events persisted by the guardian_engine, oldest-first.
|
|
30
|
+
|
|
31
|
+
Ignores lines that fail to parse so a malformed append does not
|
|
32
|
+
blow up Deep Sleep.
|
|
33
|
+
"""
|
|
34
|
+
p = Path(path)
|
|
35
|
+
if not p.exists():
|
|
36
|
+
return []
|
|
37
|
+
out: list[dict] = []
|
|
38
|
+
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if not line:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
out.append(json.loads(line))
|
|
44
|
+
except Exception:
|
|
45
|
+
continue
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def aggregate_per_rule(events: Iterable[dict]) -> dict[str, dict]:
|
|
50
|
+
"""F.2 — produce the per-rule metrics table.
|
|
51
|
+
|
|
52
|
+
Each rule_id gets:
|
|
53
|
+
- trigger_count
|
|
54
|
+
- injection_count
|
|
55
|
+
- followed_through_count (engine observed the agent then called
|
|
56
|
+
the gating tool within the dedup window)
|
|
57
|
+
- false_positive_count (operator flagged the injection as noise)
|
|
58
|
+
- avg_response_latency (ms between injection emit and agent act)
|
|
59
|
+
"""
|
|
60
|
+
agg: dict[str, dict] = defaultdict(lambda: {
|
|
61
|
+
"trigger_count": 0,
|
|
62
|
+
"injection_count": 0,
|
|
63
|
+
"followed_through_count": 0,
|
|
64
|
+
"false_positive_count": 0,
|
|
65
|
+
"latencies_ms": [],
|
|
66
|
+
})
|
|
67
|
+
for e in events:
|
|
68
|
+
rid = e.get("rule_id") or ""
|
|
69
|
+
if not rid:
|
|
70
|
+
continue
|
|
71
|
+
etype = e.get("event") or e.get("type") or "injection"
|
|
72
|
+
bucket = agg[rid]
|
|
73
|
+
if etype in ("trigger", "scan"):
|
|
74
|
+
bucket["trigger_count"] += 1
|
|
75
|
+
elif etype in ("injection", "inject"):
|
|
76
|
+
bucket["injection_count"] += 1
|
|
77
|
+
elif etype == "followed_through":
|
|
78
|
+
bucket["followed_through_count"] += 1
|
|
79
|
+
elif etype in ("false_positive", "fp"):
|
|
80
|
+
bucket["false_positive_count"] += 1
|
|
81
|
+
latency = e.get("response_latency_ms") or e.get("latency_ms")
|
|
82
|
+
if isinstance(latency, (int, float)):
|
|
83
|
+
bucket["latencies_ms"].append(float(latency))
|
|
84
|
+
|
|
85
|
+
out: dict[str, dict] = {}
|
|
86
|
+
for rid, bucket in agg.items():
|
|
87
|
+
latencies = bucket.pop("latencies_ms")
|
|
88
|
+
avg = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
|
|
89
|
+
efficacy = 0.0
|
|
90
|
+
inj = bucket["injection_count"]
|
|
91
|
+
if inj > 0:
|
|
92
|
+
efficacy = round(bucket["followed_through_count"] / inj, 3)
|
|
93
|
+
fp_rate = 0.0
|
|
94
|
+
if inj > 0:
|
|
95
|
+
fp_rate = round(bucket["false_positive_count"] / inj, 3)
|
|
96
|
+
out[rid] = {
|
|
97
|
+
**bucket,
|
|
98
|
+
"avg_response_latency_ms": avg,
|
|
99
|
+
"efficacy": efficacy,
|
|
100
|
+
"false_positive_rate": fp_rate,
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def group_false_positives(
|
|
106
|
+
events: Iterable[dict],
|
|
107
|
+
min_occurrences: int = DEFAULT_FP_GROUP_MIN_OCCURRENCES,
|
|
108
|
+
) -> list[dict]:
|
|
109
|
+
"""F.5 — cluster FP events by (rule_id, trigger_context_hash).
|
|
110
|
+
|
|
111
|
+
Returns groups that appear >= min_occurrences times, ordered by
|
|
112
|
+
frequency desc. Consumers propose threshold/scope adjustments on the
|
|
113
|
+
top groups.
|
|
114
|
+
"""
|
|
115
|
+
groups: dict[tuple, list[dict]] = defaultdict(list)
|
|
116
|
+
for e in events:
|
|
117
|
+
etype = e.get("event") or e.get("type") or ""
|
|
118
|
+
if etype not in ("false_positive", "fp"):
|
|
119
|
+
continue
|
|
120
|
+
key = (
|
|
121
|
+
e.get("rule_id") or "",
|
|
122
|
+
e.get("trigger_context_hash") or e.get("trigger_context") or "",
|
|
123
|
+
)
|
|
124
|
+
groups[key].append(e)
|
|
125
|
+
|
|
126
|
+
out: list[dict] = []
|
|
127
|
+
for (rid, ctx), items in groups.items():
|
|
128
|
+
if len(items) < min_occurrences:
|
|
129
|
+
continue
|
|
130
|
+
out.append({
|
|
131
|
+
"rule_id": rid,
|
|
132
|
+
"trigger_context": ctx,
|
|
133
|
+
"occurrences": len(items),
|
|
134
|
+
"first_seen": min((it.get("ts") or 0) for it in items),
|
|
135
|
+
"last_seen": max((it.get("ts") or 0) for it in items),
|
|
136
|
+
"sample": items[:3],
|
|
137
|
+
})
|
|
138
|
+
out.sort(key=lambda r: r["occurrences"], reverse=True)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_false_negative_candidates(
|
|
143
|
+
corrections: Iterable[dict],
|
|
144
|
+
injections: Iterable[dict],
|
|
145
|
+
*,
|
|
146
|
+
window_days: int = DEFAULT_FN_WINDOW_DAYS,
|
|
147
|
+
threshold: int = DEFAULT_FN_PROMOTION_THRESHOLD,
|
|
148
|
+
) -> list[dict]:
|
|
149
|
+
"""F.6 — user corrections with no matching guardian injection are
|
|
150
|
+
candidates for a new rule in shadow.
|
|
151
|
+
|
|
152
|
+
corrections: events that represent a user correction (from
|
|
153
|
+
nexo_cognitive_sentiment `is_correction=True` or `trust_event=correction`).
|
|
154
|
+
injections: guardian injection events (event=injection).
|
|
155
|
+
|
|
156
|
+
Corrections older than window_days are ignored. Returns candidates
|
|
157
|
+
grouped by a fingerprint of the preceding assistant action, ordered
|
|
158
|
+
by count desc, filtered by count >= threshold.
|
|
159
|
+
"""
|
|
160
|
+
now = time.time()
|
|
161
|
+
cutoff = now - (window_days * 86400)
|
|
162
|
+
|
|
163
|
+
injection_keys = {
|
|
164
|
+
(i.get("rule_id") or "", i.get("trigger_fingerprint") or "")
|
|
165
|
+
for i in injections
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
buckets: dict[str, list[dict]] = defaultdict(list)
|
|
169
|
+
for c in corrections:
|
|
170
|
+
ts = c.get("ts") or c.get("at") or 0
|
|
171
|
+
if ts and ts < cutoff:
|
|
172
|
+
continue
|
|
173
|
+
fp = c.get("fingerprint") or c.get("assistant_action_fingerprint") or ""
|
|
174
|
+
if not fp:
|
|
175
|
+
continue
|
|
176
|
+
# Already covered by an existing guardian injection → not a
|
|
177
|
+
# false-negative, it's just noise the operator could not suppress.
|
|
178
|
+
if any(k[1] == fp for k in injection_keys):
|
|
179
|
+
continue
|
|
180
|
+
buckets[fp].append(c)
|
|
181
|
+
|
|
182
|
+
out: list[dict] = []
|
|
183
|
+
for fp, items in buckets.items():
|
|
184
|
+
if len(items) < threshold:
|
|
185
|
+
continue
|
|
186
|
+
out.append({
|
|
187
|
+
"fingerprint": fp,
|
|
188
|
+
"count": len(items),
|
|
189
|
+
"first_seen": min((it.get("ts") or 0) for it in items),
|
|
190
|
+
"last_seen": max((it.get("ts") or 0) for it in items),
|
|
191
|
+
"sample": items[:3],
|
|
192
|
+
})
|
|
193
|
+
out.sort(key=lambda r: r["count"], reverse=True)
|
|
194
|
+
return out
|
package/src/hook_guardrails.py
CHANGED
|
@@ -557,6 +557,20 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
557
557
|
if op not in {"write", "delete"}:
|
|
558
558
|
return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
|
|
559
559
|
|
|
560
|
+
# Plan Consolidado F0.0.4 — skip hook-level strict blocking while a
|
|
561
|
+
# structure migration is in flight. NEXO_MIGRATING=1 is set by
|
|
562
|
+
# nexo_migrate.run_structure_migration while it moves files and
|
|
563
|
+
# re-paths the runtime. Without this bypass a legitimate migration
|
|
564
|
+
# cannot edit anything without having opened task_open for each
|
|
565
|
+
# individual moved file, which defeats the whole migration flow.
|
|
566
|
+
if os.environ.get("NEXO_MIGRATING") == "1":
|
|
567
|
+
return {
|
|
568
|
+
"ok": True,
|
|
569
|
+
"skipped": True,
|
|
570
|
+
"reason": "structure migration in progress (NEXO_MIGRATING=1)",
|
|
571
|
+
"strictness": get_protocol_strictness(),
|
|
572
|
+
}
|
|
573
|
+
|
|
560
574
|
tool_input = payload.get("tool_input")
|
|
561
575
|
files = _extract_touched_files(tool_input)
|
|
562
576
|
strictness = get_protocol_strictness()
|
|
@@ -149,11 +149,66 @@ def _dedup_record(
|
|
|
149
149
|
# ---------------------------------------------------------------------------
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
# Labels used when the local zero-shot classifier is consulted. Plan
|
|
153
|
+
# 0.21 wave-2: classifier decides *semantically* between the four
|
|
154
|
+
# buckets; regex stays as a fast prefilter.
|
|
155
|
+
_ZS_LABELS = ("decision", "correction", "explicit", "noise")
|
|
156
|
+
_ZS_CONFIDENCE_FLOOR = 0.65
|
|
157
|
+
_ZS_MIN_LEN_FOR_LLM = 40 # short lines stay regex-only
|
|
158
|
+
|
|
159
|
+
# Module-level classifier handle, constructed lazily on first use so
|
|
160
|
+
# importing the hook never pays the transformers load cost.
|
|
161
|
+
_zs_classifier = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _get_zs_classifier():
|
|
165
|
+
"""Return a LocalZeroShotClassifier or None if unavailable."""
|
|
166
|
+
global _zs_classifier
|
|
167
|
+
if _zs_classifier is not None:
|
|
168
|
+
return _zs_classifier
|
|
169
|
+
try:
|
|
170
|
+
from classifier_local import LocalZeroShotClassifier # type: ignore
|
|
171
|
+
except Exception:
|
|
172
|
+
_zs_classifier = False # type: ignore[assignment]
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
_zs_classifier = LocalZeroShotClassifier()
|
|
176
|
+
except Exception:
|
|
177
|
+
_zs_classifier = False # type: ignore[assignment]
|
|
178
|
+
return None
|
|
179
|
+
return _zs_classifier
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _zero_shot_classify(line: str) -> tuple[str, float] | None:
|
|
183
|
+
"""Plan 0.21 — ask the local zero-shot classifier to bucket the line.
|
|
184
|
+
|
|
185
|
+
Returns ``(label, confidence)`` when the classifier is available and
|
|
186
|
+
its top confidence clears ``_ZS_CONFIDENCE_FLOOR``. Returns None
|
|
187
|
+
otherwise (classifier missing, pipeline load failed, low confidence,
|
|
188
|
+
or any exception). Callers must fall back to the regex decision in
|
|
189
|
+
that case — the classifier is a pre-filter / tie-breaker, never the
|
|
190
|
+
exclusive decider.
|
|
191
|
+
"""
|
|
192
|
+
if len(line) < _ZS_MIN_LEN_FOR_LLM:
|
|
193
|
+
return None
|
|
194
|
+
clf = _get_zs_classifier()
|
|
195
|
+
if not clf:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
result = clf.classify(line, _ZS_LABELS)
|
|
199
|
+
except Exception:
|
|
200
|
+
return None
|
|
201
|
+
if result is None or result.confidence < _ZS_CONFIDENCE_FLOOR:
|
|
202
|
+
return None
|
|
203
|
+
return result.label, float(result.confidence)
|
|
204
|
+
|
|
205
|
+
|
|
152
206
|
def _classify_line(line: str) -> list[tuple[str, str]]:
|
|
153
207
|
line = line.strip()
|
|
154
208
|
if len(line) < _MIN_LINE_LENGTH:
|
|
155
209
|
return []
|
|
156
210
|
|
|
211
|
+
# 1. Regex fast-path — cheap and deterministic.
|
|
157
212
|
facts: list[tuple[str, str]] = []
|
|
158
213
|
|
|
159
214
|
for pattern in _DECISION_PATTERNS:
|
|
@@ -171,6 +226,18 @@ def _classify_line(line: str) -> list[tuple[str, str]]:
|
|
|
171
226
|
facts.append(("explicit", line))
|
|
172
227
|
break
|
|
173
228
|
|
|
229
|
+
# 2. If regex produced no facts, ask the local zero-shot classifier
|
|
230
|
+
# for a semantic opinion. This catches multilingual correction
|
|
231
|
+
# shapes the regex does not know ("la ruta estaba mal en realidad",
|
|
232
|
+
# "that last paragraph is backwards"). The floor + min-length
|
|
233
|
+
# guard keep noise / short chatter off the learning pipeline.
|
|
234
|
+
if not facts:
|
|
235
|
+
zs = _zero_shot_classify(line)
|
|
236
|
+
if zs is not None:
|
|
237
|
+
label, _ = zs
|
|
238
|
+
if label in {"decision", "correction", "explicit"}:
|
|
239
|
+
facts.append((label, line))
|
|
240
|
+
|
|
174
241
|
return facts
|
|
175
242
|
|
|
176
243
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""nexo_migrate — Plan Consolidado F0.0 migration helper.
|
|
2
|
+
|
|
3
|
+
Pre-requisite for F0.1→F0.6 (the scripts-classification / core-vs-personal
|
|
4
|
+
reshuffle). This module owns:
|
|
5
|
+
|
|
6
|
+
- ``apply_migration(version, fn, notes="")`` — idempotent runner that
|
|
7
|
+
records success in ``migrations_applied`` and writes the matching
|
|
8
|
+
``~/.nexo/.structure-version`` file so ``doctor`` and the CLI can
|
|
9
|
+
detect where the runtime is in the migration ladder.
|
|
10
|
+
- ``get_structure_version()`` — reader for the .structure-version file.
|
|
11
|
+
- ``ensure_migrations_table(conn)`` — idempotent ``CREATE TABLE IF NOT
|
|
12
|
+
EXISTS``.
|
|
13
|
+
- ``is_applied(version, conn=None)`` — check before re-running.
|
|
14
|
+
|
|
15
|
+
The guardian hook (``hooks/protocol-pretool-guardrail.sh`` /
|
|
16
|
+
``hook_guardrails.py``) already recognises the ``NEXO_MIGRATING=1``
|
|
17
|
+
environment variable; this helper sets it for the duration of
|
|
18
|
+
``apply_migration`` so live-repo writes during a fase are not blocked
|
|
19
|
+
by learnings that guard those paths.
|
|
20
|
+
|
|
21
|
+
Fail-closed: a migration that throws leaves the old structure-version
|
|
22
|
+
file in place and does NOT record the version as applied — rollback is
|
|
23
|
+
implicit.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import sqlite3
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _nexo_home() -> Path:
|
|
35
|
+
env = os.environ.get("NEXO_HOME")
|
|
36
|
+
if env:
|
|
37
|
+
return Path(env)
|
|
38
|
+
return Path.home() / ".nexo"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _db_path() -> Path:
|
|
42
|
+
env = os.environ.get("NEXO_DB_PATH")
|
|
43
|
+
if env:
|
|
44
|
+
return Path(env)
|
|
45
|
+
return _nexo_home() / "data" / "nexo.db"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _structure_version_path() -> Path:
|
|
49
|
+
return _nexo_home() / ".structure-version"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_migrations_table(conn: sqlite3.Connection) -> None:
|
|
53
|
+
conn.execute(
|
|
54
|
+
"""
|
|
55
|
+
CREATE TABLE IF NOT EXISTS migrations_applied (
|
|
56
|
+
version TEXT PRIMARY KEY,
|
|
57
|
+
applied_at TEXT NOT NULL,
|
|
58
|
+
notes TEXT
|
|
59
|
+
)
|
|
60
|
+
"""
|
|
61
|
+
)
|
|
62
|
+
conn.commit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_applied(version: str, *, conn: sqlite3.Connection | None = None) -> bool:
|
|
66
|
+
owned = False
|
|
67
|
+
if conn is None:
|
|
68
|
+
path = _db_path()
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
conn = sqlite3.connect(str(path))
|
|
71
|
+
owned = True
|
|
72
|
+
try:
|
|
73
|
+
ensure_migrations_table(conn)
|
|
74
|
+
cur = conn.execute(
|
|
75
|
+
"SELECT 1 FROM migrations_applied WHERE version = ?",
|
|
76
|
+
(version,),
|
|
77
|
+
)
|
|
78
|
+
return cur.fetchone() is not None
|
|
79
|
+
finally:
|
|
80
|
+
if owned:
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _record_applied(version: str, notes: str, conn: sqlite3.Connection) -> None:
|
|
85
|
+
ensure_migrations_table(conn)
|
|
86
|
+
conn.execute(
|
|
87
|
+
"INSERT OR REPLACE INTO migrations_applied(version, applied_at, notes) VALUES (?, ?, ?)",
|
|
88
|
+
(version, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), notes or ""),
|
|
89
|
+
)
|
|
90
|
+
conn.commit()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def apply_migration(
|
|
94
|
+
version: str,
|
|
95
|
+
fn: Callable[[sqlite3.Connection], None],
|
|
96
|
+
*,
|
|
97
|
+
notes: str = "",
|
|
98
|
+
db_path: Path | None = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""Run ``fn(conn)`` under the NEXO_MIGRATING flag and record success.
|
|
101
|
+
|
|
102
|
+
Idempotent: already-applied versions return early with
|
|
103
|
+
``{"applied": False, "reason": "already_applied"}``. A failing ``fn``
|
|
104
|
+
propagates its exception AFTER rolling back the transaction and
|
|
105
|
+
clears the NEXO_MIGRATING flag even on error.
|
|
106
|
+
"""
|
|
107
|
+
path = Path(db_path) if db_path is not None else _db_path()
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
prev_flag = os.environ.get("NEXO_MIGRATING")
|
|
110
|
+
os.environ["NEXO_MIGRATING"] = "1"
|
|
111
|
+
try:
|
|
112
|
+
with sqlite3.connect(str(path)) as conn:
|
|
113
|
+
if is_applied(version, conn=conn):
|
|
114
|
+
return {"applied": False, "version": version, "reason": "already_applied"}
|
|
115
|
+
try:
|
|
116
|
+
fn(conn)
|
|
117
|
+
_record_applied(version, notes, conn)
|
|
118
|
+
_structure_version_path().parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
_structure_version_path().write_text(version + "\n", encoding="utf-8")
|
|
120
|
+
return {"applied": True, "version": version, "notes": notes}
|
|
121
|
+
except Exception:
|
|
122
|
+
conn.rollback()
|
|
123
|
+
raise
|
|
124
|
+
finally:
|
|
125
|
+
if prev_flag is None:
|
|
126
|
+
os.environ.pop("NEXO_MIGRATING", None)
|
|
127
|
+
else:
|
|
128
|
+
os.environ["NEXO_MIGRATING"] = prev_flag
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_structure_version() -> str:
|
|
132
|
+
try:
|
|
133
|
+
return _structure_version_path().read_text(encoding="utf-8").strip()
|
|
134
|
+
except OSError:
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def bootstrap_f00(*, db_path: Path | None = None) -> dict:
|
|
139
|
+
"""Convenience: install the migrations_applied table + F0.0 marker.
|
|
140
|
+
|
|
141
|
+
Safe to call repeatedly; follows the idempotent apply_migration path.
|
|
142
|
+
"""
|
|
143
|
+
def _noop(_conn):
|
|
144
|
+
# F0.0 is a bootstrap marker — the side-effect is just "the
|
|
145
|
+
# migrations_applied table exists and we recorded F0.0". The
|
|
146
|
+
# real schema ALTERs live in later versions (F0.1+).
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
return apply_migration("F0.0", _noop, notes="bootstrap migrations_applied", db_path=db_path)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = [
|
|
153
|
+
"apply_migration",
|
|
154
|
+
"bootstrap_f00",
|
|
155
|
+
"ensure_migrations_table",
|
|
156
|
+
"get_structure_version",
|
|
157
|
+
"is_applied",
|
|
158
|
+
]
|