nexo-brain 7.37.4 → 7.38.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 +2 -2
- package/package.json +1 -1
- package/src/closure_promise_audit.py +134 -0
- package/src/cost_secret_sweep.py +120 -0
- package/src/email_credentials.py +38 -6
- package/src/enforcement_engine.py +81 -1
- package/src/evidence_matrix.py +94 -0
- package/src/hooks/post_tool_use.py +476 -6
- package/src/managed_mcp/lock.json +3 -3
- package/src/mcp_write_queue.py +7 -0
- package/src/plugins/cards.py +84 -1
- package/src/plugins/protocol.py +476 -19
- package/src/r14_correction_learning.py +62 -0
- package/src/rules/core-rules.json +42 -2
- package/src/scripts/cost_secret_sweep.py +37 -0
- package/src/scripts/deep-sleep/apply_findings.py +145 -0
- package/src/skills/support-second-ticket-parallel-sweep/guide.md +19 -0
- package/src/skills/support-second-ticket-parallel-sweep/skill.json +30 -0
- package/src/skills/verify-prod-config/guide.md +46 -0
- package/src/skills/verify-prod-config/skill.json +36 -0
- package/templates/CLAUDE.md.template +10 -0
- package/templates/CODEX.AGENTS.md.template +10 -0
- package/templates/core-prompts/morning-agent.md +6 -0
- package/templates/core-prompts/r14-accepted-correction-injection.md +1 -0
- package/templates/core-prompts/r14-accepted-correction-question.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +5 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.38.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,9 +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.
|
|
21
|
+
Version `7.38.0` is the current packaged-runtime line. Minor release over v7.37.4 - closeout integrity and operator-facing discipline: proactive status on long opaque work, per-item verified state on batch closeouts, decision-readable first responses, an automatic production change ledger across a wider mutation surface, new closure/evidence/cost-secret audit modules, and a correction-learning gate that never blocks mid-work. New behavioral rules ship in shadow/log mode by default.
|
|
22
22
|
|
|
23
|
-
Previously in `7.37.
|
|
23
|
+
Previously in `7.37.4`: patch release over v7.37.3 - product-gap reporting, stale briefing noise, and opportunity loops: Deep Sleep reports recurring NEXO product gaps through sanitized Desktop support tickets, self-audit preserves closed internal opportunities, the morning briefing reads item history before resurfacing decisions, and support-ticket tools match the live backend contract.
|
|
24
24
|
|
|
25
25
|
Previously in `7.37.2`: patch release over v7.37.1 - runtime shutdown and CI stability: session keepalive writers now stop before the shared SQLite connection closes, SQLite close is serialized under the write lock, and the full Brain test workflow has enough time to finish instead of cancelling slow-but-valid runs. The v7.37.2 tag attempt did not publish npm/GitHub release artifacts; v7.37.3 is the public line.
|
|
26
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.38.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",
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Retrospective closure audit for uncaptured promises and corrections."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Callable, Iterable, Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SignalKind = Literal["promise", "correction"]
|
|
11
|
+
CaptureCheck = Callable[[str], bool]
|
|
12
|
+
|
|
13
|
+
_PROMISE_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
14
|
+
re.compile(r"\b(?:lo\s+dejo\s+registrad[oa]|queda\s+registrad[oa])\b", re.I),
|
|
15
|
+
re.compile(r"\b(?:luego|manana|ma[nñ]ana|cuando\s+cierre|al\s+cerrar)\b", re.I),
|
|
16
|
+
re.compile(r"\b(?:te\s+aviso|lo\s+retomo|queda\s+pendiente)\b", re.I),
|
|
17
|
+
re.compile(r"\b(?:I(?:'ll| will)\s+(?:record|follow up|do|check)|later|tomorrow)\b", re.I),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_CORRECTION_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
21
|
+
re.compile(r"\b(?:no[, ]+|eso\s+no|te\s+equivocas|est[aá]\s+mal)\b", re.I),
|
|
22
|
+
re.compile(r"\b(?:corrige|correcci[oó]n|no\s+es\s+eso|as[ií]\s+no)\b", re.I),
|
|
23
|
+
re.compile(r"\b(?:wrong|incorrect|that'?s\s+not|you\s+missed)\b", re.I),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ClosureSignal:
|
|
29
|
+
kind: SignalKind
|
|
30
|
+
text: str
|
|
31
|
+
matched: str
|
|
32
|
+
persisted: bool
|
|
33
|
+
debt: bool
|
|
34
|
+
recommended_action: str
|
|
35
|
+
reason: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ClosureAudit:
|
|
40
|
+
ok: bool
|
|
41
|
+
debt_count: int
|
|
42
|
+
signals: list[ClosureSignal]
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"ok": self.ok,
|
|
47
|
+
"debt_count": self.debt_count,
|
|
48
|
+
"signals": [asdict(signal) for signal in self.signals],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def audit_closure(
|
|
53
|
+
assistant_text: str,
|
|
54
|
+
user_text: str = "",
|
|
55
|
+
*,
|
|
56
|
+
has_followup_for: CaptureCheck | None = None,
|
|
57
|
+
has_learning_for: CaptureCheck | None = None,
|
|
58
|
+
brain_down: bool = False,
|
|
59
|
+
) -> ClosureAudit:
|
|
60
|
+
"""Find promises/corrections that were not persisted before closure.
|
|
61
|
+
|
|
62
|
+
The audit is fail-closed: if persistence cannot be checked, the signal is
|
|
63
|
+
surfaced as explicit debt instead of being silently ignored.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
signals: list[ClosureSignal] = []
|
|
67
|
+
for text, patterns, kind in (
|
|
68
|
+
(assistant_text, _PROMISE_PATTERNS, "promise"),
|
|
69
|
+
(user_text, _CORRECTION_PATTERNS, "correction"),
|
|
70
|
+
):
|
|
71
|
+
for matched in _matches(text, patterns):
|
|
72
|
+
signals.append(
|
|
73
|
+
_evaluate_signal(
|
|
74
|
+
kind=kind,
|
|
75
|
+
text=text,
|
|
76
|
+
matched=matched,
|
|
77
|
+
checker=has_followup_for if kind == "promise" else has_learning_for,
|
|
78
|
+
brain_down=brain_down,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
debt_count = sum(1 for signal in signals if signal.debt)
|
|
83
|
+
return ClosureAudit(ok=debt_count == 0, debt_count=debt_count, signals=signals)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _matches(text: str, patterns: Iterable[re.Pattern[str]]) -> list[str]:
|
|
87
|
+
clean = text or ""
|
|
88
|
+
found: list[str] = []
|
|
89
|
+
seen: set[str] = set()
|
|
90
|
+
for pattern in patterns:
|
|
91
|
+
for match in pattern.finditer(clean):
|
|
92
|
+
value = match.group(0).strip()
|
|
93
|
+
key = value.lower()
|
|
94
|
+
if key not in seen:
|
|
95
|
+
seen.add(key)
|
|
96
|
+
found.append(value)
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _evaluate_signal(
|
|
101
|
+
*,
|
|
102
|
+
kind: SignalKind,
|
|
103
|
+
text: str,
|
|
104
|
+
matched: str,
|
|
105
|
+
checker: CaptureCheck | None,
|
|
106
|
+
brain_down: bool,
|
|
107
|
+
) -> ClosureSignal:
|
|
108
|
+
action = "create_followup" if kind == "promise" else "create_learning"
|
|
109
|
+
if brain_down:
|
|
110
|
+
return ClosureSignal(kind, text, matched, False, True, "mark_debt", "brain_down")
|
|
111
|
+
if checker is None:
|
|
112
|
+
return ClosureSignal(kind, text, matched, False, True, action, "missing_capture_check")
|
|
113
|
+
try:
|
|
114
|
+
persisted = bool(checker(text))
|
|
115
|
+
except Exception as exc: # pragma: no cover - reason asserted without type coupling
|
|
116
|
+
return ClosureSignal(
|
|
117
|
+
kind,
|
|
118
|
+
text,
|
|
119
|
+
matched,
|
|
120
|
+
False,
|
|
121
|
+
True,
|
|
122
|
+
action,
|
|
123
|
+
f"capture_check_failed:{type(exc).__name__}",
|
|
124
|
+
)
|
|
125
|
+
return ClosureSignal(
|
|
126
|
+
kind,
|
|
127
|
+
text,
|
|
128
|
+
matched,
|
|
129
|
+
persisted,
|
|
130
|
+
not persisted,
|
|
131
|
+
"none" if persisted else action,
|
|
132
|
+
"captured" if persisted else "not_captured",
|
|
133
|
+
)
|
|
134
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Daily cost/secret sweep queue builder."""
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from evidence_ledger import _SECRET_PATTERNS
|
|
16
|
+
except Exception: # pragma: no cover
|
|
17
|
+
_SECRET_PATTERNS = (
|
|
18
|
+
re.compile(r"(token\s*[=:]\s*)[^\s'\"]{8,}", re.I),
|
|
19
|
+
re.compile(r"(password\s*[=:]\s*)[^\s'\"]{6,}", re.I),
|
|
20
|
+
re.compile(r"(api[_-]?key\s*[=:]\s*)[^\s'\"]{8,}", re.I),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_CATEGORY_RULES: tuple[tuple[str, re.Pattern[str], int, int, str], ...] = (
|
|
25
|
+
("billing_exhausted", re.compile(r"\b(billing|saldo|quota|cuota).{0,40}\b(agot|exhaust|insufficient|sin saldo)", re.I), 3, 1, "revisar billing y recarga"),
|
|
26
|
+
("pasted_key", re.compile(r"\b(sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|AIza[A-Za-z0-9_-]{30,})\b"), 2, 3, "rotar clave pegada y mover a gestor"),
|
|
27
|
+
("bridge_token", re.compile(r"\b(bridge|mcp|internal).{0,30}\b(token|api[_-]?key)\b", re.I), 2, 2, "rotar token puente y revisar scope"),
|
|
28
|
+
("env_secret", re.compile(r"\b(env|\.env|log|stdout|trace).{0,30}\b(token|password|secret|api[_-]?key)\b", re.I), 2, 3, "mover secreto fuera de env/logs expuestos"),
|
|
29
|
+
("rotation_pending", re.compile(r"(?=.*\b(rotar|rotation|comprometid|expuest|leak|public)\b)(?=.*\b(token|clave|secret|password|credencial|credential|api[_-]?key)\b)", re.I | re.S), 2, 2, "cerrar rotacion con evidencia"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SweepItem:
|
|
35
|
+
category: str
|
|
36
|
+
source: str
|
|
37
|
+
summary: str
|
|
38
|
+
economic_impact: int
|
|
39
|
+
exposure: int
|
|
40
|
+
priority: int
|
|
41
|
+
recommendation: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_sweep_queue(records: Iterable[dict]) -> list[SweepItem]:
|
|
45
|
+
"""Return one prioritized queue sorted by economic impact x exposure."""
|
|
46
|
+
|
|
47
|
+
items: list[SweepItem] = []
|
|
48
|
+
for record in records:
|
|
49
|
+
text = str(record.get("text") or record.get("summary") or "")
|
|
50
|
+
source = str(record.get("source") or "unknown")
|
|
51
|
+
for category, pattern, economic, exposure, recommendation in _CATEGORY_RULES:
|
|
52
|
+
if not pattern.search(text):
|
|
53
|
+
continue
|
|
54
|
+
item = SweepItem(
|
|
55
|
+
category=category,
|
|
56
|
+
source=source,
|
|
57
|
+
summary=redact_secrets(text)[:500],
|
|
58
|
+
economic_impact=int(record.get("economic_impact") or economic),
|
|
59
|
+
exposure=int(record.get("exposure") or exposure),
|
|
60
|
+
priority=int(record.get("economic_impact") or economic) * int(record.get("exposure") or exposure),
|
|
61
|
+
recommendation=recommendation,
|
|
62
|
+
)
|
|
63
|
+
items.append(item)
|
|
64
|
+
break
|
|
65
|
+
return sorted(items, key=lambda item: (-item.priority, item.category, item.source))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def collect_text_sources(paths: Iterable[str]) -> list[dict]:
|
|
69
|
+
records: list[dict] = []
|
|
70
|
+
for pattern in paths:
|
|
71
|
+
for filename in glob.glob(pattern):
|
|
72
|
+
path = Path(filename)
|
|
73
|
+
if not path.is_file():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
text = path.read_text(errors="replace")
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
records.append({"source": str(path), "text": text[:20000]})
|
|
80
|
+
return records
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_sweep(*, records: Iterable[dict] = (), paths: Iterable[str] = ()) -> dict:
|
|
84
|
+
all_records = list(records)
|
|
85
|
+
all_records.extend(collect_text_sources(paths))
|
|
86
|
+
queue = build_sweep_queue(all_records)
|
|
87
|
+
return {
|
|
88
|
+
"generated_at": int(time.time()),
|
|
89
|
+
"count": len(queue),
|
|
90
|
+
"queue": [asdict(item) for item in queue],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_paths(nexo_home: str | None = None) -> list[str]:
|
|
95
|
+
home = Path(nexo_home or os.environ.get("NEXO_HOME") or "~/.nexo").expanduser()
|
|
96
|
+
return [
|
|
97
|
+
str(home / "runtime" / "logs" / "*.log"),
|
|
98
|
+
str(home / "runtime" / "operations" / "*.jsonl"),
|
|
99
|
+
str(home / ".env"),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def append_jsonl_report(report: dict, output_path: str) -> None:
|
|
104
|
+
path = Path(output_path)
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
107
|
+
fh.write(json.dumps(report, ensure_ascii=False, sort_keys=True) + "\n")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def redact_secrets(text: str) -> str:
|
|
111
|
+
redacted = str(text or "")
|
|
112
|
+
for pattern in _SECRET_PATTERNS:
|
|
113
|
+
redacted = pattern.sub(_redact_match, redacted)
|
|
114
|
+
return redacted
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _redact_match(match: re.Match[str]) -> str:
|
|
118
|
+
if match.lastindex:
|
|
119
|
+
return f"{match.group(1)}[REDACTED]"
|
|
120
|
+
return "[REDACTED]"
|
package/src/email_credentials.py
CHANGED
|
@@ -16,6 +16,7 @@ from urllib.parse import quote, unquote
|
|
|
16
16
|
|
|
17
17
|
KEYRING_SERVICE = "com.nexo.email"
|
|
18
18
|
KEYRING_MARKER_PREFIX = "keyring://"
|
|
19
|
+
HEADLESS_FALLBACK_SERVICE_SUFFIX = "::headless-fallback"
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def _db():
|
|
@@ -42,6 +43,10 @@ def _account_name(service: str, key: str) -> str:
|
|
|
42
43
|
return f"{service}:{key}"
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def _headless_fallback_ref(service: str, key: str) -> tuple[str, str]:
|
|
47
|
+
return f"{service}{HEADLESS_FALLBACK_SERVICE_SUFFIX}", key
|
|
48
|
+
|
|
49
|
+
|
|
45
50
|
def _keyring_module():
|
|
46
51
|
try:
|
|
47
52
|
return importlib.import_module("keyring")
|
|
@@ -78,6 +83,32 @@ def _write_db_value(service: str, key: str, value: str, notes: str) -> None:
|
|
|
78
83
|
conn.commit()
|
|
79
84
|
|
|
80
85
|
|
|
86
|
+
def _delete_db_value(service: str, key: str) -> None:
|
|
87
|
+
conn = _db()
|
|
88
|
+
conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
|
|
89
|
+
conn.commit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_headless_fallback(service: str, key: str, value: str, notes: str) -> None:
|
|
93
|
+
fallback_service, fallback_key = _headless_fallback_ref(service, key)
|
|
94
|
+
_write_db_value(
|
|
95
|
+
fallback_service,
|
|
96
|
+
fallback_key,
|
|
97
|
+
value,
|
|
98
|
+
notes + " (headless fallback mirror)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_headless_fallback(service: str, key: str) -> str:
|
|
103
|
+
fallback_service, fallback_key = _headless_fallback_ref(service, key)
|
|
104
|
+
return _read_stored_value(fallback_service, fallback_key)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _delete_headless_fallback(service: str, key: str) -> None:
|
|
108
|
+
fallback_service, fallback_key = _headless_fallback_ref(service, key)
|
|
109
|
+
_delete_db_value(fallback_service, fallback_key)
|
|
110
|
+
|
|
111
|
+
|
|
81
112
|
def store_email_credential(service: str, key: str, value: str, notes: str = "email account password") -> str:
|
|
82
113
|
"""Store an email password and return the SQLite value written.
|
|
83
114
|
|
|
@@ -96,6 +127,7 @@ def store_email_credential(service: str, key: str, value: str, notes: str = "ema
|
|
|
96
127
|
if keyring is not None:
|
|
97
128
|
try:
|
|
98
129
|
keyring.set_password(KEYRING_SERVICE, _account_name(service, key), value)
|
|
130
|
+
_write_headless_fallback(service, key, value, notes)
|
|
99
131
|
stored = _marker(service, key)
|
|
100
132
|
_write_db_value(service, key, stored, notes + " (stored in system keyring)")
|
|
101
133
|
return stored
|
|
@@ -118,12 +150,13 @@ def read_email_credential(service: str, key: str) -> str:
|
|
|
118
150
|
|
|
119
151
|
keyring = _keyring_module()
|
|
120
152
|
if keyring is None:
|
|
121
|
-
return
|
|
153
|
+
return _read_headless_fallback(*parsed)
|
|
122
154
|
try:
|
|
123
155
|
value = keyring.get_password(KEYRING_SERVICE, _account_name(*parsed))
|
|
124
156
|
except Exception:
|
|
125
|
-
|
|
126
|
-
|
|
157
|
+
value = ""
|
|
158
|
+
value = str(value or "")
|
|
159
|
+
return value or _read_headless_fallback(*parsed)
|
|
127
160
|
|
|
128
161
|
|
|
129
162
|
def delete_email_credential(service: str, key: str) -> None:
|
|
@@ -140,9 +173,8 @@ def delete_email_credential(service: str, key: str) -> None:
|
|
|
140
173
|
except Exception:
|
|
141
174
|
pass
|
|
142
175
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
conn.commit()
|
|
176
|
+
_delete_db_value(service, key)
|
|
177
|
+
_delete_headless_fallback(service, key)
|
|
146
178
|
|
|
147
179
|
|
|
148
180
|
def is_keyring_marker(value: str) -> bool:
|
|
@@ -31,12 +31,16 @@ except ImportError: # pragma: no cover — fallback for editable installs with
|
|
|
31
31
|
try:
|
|
32
32
|
from r14_correction_learning import (
|
|
33
33
|
detect_correction as _detect_correction,
|
|
34
|
+
detect_accepted_correction as _detect_accepted_correction,
|
|
34
35
|
INJECTION_PROMPT_TEMPLATE as _R14_PROMPT,
|
|
36
|
+
ACCEPTANCE_INJECTION_PROMPT_TEMPLATE as _R14_ACCEPTANCE_PROMPT,
|
|
35
37
|
DEFAULT_WINDOW_TOOL_CALLS as _R14_WINDOW,
|
|
36
38
|
)
|
|
37
39
|
except ImportError: # pragma: no cover
|
|
38
40
|
_detect_correction = None # type: ignore
|
|
41
|
+
_detect_accepted_correction = None # type: ignore
|
|
39
42
|
_R14_PROMPT = "" # type: ignore
|
|
43
|
+
_R14_ACCEPTANCE_PROMPT = "" # type: ignore
|
|
40
44
|
_R14_WINDOW = 3
|
|
41
45
|
|
|
42
46
|
try:
|
|
@@ -981,7 +985,78 @@ class HeadlessEnforcer:
|
|
|
981
985
|
self._r14_correction_seen_for_turn = False
|
|
982
986
|
self._r14_correction_text = ""
|
|
983
987
|
|
|
984
|
-
def
|
|
988
|
+
def _learning_add_seen_for_current_turn(self) -> bool:
|
|
989
|
+
current_turn = int(self.user_message_count or 0)
|
|
990
|
+
return any(
|
|
991
|
+
self._tool_user_message_index.get(tool, -1) >= current_turn
|
|
992
|
+
for tool in ("nexo_learning_add",)
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
def _record_missing_learning_after_accepted_correction(self) -> None:
|
|
996
|
+
if not self._session_id:
|
|
997
|
+
return
|
|
998
|
+
try:
|
|
999
|
+
from db import create_protocol_debt, list_protocol_debts, record_session_correction_requirement # type: ignore
|
|
1000
|
+
|
|
1001
|
+
record_session_correction_requirement(
|
|
1002
|
+
self._session_id,
|
|
1003
|
+
self._r14_correction_text,
|
|
1004
|
+
source="r14_accepted_correction_gate",
|
|
1005
|
+
)
|
|
1006
|
+
existing = list_protocol_debts(
|
|
1007
|
+
status="open",
|
|
1008
|
+
session_id=self._session_id,
|
|
1009
|
+
debt_type="missing_learning_after_correction",
|
|
1010
|
+
limit=1,
|
|
1011
|
+
)
|
|
1012
|
+
if not existing:
|
|
1013
|
+
create_protocol_debt(
|
|
1014
|
+
self._session_id,
|
|
1015
|
+
"missing_learning_after_correction",
|
|
1016
|
+
severity="error",
|
|
1017
|
+
evidence=(
|
|
1018
|
+
"R14 detected that the assistant accepted a user correction "
|
|
1019
|
+
"before nexo_learning_add was called."
|
|
1020
|
+
),
|
|
1021
|
+
)
|
|
1022
|
+
except Exception:
|
|
1023
|
+
pass
|
|
1024
|
+
|
|
1025
|
+
def _check_r14_accepted_correction(self, text: str, *, accepted_detector=None) -> None:
|
|
1026
|
+
if not self._r14_correction_seen_for_turn:
|
|
1027
|
+
return
|
|
1028
|
+
if self._learning_add_seen_for_current_turn():
|
|
1029
|
+
return
|
|
1030
|
+
detector = accepted_detector if accepted_detector is not None else _detect_accepted_correction
|
|
1031
|
+
if detector is None:
|
|
1032
|
+
return
|
|
1033
|
+
mode = self._guardian_rule_mode("R14_correction_learning")
|
|
1034
|
+
if mode == "off":
|
|
1035
|
+
return
|
|
1036
|
+
try:
|
|
1037
|
+
accepted = bool(
|
|
1038
|
+
detector(
|
|
1039
|
+
text or "",
|
|
1040
|
+
correction_text=self._r14_correction_text,
|
|
1041
|
+
)
|
|
1042
|
+
)
|
|
1043
|
+
except Exception as exc: # noqa: BLE001
|
|
1044
|
+
_logger.warning("R14 accepted-correction detector failed (%s); staying silent", exc)
|
|
1045
|
+
accepted = False
|
|
1046
|
+
if not accepted:
|
|
1047
|
+
return
|
|
1048
|
+
if mode == "shadow":
|
|
1049
|
+
_logger.info("[R14 SHADOW] would block accepted correction without learning_add")
|
|
1050
|
+
return
|
|
1051
|
+
self._enqueue(
|
|
1052
|
+
_R14_ACCEPTANCE_PROMPT,
|
|
1053
|
+
"r14:accepted-correction-without-learning",
|
|
1054
|
+
rule_id="R14_correction_learning",
|
|
1055
|
+
)
|
|
1056
|
+
self._record_missing_learning_after_accepted_correction()
|
|
1057
|
+
_logger.info("[R14 %s] enqueued accepted-correction learning gate", mode.upper())
|
|
1058
|
+
|
|
1059
|
+
def on_assistant_text(self, text: str, *, declared_detector=None, has_open_task=None, accepted_correction_detector=None):
|
|
985
1060
|
"""R16 — scan assistant message for done-claim with open protocol_task.
|
|
986
1061
|
|
|
987
1062
|
Args:
|
|
@@ -1005,6 +1080,10 @@ class HeadlessEnforcer:
|
|
|
1005
1080
|
self._check_jargon_text(text, tag="r26:first-response-jargon")
|
|
1006
1081
|
self._check_execute_before_ask(text)
|
|
1007
1082
|
self._check_capability_denial_requires_reality(text)
|
|
1083
|
+
self._check_r14_accepted_correction(
|
|
1084
|
+
text,
|
|
1085
|
+
accepted_detector=accepted_correction_detector,
|
|
1086
|
+
)
|
|
1008
1087
|
if _detect_declared_done is None:
|
|
1009
1088
|
return
|
|
1010
1089
|
mode = self._guardian_rule_mode("R16_declared_done")
|
|
@@ -1362,6 +1441,7 @@ class HeadlessEnforcer:
|
|
|
1362
1441
|
r"\bgcloud\s+builds\s+(?:submit|triggers\s+run)\b",
|
|
1363
1442
|
r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
|
|
1364
1443
|
r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
|
|
1444
|
+
r"\b(?:alembic\s+upgrade|prisma\s+migrate\s+deploy|sequelize\s+db:migrate|knex\s+migrate:latest|rails\s+db:migrate|python(?:3)?\s+manage\.py\s+migrate|php\s+artisan\s+migrate)\b",
|
|
1365
1445
|
r"\b(?:whmapi1|uapi|cpapi2)\b",
|
|
1366
1446
|
r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
|
|
1367
1447
|
r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Require live evidence before root-cause claims in sensitive domains."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable, Iterable, Literal
|
|
8
|
+
|
|
9
|
+
try: # Reuse the existing ledger when available, but keep tests injectable.
|
|
10
|
+
from evidence_ledger import search_evidence as _ledger_search
|
|
11
|
+
except Exception: # pragma: no cover
|
|
12
|
+
_ledger_search = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Verdict = Literal["pass", "needs_evidence", "not_applicable"]
|
|
16
|
+
SearchFn = Callable[[str], Iterable[Any]]
|
|
17
|
+
|
|
18
|
+
_DOMAIN_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {
|
|
19
|
+
"provider": (re.compile(r"\b(provider|proveedor|cloudflare|openai|shopify|stripe|google)\b", re.I),),
|
|
20
|
+
"quota": (re.compile(r"\b(quota|cuota|limit|limite|l[ií]mite|billing agotado)\b", re.I),),
|
|
21
|
+
"payment": (re.compile(r"\b(payment|pago|billing|facturaci[oó]n|tarjeta|stripe)\b", re.I),),
|
|
22
|
+
"token": (re.compile(r"\b(token|api[_ -]?key|clave|secret|secreto|credential|credencial)\b", re.I),),
|
|
23
|
+
"firewall": (re.compile(r"\b(firewall|csf|waf|iptables|cloud armor|bloque[oó])\b", re.I),),
|
|
24
|
+
"production": (re.compile(r"\b(prod|producci[oó]n|live|deploy|cloud run|server|servidor)\b", re.I),),
|
|
25
|
+
"customer_impact": (re.compile(r"\b(cliente|customer|venta|pedido|impacto|ca[ií]da|503|5xx)\b", re.I),),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_CAUSE_PATTERN = re.compile(
|
|
29
|
+
r"\b(causa|root cause|porque|por\s+que|se debe a|caused by|culpa|fall[oó]\s+por|bloqueado por)\b",
|
|
30
|
+
re.I,
|
|
31
|
+
)
|
|
32
|
+
_HEDGED_PATTERN = re.compile(
|
|
33
|
+
r"\b(podr[ií]a|parece|posible|hip[oó]tesis|hay que verificar|no confirmado|maybe|might|could)\b|\?$",
|
|
34
|
+
re.I,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EvidenceMatrixResult:
|
|
40
|
+
verdict: Verdict
|
|
41
|
+
domains: list[str]
|
|
42
|
+
evidence_count: int
|
|
43
|
+
reason: str
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"verdict": self.verdict,
|
|
48
|
+
"domains": self.domains,
|
|
49
|
+
"evidence_count": self.evidence_count,
|
|
50
|
+
"reason": self.reason,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_claim(
|
|
55
|
+
text: str,
|
|
56
|
+
*,
|
|
57
|
+
evidence_refs: Iterable[Any] | None = None,
|
|
58
|
+
search: SearchFn | None = None,
|
|
59
|
+
) -> EvidenceMatrixResult:
|
|
60
|
+
"""Validate a sensitive causal claim against live evidence pointers."""
|
|
61
|
+
|
|
62
|
+
claim = text or ""
|
|
63
|
+
domains = _domains_for(claim)
|
|
64
|
+
if not domains or not _CAUSE_PATTERN.search(claim) or _HEDGED_PATTERN.search(claim):
|
|
65
|
+
return EvidenceMatrixResult("not_applicable", domains, 0, "non_causal_or_hedged")
|
|
66
|
+
|
|
67
|
+
refs = [ref for ref in (evidence_refs or []) if ref]
|
|
68
|
+
if refs:
|
|
69
|
+
return EvidenceMatrixResult("pass", domains, len(refs), "explicit_evidence_refs")
|
|
70
|
+
|
|
71
|
+
search_fn = search or _ledger_search
|
|
72
|
+
if search_fn is not None:
|
|
73
|
+
try:
|
|
74
|
+
hits = list(search_fn(claim) or [])
|
|
75
|
+
except Exception:
|
|
76
|
+
hits = []
|
|
77
|
+
if hits:
|
|
78
|
+
return EvidenceMatrixResult("pass", domains, len(hits), "ledger_evidence")
|
|
79
|
+
|
|
80
|
+
return EvidenceMatrixResult(
|
|
81
|
+
"needs_evidence",
|
|
82
|
+
domains,
|
|
83
|
+
0,
|
|
84
|
+
"cita un log, query, endpoint, commit o reproduccion antes de afirmar causa raiz",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _domains_for(text: str) -> list[str]:
|
|
89
|
+
return [
|
|
90
|
+
domain
|
|
91
|
+
for domain, patterns in _DOMAIN_PATTERNS.items()
|
|
92
|
+
if any(pattern.search(text or "") for pattern in patterns)
|
|
93
|
+
]
|
|
94
|
+
|