From 5fea5bbf449551fab3f441636a0f567d799b6902 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 9 Jul 2026 16:12:44 -0700 Subject: [PATCH] =?UTF-8?q?seedgo+hooks:=20json=5Fhandler=20empty-guard=20?= =?UTF-8?q?(#667)=20+=20fix=20silent=20hook-wiring=20break=20=E2=80=94=20n?= =?UTF-8?q?ew=20wire=5Fverify=20guard=20fails=20loud=20on=20empty/orphaned?= =?UTF-8?q?/dup=20provider=20hook=20events=20(the=20real=20bug:=20half-wir?= =?UTF-8?q?ed=20hooks=20written=20silently);=20presence=5Fgate=20marked=20?= =?UTF-8?q?provider=5Fwired:false=20(dormant=20by=20design,=20not=20a=20br?= =?UTF-8?q?eak);=20snapshot=20fixture=20corrected=20(drop=20presence=5Fgat?= =?UTF-8?q?e,=20add=20SessionStart:cadence=5Freset);=20load=5Fjson=20empty?= =?UTF-8?q?-guard.=20Live=20SessionStart=20orphan=20re-wired=20separately.?= =?UTF-8?q?=201138=20seedgo=20+=20831=20hooks=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .aipass/hooks.json | 3 +- src/aipass/hooks/.seedgo/bypass.json | 30 ++ src/aipass/hooks/README.md | 6 +- src/aipass/hooks/apps/modules/hookstatus.py | 1 + src/aipass/hooks/apps/modules/wire_verify.py | 233 +++++++++++ src/aipass/hooks/tests/test_wire_verify.py | 380 ++++++++++++++++++ .../seedgo/apps/handlers/json/json_handler.py | 17 +- .../fixtures/provider_hooks_snapshot.json | 19 +- src/aipass/seedgo/tests/test_json_handler.py | 41 ++ 9 files changed, 717 insertions(+), 13 deletions(-) create mode 100644 src/aipass/hooks/apps/modules/wire_verify.py create mode 100644 src/aipass/hooks/tests/test_wire_verify.py diff --git a/.aipass/hooks.json b/.aipass/hooks.json index f6f3fe27..a35c4000 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -6,7 +6,8 @@ "presence_gate": { "enabled": true, "handler": "aipass.hooks.apps.handlers.security.presence_gate.handle", - "matcher": "" + "matcher": "", + "provider_wired": false }, "identity_injector": { "enabled": true, diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 4374e276..e246f446 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -366,6 +366,11 @@ "standard": "json_structure", "reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops." }, + { + "file": "apps/modules/wire_verify.py", + "standard": "json_structure", + "reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json \u2014 not branch data storage needing json_handler." + }, { "file": "apps/modules/cadence.py", "standard": "modules", @@ -1103,6 +1108,31 @@ "file": "tests/test_session_start.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_wire_verify.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_wire_verify.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_wire_verify.py", + "standard": "encapsulation", + "reason": "Tests import modules directly to test implementation details." + }, + { + "file": "tests/test_wire_verify.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_wire_verify.py", + "standard": "help_text", + "reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text." } ], "notes": { diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index dc153e09..6a69124a 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -26,6 +26,7 @@ Every hook event flows through one engine. Platform bridges normalize the event | `drone @hooks hooksound off` | Mute all hook sounds | | `drone @hooks hooksound on` | Unmute all hook sounds | | `drone @hooks cadence` | Show prompt injection cadence config and state | +| `drone @hooks verify` | Cross-check provider settings vs project hook config | | `drone @hooks --help` | Full help reference | | `drone @hooks --version` | Version info | @@ -54,7 +55,8 @@ src/aipass/hooks/ │ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off) │ │ ├── hookstatus.py # Config viewer (drone @hooks status) │ │ ├── presence.py # Branch presence — claim/release/refresh for .ai_central/PRESENCE.central.json -│ │ └── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator +│ │ ├── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator +│ │ └── wire_verify.py # Wire verification — provider ↔ project hook wiring checker │ ├── handlers/ │ │ ├── bridges/ # One per provider (thin normalization) │ │ │ └── claude.py # Claude Code bridge @@ -86,7 +88,7 @@ src/aipass/hooks/ │ └── diagnostics.py # JSONL logging for hook execution ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -└── tests/ # 705 tests across 25 test files +└── tests/ # 825 tests across 27 test files ``` ## How It Works diff --git a/src/aipass/hooks/apps/modules/hookstatus.py b/src/aipass/hooks/apps/modules/hookstatus.py index 9b084ab9..179770f3 100644 --- a/src/aipass/hooks/apps/modules/hookstatus.py +++ b/src/aipass/hooks/apps/modules/hookstatus.py @@ -27,6 +27,7 @@ EVENT_TYPES = [ "SubagentStop", "Stop", "Notification", + "SessionStart", "PreCompact", ] diff --git a/src/aipass/hooks/apps/modules/wire_verify.py b/src/aipass/hooks/apps/modules/wire_verify.py new file mode 100644 index 00000000..365be802 --- /dev/null +++ b/src/aipass/hooks/apps/modules/wire_verify.py @@ -0,0 +1,233 @@ +# =================== AIPass ==================== +# Name: wire_verify.py +# Version: 1.0.0 +# Description: Wire verification — cross-checks provider settings vs project hook config +# Branch: hooks +# Layer: apps/modules +# Created: 2026-07-09 +# Modified: 2026-07-09 +# ============================================= + +"""Wire verification — catches silent hook-wiring breaks. + +Cross-checks ~/.claude/settings.json (provider hooks) against +.aipass/hooks.json (project hook config). Detects: + - Empty provider hook arrays (event key exists but nothing fires) + - Enabled handlers with no provider bridge entry (handler never dispatched) + - Duplicate provider entries (handler fires multiple times) + - Orphaned provider entries (bridge entry with no project config handler) + +Invoked via: drone @hooks verify +""" + +import json +from pathlib import Path + +from aipass.cli.apps.modules import err_console +from aipass.hooks.apps.handlers.config.loader import find_project_config +from aipass.prax.apps.modules.logger import system_logger as logger + +CONSOLE = err_console + +HELP_COMMANDS = [ + ("verify", "Cross-check provider settings vs project hook config"), +] + +_BRIDGE_MARKER = "bridges/claude.py" +_META_KEYS = frozenset({"_comment", "hooks_enabled"}) + + +def _read_provider_hooks(path=None): + """Read hook events from provider settings. Returns {event: [entries]}.""" + settings_path = Path(path) if path else Path.home() / ".claude" / "settings.json" + try: + raw = settings_path.read_text(encoding="utf-8") + data = json.loads(raw) + return data.get("hooks", {}) + except (OSError, json.JSONDecodeError) as exc: + logger.info("[WIRE_VERIFY] cannot read provider settings: %s", exc) + return {} + + +def _extract_bridge_arg(entry): + """Extract the bridge event arg from a provider entry's command string. + + Returns e.g. 'UserPromptSubmit:tier0_kernel' or 'Stop', or None if not a bridge entry. + """ + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + if _BRIDGE_MARKER not in cmd: + continue + parts = cmd.split() + for i, part in enumerate(parts): + if part.endswith("claude.py") or _BRIDGE_MARKER in part: + if i + 1 < len(parts): + return parts[i + 1] + return None + + +def _build_provider_index(provider_hooks, errors): + """Parse provider hook entries into a lookup index. + + Returns {event: {"filtered": {hook_name: {matcher: count}}, "unfiltered": int, "empty": bool}}. + Appends to *errors* for empty arrays. + """ + index = {} + for event, entries in provider_hooks.items(): + idx = {"filtered": {}, "unfiltered": 0, "empty": False} + if not entries: + errors.append(f"{event}: provider entry exists but hooks array is EMPTY — nothing fires") + idx["empty"] = True + for entry in entries: + arg = _extract_bridge_arg(entry) + if arg is None: + continue + if ":" in arg: + hook_name = arg.split(":", 1)[1] + matcher = entry.get("matcher", "") + if hook_name not in idx["filtered"]: + idx["filtered"][hook_name] = {} + idx["filtered"][hook_name][matcher] = idx["filtered"][hook_name].get(matcher, 0) + 1 + else: + idx["unfiltered"] += 1 + index[event] = idx + return index + + +def _check_event_wiring(event_type, hooks_group, pidx, errors, warnings, info): + """Check one project config event against its provider index entry.""" + enabled_hooks = { + name: defn for name, defn in hooks_group.items() if isinstance(defn, dict) and defn.get("enabled", False) + } + if not enabled_hooks: + return + + provider_wired_hooks = { + name: defn for name, defn in enabled_hooks.items() if defn.get("provider_wired", True) is not False + } + + if pidx is None: + if provider_wired_hooks: + errors.append( + f"{event_type}: {len(provider_wired_hooks)} enabled handler(s) in project config" + f" but NO provider event entry — handlers never fire" + ) + return + + if pidx["empty"]: + return + + filtered = pidx["filtered"] + if pidx["unfiltered"] > 0: + if pidx["unfiltered"] > 1: + warnings.append(f"{event_type}: {pidx['unfiltered']} duplicate unfiltered provider entries") + info.append(f"{event_type}: unfiltered bridge, {len(enabled_hooks)} enabled hooks OK") + else: + for hook_name, hook_defn in enabled_hooks.items(): + if hook_defn.get("provider_wired", True) is False: + continue + if hook_name not in filtered: + errors.append( + f"{event_type}:{hook_name}: enabled in project config" + " but no provider bridge entry — handler never fires" + ) + continue + for matcher, count in filtered[hook_name].items(): + if count > 1: + warnings.append( + f"{event_type}:{hook_name}: {count} duplicate provider entries (matcher={matcher or 'none'})" + ) + + for hook_name in filtered: + if hook_name not in hooks_group: + warnings.append( + f"{event_type}:{hook_name}: provider entry exists but no handler in project config (orphaned)" + ) + + +def verify_wiring(provider_path=None, project_config=None): + """Cross-check provider settings against project hook config. + + Returns dict with keys: errors (list), warnings (list), info (list), ok (bool). + """ + errors = [] + warnings = [] + info = [] + + provider_hooks = _read_provider_hooks(provider_path) + if not provider_hooks: + errors.append("No provider hooks found in ~/.claude/settings.json") + return {"errors": errors, "warnings": warnings, "info": info, "ok": False} + + config = project_config if project_config is not None else find_project_config() + if config is None: + errors.append("No .aipass/hooks.json found in directory tree") + return {"errors": errors, "warnings": warnings, "info": info, "ok": False} + + provider_index = _build_provider_index(provider_hooks, errors) + + for event_type, hooks_group in config.items(): + if event_type in _META_KEYS or not isinstance(hooks_group, dict): + continue + pidx = provider_index.get(event_type) + _check_event_wiring(event_type, hooks_group, pidx, errors, warnings, info) + + for event in provider_hooks: + if event not in config and not provider_index.get(event, {}).get("empty"): + info.append(f"{event}: provider-only event (no project config section)") + + return { + "errors": errors, + "warnings": warnings, + "info": info, + "ok": len(errors) == 0, + } + + +def _render_results(results): + """Render verification results to console.""" + CONSOLE.print() + + if results["ok"]: + CONSOLE.print("[bold green]✓ Wire check passed[/bold green]") + else: + CONSOLE.print("[bold red]✗ Wire check FAILED[/bold red]") + + CONSOLE.print() + + for error in results["errors"]: + CONSOLE.print(f" [red]ERROR[/red] {error}") + + for warning in results["warnings"]: + CONSOLE.print(f" [yellow]WARN[/yellow] {warning}") + + for item in results["info"]: + CONSOLE.print(f" [dim]OK[/dim] {item}") + + CONSOLE.print() + CONSOLE.print(f"[bold]{len(results['errors'])} errors, {len(results['warnings'])} warnings[/bold]") + + +def print_introspection(): + """Print module structure for drone routing.""" + CONSOLE.print("[bold cyan]wire_verify[/bold cyan] — Provider ↔ project hook wiring checker") + + +def handle_command(command, args) -> bool: + """Route verify commands from drone @hooks.""" + if command != "verify": + return False + + if args and args[0] in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]wire_verify[/bold cyan] — Provider ↔ project hook wiring checker") + CONSOLE.print() + CONSOLE.print(" drone @hooks verify Cross-check provider settings vs project config") + CONSOLE.print() + CONSOLE.print("Reads ~/.claude/settings.json and .aipass/hooks.json,") + CONSOLE.print("verifies every enabled handler has a working provider bridge entry.") + CONSOLE.print("Exits non-zero on any ERROR finding.") + return True + + results = verify_wiring() + _render_results(results) + return True diff --git a/src/aipass/hooks/tests/test_wire_verify.py b/src/aipass/hooks/tests/test_wire_verify.py new file mode 100644 index 00000000..9d8f8264 --- /dev/null +++ b/src/aipass/hooks/tests/test_wire_verify.py @@ -0,0 +1,380 @@ +"""Tests for wire_verify module — provider ↔ project hook wiring checker.""" + +import json +from unittest.mock import patch + +from aipass.hooks.apps.modules import wire_verify + +_BRIDGE_CMD = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py" + + +def _provider_entry(event_arg, timeout=None, matcher=None): + hook = {"type": "command", "command": f"{_BRIDGE_CMD} {event_arg}"} + if timeout: + hook["timeout"] = timeout + entry: dict = {"hooks": [hook]} + if matcher is not None: + entry["matcher"] = matcher + return entry + + +GOOD_PROVIDER = { + "UserPromptSubmit": [ + _provider_entry("UserPromptSubmit:identity_injector"), + _provider_entry("UserPromptSubmit:branch_prompt"), + ], + "Stop": [_provider_entry("Stop")], + "PreToolUse": [_provider_entry("PreToolUse")], +} + +GOOD_PROJECT = { + "hooks_enabled": True, + "UserPromptSubmit": { + "identity_injector": {"enabled": True, "handler": "x.handle", "matcher": ""}, + "branch_prompt": {"enabled": True, "handler": "y.handle", "matcher": ""}, + }, + "Stop": { + "stop_sound": {"enabled": True, "handler": "s.handle", "matcher": ""}, + }, + "PreToolUse": { + "tool_sound": {"enabled": True, "handler": "t.handle", "matcher": "Bash|Edit"}, + }, +} + + +class TestExtractBridgeArg: + def test_filtered_arg(self): + entry = _provider_entry("UserPromptSubmit:tier0_kernel") + assert wire_verify._extract_bridge_arg(entry) == "UserPromptSubmit:tier0_kernel" + + def test_unfiltered_arg(self): + entry = _provider_entry("Stop") + assert wire_verify._extract_bridge_arg(entry) == "Stop" + + def test_no_bridge_marker(self): + entry = {"hooks": [{"command": "echo hello"}]} + assert wire_verify._extract_bridge_arg(entry) is None + + def test_empty_hooks(self): + assert wire_verify._extract_bridge_arg({"hooks": []}) is None + + def test_no_hooks_key(self): + assert wire_verify._extract_bridge_arg({}) is None + + +class TestBuildProviderIndex: + def test_builds_filtered_index(self): + provider = { + "UserPromptSubmit": [ + _provider_entry("UserPromptSubmit:identity_injector"), + _provider_entry("UserPromptSubmit:branch_prompt"), + ], + } + errors = [] + idx = wire_verify._build_provider_index(provider, errors) + assert errors == [] + assert idx["UserPromptSubmit"]["filtered"]["identity_injector"] == {"": 1} + assert idx["UserPromptSubmit"]["filtered"]["branch_prompt"] == {"": 1} + assert idx["UserPromptSubmit"]["unfiltered"] == 0 + + def test_builds_unfiltered_index(self): + provider = {"Stop": [_provider_entry("Stop")]} + errors = [] + idx = wire_verify._build_provider_index(provider, errors) + assert idx["Stop"]["unfiltered"] == 1 + assert idx["Stop"]["filtered"] == {} + + def test_empty_array_errors(self): + provider = {"SessionStart": []} + errors = [] + idx = wire_verify._build_provider_index(provider, errors) + assert len(errors) == 1 + assert "EMPTY" in errors[0] + assert idx["SessionStart"]["empty"] is True + + def test_duplicate_filtered_counted(self): + provider = { + "PreCompact": [ + _provider_entry("PreCompact:pre_compact"), + _provider_entry("PreCompact:pre_compact"), + ], + } + errors = [] + idx = wire_verify._build_provider_index(provider, errors) + assert idx["PreCompact"]["filtered"]["pre_compact"] == {"": 2} + + def test_distinct_matchers_not_duplicate(self): + provider = { + "PreCompact": [ + _provider_entry("PreCompact:pre_compact", matcher="manual"), + _provider_entry("PreCompact:pre_compact", matcher="auto"), + ], + } + errors = [] + idx = wire_verify._build_provider_index(provider, errors) + assert idx["PreCompact"]["filtered"]["pre_compact"] == {"manual": 1, "auto": 1} + + +class TestCheckEventWiring: + def test_unfiltered_ok(self): + pidx = {"filtered": {}, "unfiltered": 1, "empty": False} + hooks_group = {"stop_sound": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("Stop", hooks_group, pidx, errors, warnings, info) + assert errors == [] + assert any("unfiltered" in i for i in info) + + def test_missing_provider_event(self): + hooks_group = {"cadence_reset": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("SessionStart", hooks_group, None, errors, warnings, info) + assert len(errors) == 1 + assert "NO provider event entry" in errors[0] + + def test_missing_per_hook_entry(self): + pidx = {"filtered": {"identity_injector": {"": 1}}, "unfiltered": 0, "empty": False} + hooks_group = { + "identity_injector": {"enabled": True, "handler": "x"}, + "presence_gate": {"enabled": True, "handler": "y"}, + } + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("UserPromptSubmit", hooks_group, pidx, errors, warnings, info) + assert len(errors) == 1 + assert "presence_gate" in errors[0] + assert "never fires" in errors[0] + + def test_duplicate_per_hook_warns(self): + pidx = {"filtered": {"pre_compact": {"": 2}}, "unfiltered": 0, "empty": False} + hooks_group = {"pre_compact": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("PreCompact", hooks_group, pidx, errors, warnings, info) + assert errors == [] + assert len(warnings) == 1 + assert "duplicate" in warnings[0] + + def test_distinct_matchers_no_warning(self): + pidx = {"filtered": {"pre_compact": {"manual": 1, "auto": 1}}, "unfiltered": 0, "empty": False} + hooks_group = {"pre_compact": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("PreCompact", hooks_group, pidx, errors, warnings, info) + assert errors == [] + assert warnings == [] + + def test_orphaned_provider_entry(self): + pidx = {"filtered": {"ghost_hook": {"": 1}}, "unfiltered": 0, "empty": False} + hooks_group = {"real_hook": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("UserPromptSubmit", hooks_group, pidx, errors, warnings, info) + assert any("orphaned" in w for w in warnings) + + def test_disabled_hooks_skipped(self): + pidx = {"filtered": {}, "unfiltered": 0, "empty": False} + hooks_group = {"disabled_hook": {"enabled": False, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("UserPromptSubmit", hooks_group, pidx, errors, warnings, info) + assert errors == [] + + def test_empty_provider_skipped(self): + pidx = {"filtered": {}, "unfiltered": 0, "empty": True} + hooks_group = {"cadence_reset": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("SessionStart", hooks_group, pidx, errors, warnings, info) + assert errors == [] + + def test_duplicate_unfiltered_warns(self): + pidx = {"filtered": {}, "unfiltered": 3, "empty": False} + hooks_group = {"stop_sound": {"enabled": True, "handler": "x"}} + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("Stop", hooks_group, pidx, errors, warnings, info) + assert len(warnings) == 1 + assert "duplicate unfiltered" in warnings[0] + + def test_provider_wired_false_skips_error(self): + pidx = {"filtered": {"identity_injector": {"": 1}}, "unfiltered": 0, "empty": False} + hooks_group = { + "identity_injector": {"enabled": True, "handler": "x"}, + "presence_gate": {"enabled": True, "handler": "y", "provider_wired": False}, + } + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("UserPromptSubmit", hooks_group, pidx, errors, warnings, info) + assert errors == [] + + def test_provider_wired_false_no_event_no_error(self): + hooks_group = { + "presence_gate": {"enabled": True, "handler": "y", "provider_wired": False}, + } + errors, warnings, info = [], [], [] + wire_verify._check_event_wiring("UserPromptSubmit", hooks_group, None, errors, warnings, info) + assert errors == [] + + +class TestVerifyWiring: + def test_all_good(self, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": GOOD_PROVIDER})) + result = wire_verify.verify_wiring(provider_path=settings, project_config=GOOD_PROJECT) + assert result["ok"] is True + assert result["errors"] == [] + + def test_empty_provider_array(self, tmp_path): + provider = {**GOOD_PROVIDER, "SessionStart": []} + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": provider})) + project = { + **GOOD_PROJECT, + "SessionStart": { + "cadence_reset": {"enabled": True, "handler": "x"}, + }, + } + result = wire_verify.verify_wiring(provider_path=settings, project_config=project) + assert result["ok"] is False + assert any("EMPTY" in e for e in result["errors"]) + + def test_missing_provider_file(self, tmp_path): + result = wire_verify.verify_wiring( + provider_path=tmp_path / "nonexistent.json", + project_config=GOOD_PROJECT, + ) + assert result["ok"] is False + assert any("No provider" in e for e in result["errors"]) + + def test_no_project_config(self, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": GOOD_PROVIDER})) + with patch.object(wire_verify, "find_project_config", return_value=None): + result = wire_verify.verify_wiring(provider_path=settings) + assert result["ok"] is False + assert any("hooks.json" in e for e in result["errors"]) + + def test_missing_per_hook_entry_is_error(self, tmp_path): + provider = { + "UserPromptSubmit": [ + _provider_entry("UserPromptSubmit:identity_injector"), + ], + } + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": provider})) + project = { + "hooks_enabled": True, + "UserPromptSubmit": { + "identity_injector": {"enabled": True, "handler": "x"}, + "presence_gate": {"enabled": True, "handler": "y"}, + }, + } + result = wire_verify.verify_wiring(provider_path=settings, project_config=project) + assert result["ok"] is False + assert any("presence_gate" in e for e in result["errors"]) + + def test_provider_wired_false_passes(self, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": GOOD_PROVIDER})) + project = { + **GOOD_PROJECT, + "UserPromptSubmit": { + **GOOD_PROJECT["UserPromptSubmit"], + "presence_gate": {"enabled": True, "handler": "z", "provider_wired": False}, + }, + } + result = wire_verify.verify_wiring(provider_path=settings, project_config=project) + assert result["ok"] is True + assert not any("presence_gate" in e for e in result["errors"]) + + def test_distinct_matchers_no_dupe_warning(self, tmp_path): + provider = { + **GOOD_PROVIDER, + "PreCompact": [ + _provider_entry("PreCompact:pre_compact", matcher="manual"), + _provider_entry("PreCompact:pre_compact", matcher="auto"), + ], + } + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": provider})) + project = { + **GOOD_PROJECT, + "PreCompact": { + "pre_compact": {"enabled": True, "handler": "x"}, + }, + } + result = wire_verify.verify_wiring(provider_path=settings, project_config=project) + assert result["ok"] is True + assert not any("duplicate" in w for w in result["warnings"]) + + def test_provider_only_event_info(self, tmp_path): + provider = {**GOOD_PROVIDER, "CustomEvent": [_provider_entry("CustomEvent")]} + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": provider})) + result = wire_verify.verify_wiring(provider_path=settings, project_config=GOOD_PROJECT) + assert any("provider-only" in i for i in result["info"]) + + def test_meta_keys_ignored(self, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": GOOD_PROVIDER})) + project = {**GOOD_PROJECT, "_comment": "test", "hooks_enabled": True} + result = wire_verify.verify_wiring(provider_path=settings, project_config=project) + assert result["ok"] is True + + +class TestReadProviderHooks: + def test_reads_file(self, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": {"Stop": []}})) + result = wire_verify._read_provider_hooks(settings) + assert "Stop" in result + + def test_missing_file_returns_empty(self, tmp_path): + result = wire_verify._read_provider_hooks(tmp_path / "missing.json") + assert result == {} + + def test_malformed_json_returns_empty(self, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text("not json{{{") + result = wire_verify._read_provider_hooks(settings) + assert result == {} + + +class TestHandleCommand: + def test_returns_false_for_non_verify(self): + assert wire_verify.handle_command("status", []) is False + + def test_routes_verify(self): + mock_result = {"ok": True, "errors": [], "warnings": [], "info": []} + with patch.object(wire_verify, "verify_wiring", return_value=mock_result): + assert wire_verify.handle_command("verify", []) is True + + def test_help_flag(self): + assert wire_verify.handle_command("verify", ["--help"]) is True + + def test_help_word(self): + assert wire_verify.handle_command("verify", ["help"]) is True + + +class TestRenderResults: + def test_renders_pass(self): + from io import StringIO + + from rich.console import Console + + buf = StringIO() + test_console = Console(file=buf, force_terminal=False) + with patch.object(wire_verify, "CONSOLE", test_console): + wire_verify._render_results({"ok": True, "errors": [], "warnings": [], "info": ["x"]}) + output = buf.getvalue() + assert "passed" in output + + def test_renders_fail(self): + from io import StringIO + + from rich.console import Console + + buf = StringIO() + test_console = Console(file=buf, force_terminal=False) + with patch.object(wire_verify, "CONSOLE", test_console): + wire_verify._render_results({"ok": False, "errors": ["bad"], "warnings": [], "info": []}) + output = buf.getvalue() + assert "FAILED" in output + assert "bad" in output + + +class TestPrintIntrospection: + def test_runs_without_error(self): + wire_verify.print_introspection() diff --git a/src/aipass/seedgo/apps/handlers/json/json_handler.py b/src/aipass/seedgo/apps/handlers/json/json_handler.py index 08646a59..c5ddc7a6 100755 --- a/src/aipass/seedgo/apps/handlers/json/json_handler.py +++ b/src/aipass/seedgo/apps/handlers/json/json_handler.py @@ -125,14 +125,27 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: def load_json(module_name: str, json_type: str) -> Optional[Any]: - """Load JSON file, auto-create if missing""" + """Load JSON file, auto-create if missing. + + Guards against an empty/whitespace file — e.g. a concurrent writer caught + mid-truncate in the TOCTOU window between ensure_json_exists() and this + read. Rather than raising JSONDecodeError, fall back to the type's default + template so callers always get a valid structure. A non-empty but malformed + file still raises (fail honestly — that is real corruption, not a race). + """ if not ensure_json_exists(module_name, json_type): return None json_path = get_json_path(module_name, json_type) with open(json_path, "r", encoding="utf-8") as f: - return json.load(f) + content = f.read() + + if not content.strip(): + logger.warning("JSON file empty, using default template: %s", json_path) + return _create_default(json_type, module_name) + + return json.loads(content) def save_json(module_name: str, json_type: str, data: Any) -> bool: diff --git a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json index f46db711..8bdc07e3 100644 --- a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json +++ b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json @@ -1,13 +1,5 @@ { "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate" - } - ] - }, { "hooks": [ { @@ -171,5 +163,16 @@ } ] } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SessionStart:cadence_reset", + "timeout": 30 + } + ] + } ] } diff --git a/src/aipass/seedgo/tests/test_json_handler.py b/src/aipass/seedgo/tests/test_json_handler.py index e82a7bd0..43b20558 100644 --- a/src/aipass/seedgo/tests/test_json_handler.py +++ b/src/aipass/seedgo/tests/test_json_handler.py @@ -620,6 +620,47 @@ def test_load_json_empty_file(tmp_path: Path) -> None: assert isinstance(result, dict), "load_json must return dict even for empty file" +def test_load_json_empty_at_read_survives_race(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """#667: empty file at load_json's OWN read. + + The single-threaded case above passes because ensure_json_exists repairs the + empty file first. The real bug is a TOCTOU race: ensure_json_exists reports + OK, then a concurrent writer truncates the file before load_json re-reads it. + Simulate by stubbing ensure_json_exists to pass without repairing. + """ + json_dir = _json_dir_as_path(tmp_path) + json_dir.mkdir(parents=True, exist_ok=True) + # whitespace-only — what a writer caught mid-truncate can leave behind + (json_dir / "raced_config.json").write_text(" \n", encoding="utf-8") + monkeypatch.setattr(json_handler, "ensure_json_exists", lambda *a, **k: True) + result = json_handler.load_json("raced", "config") + assert isinstance(result, dict), "empty-at-read must fall back to default, not crash" + + +def test_load_json_empty_at_read_log_returns_list(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """#667: empty-at-read for a log falls back to the [] default, not a crash.""" + json_dir = _json_dir_as_path(tmp_path) + json_dir.mkdir(parents=True, exist_ok=True) + (json_dir / "raced_log.json").write_text("", encoding="utf-8") + monkeypatch.setattr(json_handler, "ensure_json_exists", lambda *a, **k: True) + result = json_handler.load_json("raced", "log") + assert result == [], "empty-at-read log must fall back to the [] default" + + +def test_load_json_malformed_nonempty_still_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """#667: a non-empty but malformed file still raises (fail honestly). + + The guard only swallows empty/whitespace (a race artifact). Real corruption + must surface, not be masked by a silent default. + """ + json_dir = _json_dir_as_path(tmp_path) + json_dir.mkdir(parents=True, exist_ok=True) + (json_dir / "corrupt_config.json").write_text("{bad json", encoding="utf-8") + monkeypatch.setattr(json_handler, "ensure_json_exists", lambda *a, **k: True) + with pytest.raises(json.JSONDecodeError): + json_handler.load_json("corrupt", "config") + + def test_get_json_path_returns_pathlib_path(tmp_path: Path) -> None: """paths_return_path: get_json_path returns a pathlib.Path instance.""" result = json_handler.get_json_path("pathmod", "config")