nexo-brain 7.2.0 → 7.4.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/hooks/hooks.json +12 -0
- package/package.json +2 -1
- package/src/auto_update.py +128 -17
- package/src/cli.py +53 -0
- package/src/client_sync.py +3 -0
- package/src/crons/manifest.json +12 -0
- package/src/db/_schema.py +41 -0
- package/src/hook_guardrails.py +21 -0
- package/src/hooks/manifest.json +1 -0
- package/src/hooks/pre_tool_use.py +161 -0
- package/src/lifecycle_events.py +186 -0
- package/src/plugins/lifecycle_events.py +113 -0
- package/src/presets/entities_universal.json +74 -0
- package/src/scripts/guardian_metrics_aggregate.py +177 -0
- package/tool-enforcement-map.json +3740 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.0",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.
|
|
21
|
+
Version `7.4.0` is the current packaged-runtime line. Hotfix minor release correcting three critical bugs that surfaced right after v7.2.0. B11 Guardian wire: new `src/hooks/pre_tool_use.py` entrypoint is now registered in `src/hooks/manifest.json` and `hooks/hooks.json` so the Claude Code PreToolUse event actually reaches Guardian — before this, `guardian-runtime-overrides.json` in hard mode was silently inert for G3-destructive, G3-SSH, and G4-guard_check gates. A parallel SSH prescreen in `hook_guardrails.process_pre_tool_event` forces `op=write` on remote-write patterns the local shell classifier could not see. B10 post-sync: `_run_post_install_hooks_fresh(dest, env)` invokes each whitelisted hook (`_persist_guardian_hard_defaults`, `_maybe_promote_adaptive_weights_empirically`) in a clean subprocess against the newly copied tree, so the first `nexo update` that introduces a new post-install hook actually runs it — previously the hook dispatch used the pre-upgrade module held in memory and silently no-op'd. B12 map distribution: `tool-enforcement-map.json` is now part of the npm `files` whitelist so fresh `npm install -g nexo-brain` ships it, closing the three-way gap (Brain npm + Desktop bundle + runtime sync) that prevented Desktop from ever discovering the map on new installs. Also ships the PE1 rapid items promised for this milestone: 5 additional `destructive_command` entries in `src/presets/entities_universal.json` (`curl_pipe_shell`, `dd_to_device`, `chmod_recursive_wide_open`, `ssh_remote_overwrite`, `scp_rsync_upload`; coverage floor raised from 7 to 12) and the `guardian-metrics` daily cron at 02:15 that feeds Fase C gate and the Guardian Proposals panel.
|
|
22
|
+
|
|
23
|
+
Previously in `7.2.0`: minor release consolidating three parallel workstreams into a single Guardian-active-by-default train. Block K roadmap closure (G1 enforcer active, G3 SSH remote-write detector, `src/guardian_runtime_config.py` resolver, `_persist_guardian_hard_defaults` during `nexo update`). F0.6 hardening wave (`nexo rollback f06` CLI, `src/scripts/prune_runtime_backups.py` promoted to core, `docs/f06-layout-contract.md`, three new doctor boot-tier checks, `scripts/nexo-migrate-nora.sh` + `scripts/f0-safe-apply-remote.sh` idempotent migration). Adaptive weights flipped from "14-day calendar wait" to "14 days OR (≥200 samples AND ≥2 days)" with auto-promotion during `nexo update`. Small-fixes batch: R34 `bool("unknown")==True` fix, `classify_scripts_dir` dedup, B10 module-level path constants lazy-evaluated, schedule override audit log, `scripts/pre-release-verify.sh` + `docs/release-discipline.md`, pre-commit hook that blocks commits when `tool-enforcement-map.json` drifts from `src/plugins/`.
|
|
22
24
|
|
|
23
25
|
Previously in `7.1.10`: follow-up over v7.1.8 that shipped two rescue batches of WIP stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expanded the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), added three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wired post/pre-compact hooks that read those flags, surfaced them through protocol/workflow handlers and session payload, and introduced the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gained a smoke-artifact contract pass that validates `release-contracts/smoke/v<version>.json` before any tag push, the release-final audit skill references the new contract, `src/hook_guardrails.py` + `src/hooks/post_tool_use.py` refine the post-tool protocol reminder path with a new contract test, and a couple of core prompts (task-close evidence, r14 correction learning) got wording polish.
|
|
24
26
|
|
package/hooks/hooks.json
CHANGED
|
@@ -40,6 +40,18 @@
|
|
|
40
40
|
]
|
|
41
41
|
}
|
|
42
42
|
],
|
|
43
|
+
"PreToolUse": [
|
|
44
|
+
{
|
|
45
|
+
"matcher": "*",
|
|
46
|
+
"hooks": [
|
|
47
|
+
{
|
|
48
|
+
"type": "command",
|
|
49
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/pre_tool_use.py\"",
|
|
50
|
+
"timeout": 8
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
],
|
|
43
55
|
"PostToolUse": [
|
|
44
56
|
{
|
|
45
57
|
"matcher": "*",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain \u2014 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",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
".claude-plugin/",
|
|
90
90
|
".mcp.json",
|
|
91
91
|
"hooks/hooks.json",
|
|
92
|
+
"tool-enforcement-map.json",
|
|
92
93
|
"README.md",
|
|
93
94
|
"LICENSE"
|
|
94
95
|
]
|
package/src/auto_update.py
CHANGED
|
@@ -4648,25 +4648,27 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
|
|
|
4648
4648
|
except Exception as exc:
|
|
4649
4649
|
actions.append(f"classifier-install-warning:{exc.__class__.__name__}")
|
|
4650
4650
|
|
|
4651
|
+
# v7.3.0 fix for the post-v7.2.0 bug: ``_run_runtime_post_sync`` was
|
|
4652
|
+
# invoking post-install hooks directly against the ``auto_update``
|
|
4653
|
+
# module already loaded in this process (the OLD code). Any function
|
|
4654
|
+
# added to auto_update in the CURRENT release therefore never ran on
|
|
4655
|
+
# the first ``nexo update`` that introduced it — only on the next
|
|
4656
|
+
# one. Exhibit A: v7.2.0 shipped ``_persist_guardian_hard_defaults``
|
|
4657
|
+
# and ``_maybe_promote_adaptive_weights_empirically`` but
|
|
4658
|
+
# ``guardian-runtime-overrides.json`` was not written on the operator's
|
|
4659
|
+
# first upgrade until the functions were invoked manually.
|
|
4660
|
+
#
|
|
4661
|
+
# The fix runs those post-install hooks inside a clean Python
|
|
4662
|
+
# subprocess that imports from the freshly-copied tree at
|
|
4663
|
+
# ``dest/core`` (packaged) or ``dest`` (dev/legacy layout). The
|
|
4664
|
+
# subprocess emits a single JSON line so the parent process can
|
|
4665
|
+
# record each hook's outcome in ``actions``.
|
|
4651
4666
|
try:
|
|
4652
|
-
_emit_progress(progress_fn, "
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
actions.append("guardian-hard-persisted")
|
|
4656
|
-
if persist_message:
|
|
4657
|
-
actions.append(persist_message)
|
|
4667
|
+
_emit_progress(progress_fn, "Running post-install hooks from fresh code...")
|
|
4668
|
+
fresh_actions = _run_post_install_hooks_fresh(dest, env=env)
|
|
4669
|
+
actions.extend(fresh_actions)
|
|
4658
4670
|
except Exception as exc:
|
|
4659
|
-
actions.append(f"
|
|
4660
|
-
|
|
4661
|
-
try:
|
|
4662
|
-
_emit_progress(progress_fn, "Promoting adaptive weights if enough data...")
|
|
4663
|
-
promoted, promote_message = _maybe_promote_adaptive_weights_empirically(dest)
|
|
4664
|
-
if promoted:
|
|
4665
|
-
actions.append("adaptive-weights-promoted")
|
|
4666
|
-
if promote_message:
|
|
4667
|
-
actions.append(promote_message)
|
|
4668
|
-
except Exception as exc:
|
|
4669
|
-
actions.append(f"adaptive-weights-promote-warning:{exc.__class__.__name__}")
|
|
4671
|
+
actions.append(f"post-install-hooks-fresh-warning:{exc.__class__.__name__}")
|
|
4670
4672
|
|
|
4671
4673
|
_emit_progress(progress_fn, "Verifying runtime imports...")
|
|
4672
4674
|
verify = subprocess.run(
|
|
@@ -4886,6 +4888,115 @@ def _maybe_promote_adaptive_weights_empirically(dest: Path) -> tuple[bool, str |
|
|
|
4886
4888
|
return True, None
|
|
4887
4889
|
|
|
4888
4890
|
|
|
4891
|
+
# Whitelist of post-install hooks to invoke from the fresh tree. Each entry
|
|
4892
|
+
# is the function name inside ``auto_update.py`` of the freshly-copied
|
|
4893
|
+
# code. The subprocess resolves them on the NEW module and calls
|
|
4894
|
+
# ``fn(dest)`` returning ``(bool, str | None)``. New hooks added in
|
|
4895
|
+
# future releases only need an entry here — no extra wiring.
|
|
4896
|
+
_POST_INSTALL_FRESH_HOOKS = (
|
|
4897
|
+
("guardian-hard-persisted", "_persist_guardian_hard_defaults"),
|
|
4898
|
+
("adaptive-weights-promoted", "_maybe_promote_adaptive_weights_empirically"),
|
|
4899
|
+
)
|
|
4900
|
+
|
|
4901
|
+
|
|
4902
|
+
def _run_post_install_hooks_fresh(dest: Path, *, env: dict | None = None) -> list[str]:
|
|
4903
|
+
"""Execute post-install hooks from the freshly-copied code, not the old
|
|
4904
|
+
module already loaded in this process.
|
|
4905
|
+
|
|
4906
|
+
Without this, any function added to ``auto_update.py`` in the current
|
|
4907
|
+
release never runs on the first ``nexo update`` — only on the next
|
|
4908
|
+
one. See the post-v7.2.0 bug: ``_persist_guardian_hard_defaults`` and
|
|
4909
|
+
``_maybe_promote_adaptive_weights_empirically`` both shipped in
|
|
4910
|
+
v7.2.0 but neither fired until the operator ran the functions
|
|
4911
|
+
manually.
|
|
4912
|
+
|
|
4913
|
+
Resolution order for the fresh code root:
|
|
4914
|
+
1. ``<dest>/core`` (packaged/F0.6 layout).
|
|
4915
|
+
2. ``<dest>`` (legacy/dev layout).
|
|
4916
|
+
|
|
4917
|
+
Subprocess contract: emits a single JSON line on stdout with per-hook
|
|
4918
|
+
``{"action": ..., "ok": bool, "changed": bool, "message": str|null}``.
|
|
4919
|
+
Returns a list of action strings mirroring the shape the old in-process
|
|
4920
|
+
path used, so callers that read ``actions`` keep working unchanged.
|
|
4921
|
+
"""
|
|
4922
|
+
code_root = dest / "core"
|
|
4923
|
+
if not code_root.is_dir():
|
|
4924
|
+
code_root = dest
|
|
4925
|
+
hook_specs = list(_POST_INSTALL_FRESH_HOOKS)
|
|
4926
|
+
hook_specs_json = json.dumps(hook_specs)
|
|
4927
|
+
dest_str = str(dest)
|
|
4928
|
+
script = (
|
|
4929
|
+
"import json, sys\n"
|
|
4930
|
+
"from pathlib import Path\n"
|
|
4931
|
+
f"sys.path.insert(0, {repr(str(code_root))})\n"
|
|
4932
|
+
"hooks = json.loads(" + repr(hook_specs_json) + ")\n"
|
|
4933
|
+
f"dest = Path({repr(dest_str)})\n"
|
|
4934
|
+
"results = []\n"
|
|
4935
|
+
"try:\n"
|
|
4936
|
+
" import auto_update as fresh\n"
|
|
4937
|
+
"except Exception as exc:\n"
|
|
4938
|
+
" print(json.dumps({'error': 'import_auto_update_failed', 'detail': repr(exc)}))\n"
|
|
4939
|
+
" sys.exit(0)\n"
|
|
4940
|
+
"for tag, fn_name in hooks:\n"
|
|
4941
|
+
" fn = getattr(fresh, fn_name, None)\n"
|
|
4942
|
+
" if fn is None:\n"
|
|
4943
|
+
" results.append({'action': tag, 'ok': False, 'changed': False, 'message': 'fn_missing:' + fn_name})\n"
|
|
4944
|
+
" continue\n"
|
|
4945
|
+
" try:\n"
|
|
4946
|
+
" changed, message = fn(dest)\n"
|
|
4947
|
+
" results.append({'action': tag, 'ok': True, 'changed': bool(changed), 'message': message})\n"
|
|
4948
|
+
" except Exception as exc:\n"
|
|
4949
|
+
" results.append({'action': tag, 'ok': False, 'changed': False, 'message': 'error:' + exc.__class__.__name__})\n"
|
|
4950
|
+
"print(json.dumps({'hooks': results}))\n"
|
|
4951
|
+
)
|
|
4952
|
+
try:
|
|
4953
|
+
proc = subprocess.run(
|
|
4954
|
+
[sys.executable, "-c", script],
|
|
4955
|
+
capture_output=True,
|
|
4956
|
+
text=True,
|
|
4957
|
+
timeout=60,
|
|
4958
|
+
env=env or os.environ.copy(),
|
|
4959
|
+
)
|
|
4960
|
+
except subprocess.TimeoutExpired:
|
|
4961
|
+
return ["post-install-hooks-fresh-warning:timeout"]
|
|
4962
|
+
except Exception as exc:
|
|
4963
|
+
return [f"post-install-hooks-fresh-warning:{exc.__class__.__name__}"]
|
|
4964
|
+
|
|
4965
|
+
if proc.returncode != 0:
|
|
4966
|
+
return [f"post-install-hooks-fresh-warning:exit-{proc.returncode}"]
|
|
4967
|
+
|
|
4968
|
+
stdout = (proc.stdout or "").strip()
|
|
4969
|
+
payload = None
|
|
4970
|
+
for line in reversed(stdout.splitlines()):
|
|
4971
|
+
line = line.strip()
|
|
4972
|
+
if not line:
|
|
4973
|
+
continue
|
|
4974
|
+
try:
|
|
4975
|
+
payload = json.loads(line)
|
|
4976
|
+
break
|
|
4977
|
+
except Exception:
|
|
4978
|
+
continue
|
|
4979
|
+
if not isinstance(payload, dict):
|
|
4980
|
+
return ["post-install-hooks-fresh-warning:no-json-line"]
|
|
4981
|
+
if "error" in payload:
|
|
4982
|
+
return [f"post-install-hooks-fresh-warning:{payload['error']}"]
|
|
4983
|
+
|
|
4984
|
+
actions: list[str] = []
|
|
4985
|
+
hooks_out = payload.get("hooks")
|
|
4986
|
+
if not isinstance(hooks_out, list):
|
|
4987
|
+
return ["post-install-hooks-fresh-warning:bad-shape"]
|
|
4988
|
+
for entry in hooks_out:
|
|
4989
|
+
if not isinstance(entry, dict):
|
|
4990
|
+
continue
|
|
4991
|
+
tag = str(entry.get("action") or "")
|
|
4992
|
+
if entry.get("ok") and entry.get("changed"):
|
|
4993
|
+
actions.append(tag)
|
|
4994
|
+
message = entry.get("message")
|
|
4995
|
+
if message:
|
|
4996
|
+
actions.append(str(message))
|
|
4997
|
+
return actions
|
|
4998
|
+
|
|
4999
|
+
|
|
4889
5000
|
def _parse_runtime_init_payload(stdout: str) -> dict:
|
|
4890
5001
|
"""Extract the JSON payload emitted by the runtime init helper."""
|
|
4891
5002
|
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
package/src/cli.py
CHANGED
|
@@ -3076,6 +3076,22 @@ def main():
|
|
|
3076
3076
|
dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
|
|
3077
3077
|
|
|
3078
3078
|
# -- desktop bridge (read-only, for NEXO Desktop and any external UI) --
|
|
3079
|
+
# v7.4.0 — lifecycle event bridge (guardian-claude-desktop-plan).
|
|
3080
|
+
lifecycle_parser = sub.add_parser("lifecycle", help="Conversation lifecycle event handler (v7.4 Desktop bridge)")
|
|
3081
|
+
lifecycle_sub = lifecycle_parser.add_subparsers(dest="lifecycle_command")
|
|
3082
|
+
|
|
3083
|
+
lrec_p = lifecycle_sub.add_parser("record", help="Record a lifecycle event")
|
|
3084
|
+
lrec_p.add_argument("--event-id", required=True, help="UUID minted by the client (idempotency key)")
|
|
3085
|
+
lrec_p.add_argument("--action", required=True, help="close|delete|archive|switch|app-exit|window-close")
|
|
3086
|
+
lrec_p.add_argument("--conversation-id", required=True)
|
|
3087
|
+
lrec_p.add_argument("--session-id", default="")
|
|
3088
|
+
lrec_p.add_argument("--reason", default="user_action")
|
|
3089
|
+
lrec_p.add_argument("--payload", default="", help="JSON-encoded payload_snapshot")
|
|
3090
|
+
lrec_p.add_argument("--source", default="desktop")
|
|
3091
|
+
|
|
3092
|
+
lstat_p = lifecycle_sub.add_parser("status", help="Read the current delivery_status of an event")
|
|
3093
|
+
lstat_p.add_argument("--event-id", required=True)
|
|
3094
|
+
|
|
3079
3095
|
# Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
|
|
3080
3096
|
quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
|
|
3081
3097
|
quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
|
|
@@ -3288,6 +3304,43 @@ def main():
|
|
|
3288
3304
|
# No subcommand — show help.
|
|
3289
3305
|
quarantine_parser.print_help()
|
|
3290
3306
|
return 1
|
|
3307
|
+
elif args.command == "lifecycle":
|
|
3308
|
+
# v7.4.0 — bridge for NEXO Desktop's ConversationLifecycleService.
|
|
3309
|
+
# Both subcommands emit a single JSON line to stdout so the
|
|
3310
|
+
# caller (main.js runNexoCommand) can parse the ack directly.
|
|
3311
|
+
import json as _json
|
|
3312
|
+
import plugins.lifecycle_events as _lifecycle_plugin
|
|
3313
|
+
if args.lifecycle_command == "record":
|
|
3314
|
+
out = _lifecycle_plugin.handle_nexo_lifecycle_event(
|
|
3315
|
+
event_id=args.event_id,
|
|
3316
|
+
action=args.action,
|
|
3317
|
+
conversation_id=args.conversation_id,
|
|
3318
|
+
session_id=args.session_id or "",
|
|
3319
|
+
reason=args.reason or "user_action",
|
|
3320
|
+
payload_snapshot=args.payload or "",
|
|
3321
|
+
source=args.source or "desktop",
|
|
3322
|
+
schema_version=1,
|
|
3323
|
+
)
|
|
3324
|
+
print(out)
|
|
3325
|
+
try:
|
|
3326
|
+
parsed = _json.loads(out)
|
|
3327
|
+
status = str(parsed.get("status", ""))
|
|
3328
|
+
except Exception:
|
|
3329
|
+
status = ""
|
|
3330
|
+
# Exit code 0 for terminal ok states, 2 for retryable_error so
|
|
3331
|
+
# Desktop can distinguish "persisted + processed" from "try
|
|
3332
|
+
# again on boot reconciliation". Rejected is exit 3 (bad input).
|
|
3333
|
+
if status in ("processed", "already_processed", "accepted"):
|
|
3334
|
+
return 0
|
|
3335
|
+
if status == "retryable_error":
|
|
3336
|
+
return 2
|
|
3337
|
+
return 3
|
|
3338
|
+
if args.lifecycle_command == "status":
|
|
3339
|
+
out = _lifecycle_plugin.handle_nexo_lifecycle_status(args.event_id)
|
|
3340
|
+
print(out)
|
|
3341
|
+
return 0
|
|
3342
|
+
lifecycle_parser.print_help()
|
|
3343
|
+
return 1
|
|
3291
3344
|
elif args.command in ("schema", "identity", "onboard", "scan-profile"):
|
|
3292
3345
|
from desktop_bridge import cmd_schema, cmd_identity, cmd_onboard, cmd_scan_profile
|
|
3293
3346
|
return {
|
package/src/client_sync.py
CHANGED
|
@@ -87,6 +87,9 @@ HOOK_TIMEOUTS_BY_EVENT = {
|
|
|
87
87
|
"PreCompact": 15,
|
|
88
88
|
"PostCompact": 15,
|
|
89
89
|
"UserPromptSubmit": 5,
|
|
90
|
+
# PreToolUse is synchronous on every tool call — keep low. 8s gives room
|
|
91
|
+
# for DB lookups under load without stalling routine Edit/Write/Bash.
|
|
92
|
+
"PreToolUse": 8,
|
|
90
93
|
"PostToolUse": 20,
|
|
91
94
|
"Notification": 3,
|
|
92
95
|
"SubagentStop": 10,
|
package/src/crons/manifest.json
CHANGED
|
@@ -192,6 +192,18 @@
|
|
|
192
192
|
"run_on_boot": true,
|
|
193
193
|
"run_on_wake": true
|
|
194
194
|
},
|
|
195
|
+
{
|
|
196
|
+
"id": "guardian-metrics",
|
|
197
|
+
"script": "scripts/guardian_metrics_aggregate.py",
|
|
198
|
+
"schedule": {"hour": 2, "minute": 15},
|
|
199
|
+
"description": "Plan Consolidado 0.25 — daily aggregation of Guardian KPIs (capture rate, core rule violations, declared-done without evidence, false-positive correction, minutes between guard_check failures) from guardian-telemetry.ndjson to guardian-metrics.ndjson. Feeds Fase C gate + Guardian Proposals panel.",
|
|
200
|
+
"core": true,
|
|
201
|
+
"recovery_policy": "catchup",
|
|
202
|
+
"idempotent": true,
|
|
203
|
+
"max_catchup_age": 172800,
|
|
204
|
+
"run_on_boot": false,
|
|
205
|
+
"run_on_wake": false
|
|
206
|
+
},
|
|
195
207
|
{
|
|
196
208
|
"id": "auto-close-sessions",
|
|
197
209
|
"script": "auto_close_sessions.py",
|
package/src/db/_schema.py
CHANGED
|
@@ -1311,6 +1311,46 @@ def _m44_entities_extended_schema(conn):
|
|
|
1311
1311
|
_migrate_add_column(conn, "entities", "access_mode", "TEXT DEFAULT 'unknown'")
|
|
1312
1312
|
|
|
1313
1313
|
|
|
1314
|
+
def _m51_lifecycle_events(conn):
|
|
1315
|
+
"""v7.4.0 — durable lifecycle event store for the Desktop pipeline.
|
|
1316
|
+
|
|
1317
|
+
Matches the Desktop-side NDJSON queue contract:
|
|
1318
|
+
event_id (PRIMARY KEY, uuid from Desktop), source (desktop|cron|...),
|
|
1319
|
+
action (close|delete|archive|switch|app-exit|window-close),
|
|
1320
|
+
conversation_id, session_id (claude session), reason,
|
|
1321
|
+
payload_snapshot (JSON), delivery_status
|
|
1322
|
+
(pending|accepted|processed|already_processed|rejected|retryable_error),
|
|
1323
|
+
retry_count, created_at, processed_at, last_error.
|
|
1324
|
+
|
|
1325
|
+
Idempotency key is event_id. Re-delivery of the same event_id returns
|
|
1326
|
+
status=already_processed without re-running any canonical side effect
|
|
1327
|
+
(diary, stop, archive bookkeeping). This is the backbone of
|
|
1328
|
+
guardian-claude-desktop-plan.md → "5. Idempotencia real".
|
|
1329
|
+
"""
|
|
1330
|
+
conn.execute(
|
|
1331
|
+
"""
|
|
1332
|
+
CREATE TABLE IF NOT EXISTS lifecycle_events (
|
|
1333
|
+
event_id TEXT PRIMARY KEY,
|
|
1334
|
+
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
1335
|
+
source TEXT NOT NULL DEFAULT 'desktop',
|
|
1336
|
+
action TEXT NOT NULL,
|
|
1337
|
+
conversation_id TEXT NOT NULL,
|
|
1338
|
+
session_id TEXT DEFAULT NULL,
|
|
1339
|
+
reason TEXT DEFAULT 'user_action',
|
|
1340
|
+
payload_snapshot TEXT DEFAULT '{}',
|
|
1341
|
+
delivery_status TEXT NOT NULL DEFAULT 'accepted',
|
|
1342
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
1343
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
1344
|
+
processed_at TEXT DEFAULT NULL,
|
|
1345
|
+
last_error TEXT DEFAULT NULL
|
|
1346
|
+
)
|
|
1347
|
+
"""
|
|
1348
|
+
)
|
|
1349
|
+
_migrate_add_index(conn, "idx_lifecycle_events_status", "lifecycle_events", "delivery_status")
|
|
1350
|
+
_migrate_add_index(conn, "idx_lifecycle_events_conv", "lifecycle_events", "conversation_id")
|
|
1351
|
+
_migrate_add_index(conn, "idx_lifecycle_events_action", "lifecycle_events", "action")
|
|
1352
|
+
|
|
1353
|
+
|
|
1314
1354
|
MIGRATIONS = [
|
|
1315
1355
|
(1, "learnings_columns", _m1_learnings_columns),
|
|
1316
1356
|
(2, "followups_reasoning", _m2_followups_reasoning),
|
|
@@ -1362,6 +1402,7 @@ MIGRATIONS = [
|
|
|
1362
1402
|
(48, "email_agent_contract_backfill", _m48_email_agent_contract_backfill),
|
|
1363
1403
|
(49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
|
|
1364
1404
|
(50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
|
|
1405
|
+
(51, "lifecycle_events", _m51_lifecycle_events),
|
|
1365
1406
|
]
|
|
1366
1407
|
|
|
1367
1408
|
|
package/src/hook_guardrails.py
CHANGED
|
@@ -1000,6 +1000,27 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1000
1000
|
_shell_cmd = _extract_bash_command(tool_input)
|
|
1001
1001
|
if _classify_destructive_intent(_shell_cmd):
|
|
1002
1002
|
op = "delete" # force the main gate to keep evaluating
|
|
1003
|
+
# Block K G3 SSH prescreen: SSH remote-write patterns never map to
|
|
1004
|
+
# ``write``/``delete`` via ``_classify_bash_operation`` (they look like
|
|
1005
|
+
# ``other`` at shell level because the mutation happens on the remote
|
|
1006
|
+
# end). Without this prescreen the ``if op not in {'write', 'delete'}``
|
|
1007
|
+
# early-return below lets them sail past the main G3-SSH gate — the
|
|
1008
|
+
# exact failure mode uncovered on v7.2.0: ``ssh host "cat > file"``
|
|
1009
|
+
# in hard mode never emitted the deny response.
|
|
1010
|
+
if tool_name == "Bash" and op not in {"write", "delete"}:
|
|
1011
|
+
try:
|
|
1012
|
+
from guardian_runtime_config import resolve_guardian_flag as _resolve_ssh
|
|
1013
|
+
_g3_ssh_mode_prescreen = _resolve_ssh(
|
|
1014
|
+
"G3_SSH_ENFORCE_REMOTE_WRITE", default="shadow"
|
|
1015
|
+
)
|
|
1016
|
+
except Exception:
|
|
1017
|
+
_g3_ssh_mode_prescreen = os.environ.get(
|
|
1018
|
+
"NEXO_G3_SSH_ENFORCE_REMOTE_WRITE", "shadow"
|
|
1019
|
+
).strip().lower()
|
|
1020
|
+
if _g3_ssh_mode_prescreen in {"shadow", "hard"}:
|
|
1021
|
+
_shell_cmd_ssh = _extract_bash_command(tool_input)
|
|
1022
|
+
if _classify_ssh_remote_write(_shell_cmd_ssh):
|
|
1023
|
+
op = "write" # force the main gate to keep evaluating
|
|
1003
1024
|
if op not in {"write", "delete"}:
|
|
1004
1025
|
return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
|
|
1005
1026
|
|
package/src/hooks/manifest.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"hooks": [
|
|
4
4
|
{ "event": "SessionStart", "handler": "src/hooks/session_start.py", "critical": true },
|
|
5
5
|
{ "event": "UserPromptSubmit", "handler": "src/hooks/auto_capture.py", "critical": false },
|
|
6
|
+
{ "event": "PreToolUse", "handler": "src/hooks/pre_tool_use.py", "critical": true },
|
|
6
7
|
{ "event": "PostToolUse", "handler": "src/hooks/post_tool_use.py", "critical": false },
|
|
7
8
|
{ "event": "PreCompact", "handler": "src/hooks/pre_compact.py", "critical": true },
|
|
8
9
|
{ "event": "Stop", "handler": "src/hooks/stop.py", "critical": true },
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse unified handler.
|
|
3
|
+
|
|
4
|
+
v7.3.0 wires the Block K Guardian gates (G3 destructive + G3 SSH + G4
|
|
5
|
+
guard_check + conditioned-file blocks + automation live-repo guard)
|
|
6
|
+
into Claude Code's PreToolUse event. Without this handler, the
|
|
7
|
+
``process_pre_tool_event`` logic in ``hook_guardrails.py`` was code
|
|
8
|
+
that never ran in production — the post-v7.2.0 bug Francisco found
|
|
9
|
+
on 2026-04-22.
|
|
10
|
+
|
|
11
|
+
Responsibility:
|
|
12
|
+
- Read the hook payload from stdin.
|
|
13
|
+
- Resolve the NEXO sid from the payload / env.
|
|
14
|
+
- Delegate to ``hook_guardrails.process_pre_tool_event``.
|
|
15
|
+
- If the result carries ``status == "blocked"`` AND at least one
|
|
16
|
+
block has severity error (hard mode), emit a PreToolUse denial
|
|
17
|
+
response so Claude Code refuses to execute the tool.
|
|
18
|
+
- Otherwise exit cleanly.
|
|
19
|
+
|
|
20
|
+
The hook NEVER crashes the tool pipeline: any exception drops to a
|
|
21
|
+
safe no-op return and the tool is allowed (fail-open for robustness;
|
|
22
|
+
a hard denial requires a successful evaluation that saw a hard block).
|
|
23
|
+
Observability hooks still record the run via ``hook_observability``.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_DIR = Path(__file__).resolve().parent
|
|
35
|
+
if str(_DIR.parent) not in sys.path:
|
|
36
|
+
sys.path.insert(0, str(_DIR.parent))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_stdin_json() -> dict:
|
|
40
|
+
if sys.stdin.isatty():
|
|
41
|
+
return {}
|
|
42
|
+
try:
|
|
43
|
+
raw = sys.stdin.read()
|
|
44
|
+
if not raw.strip():
|
|
45
|
+
return {}
|
|
46
|
+
return json.loads(raw)
|
|
47
|
+
except Exception:
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _record(duration_ms: int, exit_code: int, summary: str) -> None:
|
|
52
|
+
try:
|
|
53
|
+
sys.path.insert(0, str(_DIR.parent))
|
|
54
|
+
import hook_observability # type: ignore
|
|
55
|
+
hook_observability.record_hook_run(
|
|
56
|
+
"pre_tool_use",
|
|
57
|
+
duration_ms=duration_ms,
|
|
58
|
+
exit_code=exit_code,
|
|
59
|
+
summary=summary,
|
|
60
|
+
session_id=os.environ.get("CLAUDE_SESSION_ID", ""),
|
|
61
|
+
)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_block_reason(result: dict) -> str:
|
|
67
|
+
"""Build a human-readable reason for the deny response."""
|
|
68
|
+
blocks = result.get("blocks") or []
|
|
69
|
+
if not isinstance(blocks, list) or not blocks:
|
|
70
|
+
return "Guardian: tool execution blocked by a hard-mode gate."
|
|
71
|
+
first = blocks[0] if isinstance(blocks[0], dict) else {}
|
|
72
|
+
reason_code = str(first.get("reason_code") or first.get("debt_type") or "")
|
|
73
|
+
pattern = str(first.get("pattern") or "")
|
|
74
|
+
file_token = str(first.get("file") or "")
|
|
75
|
+
severity = str(first.get("severity") or "")
|
|
76
|
+
parts = ["Guardian gate blocked this tool call"]
|
|
77
|
+
if reason_code:
|
|
78
|
+
parts.append(f"reason={reason_code}")
|
|
79
|
+
if pattern:
|
|
80
|
+
parts.append(f"pattern={pattern}")
|
|
81
|
+
if file_token:
|
|
82
|
+
parts.append(f"file={file_token}")
|
|
83
|
+
if severity:
|
|
84
|
+
parts.append(f"severity={severity}")
|
|
85
|
+
tail = " | ".join(parts)
|
|
86
|
+
tail += (
|
|
87
|
+
". Run nexo_guard_check and nexo_task_open + nexo_cortex_decide "
|
|
88
|
+
"with explicit evidence before retrying. "
|
|
89
|
+
"Override per-gate at operator's risk: export NEXO_<GATE>=shadow."
|
|
90
|
+
)
|
|
91
|
+
return tail
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _has_hard_block(result: dict) -> bool:
|
|
95
|
+
if not isinstance(result, dict):
|
|
96
|
+
return False
|
|
97
|
+
if str(result.get("status") or "") != "blocked":
|
|
98
|
+
return False
|
|
99
|
+
blocks = result.get("blocks") or []
|
|
100
|
+
if not isinstance(blocks, list):
|
|
101
|
+
return False
|
|
102
|
+
for block in blocks:
|
|
103
|
+
if not isinstance(block, dict):
|
|
104
|
+
continue
|
|
105
|
+
severity = str(block.get("severity") or "").lower()
|
|
106
|
+
if severity == "error":
|
|
107
|
+
return True
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> int:
|
|
112
|
+
started = time.time()
|
|
113
|
+
payload = _read_stdin_json()
|
|
114
|
+
exit_code = 0
|
|
115
|
+
summary = "skipped"
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
sys.path.insert(0, str(_DIR.parent))
|
|
119
|
+
from hook_guardrails import process_pre_tool_event # type: ignore
|
|
120
|
+
result = process_pre_tool_event(payload)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
# Fail-open: never block the tool pipeline on an internal hook
|
|
123
|
+
# crash. Observability still records the error.
|
|
124
|
+
summary = f"error:{exc.__class__.__name__}"
|
|
125
|
+
result = {}
|
|
126
|
+
|
|
127
|
+
if _has_hard_block(result):
|
|
128
|
+
reason = _format_block_reason(result)
|
|
129
|
+
response = {
|
|
130
|
+
"hookSpecificOutput": {
|
|
131
|
+
"hookEventName": "PreToolUse",
|
|
132
|
+
"permissionDecision": "deny",
|
|
133
|
+
"permissionDecisionReason": reason,
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
try:
|
|
137
|
+
print(json.dumps(response))
|
|
138
|
+
except Exception:
|
|
139
|
+
print(json.dumps({
|
|
140
|
+
"hookSpecificOutput": {
|
|
141
|
+
"hookEventName": "PreToolUse",
|
|
142
|
+
"permissionDecision": "deny",
|
|
143
|
+
"permissionDecisionReason": "Guardian gate blocked this tool call.",
|
|
144
|
+
},
|
|
145
|
+
}))
|
|
146
|
+
summary = "blocked"
|
|
147
|
+
exit_code = 0 # JSON response is the canonical path; non-zero is redundant
|
|
148
|
+
|
|
149
|
+
elif isinstance(result, dict) and result.get("skipped"):
|
|
150
|
+
summary = f"skipped:{result.get('reason', '')[:40]}"
|
|
151
|
+
elif isinstance(result, dict) and str(result.get("status") or "") == "blocked":
|
|
152
|
+
# Shadow-mode block: debt recorded but tool allowed.
|
|
153
|
+
summary = "shadow_debt"
|
|
154
|
+
|
|
155
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
156
|
+
_record(duration_ms, exit_code, summary)
|
|
157
|
+
return exit_code
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|