From b971d2161e801ab28da3bf7ea8ef1e85a31c7b52 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 7 May 2026 12:58:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(system):=20feat(hooks):=20DPLAN-0167=20hoo?= =?UTF-8?q?k=20testing=20framework=20+=20bootstrap=20fix=20=E2=80=94=2020-?= =?UTF-8?q?test=20harness=20with=20direct+integration=20layers,=20hook=20e?= =?UTF-8?q?xecution=20logger,=20CWD=20guard=20verification=20across=20proj?= =?UTF-8?q?ects,=20setup.sh=20provider=20wiring=20update=20(global=5Fpromp?= =?UTF-8?q?t=5Floader=20+=20env=20vars=20+=20git=20deny=20rules),=20bootst?= =?UTF-8?q?rap.py=20removes=20dead=20PreToolUse/PostToolUse=20from=20proje?= =?UTF-8?q?ct=20settings,=20hook=20READMEs=20at=20provider+project=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: @devpulse --- .claude/hooks/README.md | 144 ++++ .claude/hooks/auto_fix_diagnostics.py | 81 +- .claude/hooks/branch_prompt_loader.py | 7 +- .claude/hooks/email_notification.py | 7 +- .claude/hooks/git_gate.py | 45 +- .claude/hooks/global_prompt_loader.py | 7 +- .claude/hooks/hook_log.py | 120 +++ .claude/hooks/hook_report.py | 190 +++++ .claude/hooks/hook_test.py | 751 ++++++++++++++++++ .claude/hooks/identity_injector.py | 7 +- .claude/hooks/notification_sound.py | 5 +- .claude/hooks/pre_compact.py | 5 +- .claude/hooks/probes/README.md | 9 +- .claude/hooks/probes/probe_notification.py | 12 +- .claude/hooks/probes/probe_post_tool_use.py | 12 +- .claude/hooks/probes/probe_pre_compact.py | 12 +- .claude/hooks/probes/probe_pre_tool_use.py | 12 +- .claude/hooks/probes/probe_stop.py | 12 +- .claude/hooks/probes/probe_subagent_stop.py | 12 +- .../hooks/probes/probe_user_prompt_submit.py | 12 +- .claude/hooks/stop_sound.py | 5 +- .claude/hooks/subagent_stop_gate.py | 5 +- .claude/hooks/tool_use_sound.py | 5 +- setup.sh | 59 +- src/aipass/aipass/.claude/hooks/README.md | 56 ++ src/aipass/aipass/.seedgo/bypass.json | 20 + .../aipass/apps/handlers/init/bootstrap.py | 34 +- .../aipass/status/.claude/hooks/README.md | 56 ++ src/aipass/devpulse/.claude/hooks/README.md | 56 ++ 29 files changed, 1584 insertions(+), 174 deletions(-) create mode 100644 .claude/hooks/README.md create mode 100644 .claude/hooks/hook_log.py create mode 100644 .claude/hooks/hook_report.py create mode 100644 .claude/hooks/hook_test.py create mode 100644 src/aipass/aipass/.claude/hooks/README.md create mode 100644 src/aipass/aipass/status/.claude/hooks/README.md create mode 100644 src/aipass/devpulse/.claude/hooks/README.md diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md new file mode 100644 index 00000000..fff0511f --- /dev/null +++ b/.claude/hooks/README.md @@ -0,0 +1,144 @@ +# AIPass Hook System + +Provider-level hooks for the AIPass ecosystem. These fire for every Claude Code +session on this machine via `~/.claude/settings.json`. + +## File Layout + +``` +.claude/hooks/ +├── README.md # This file +│ +│ ── Hooks (wired in ~/.claude/settings.json) ── +├── global_prompt_loader.py # UserPromptSubmit — AIPass global prompt (~22KB) +├── branch_prompt_loader.py # UserPromptSubmit — branch-specific prompt +├── identity_injector.py # UserPromptSubmit — branch identity from passport +├── email_notification.py # UserPromptSubmit — unread email count +├── tool_use_sound.py # PreToolUse — key-press sound on tool calls +├── git_gate.py # PreToolUse — blocks raw git/gh, protects settings +├── auto_fix_diagnostics.py # PostToolUse — pyright + ruff on edited files +├── subagent_stop_gate.py # SubagentStop — seedgo checklist on modified files +├── pre_compact.py # PreCompact — post-compact recovery context +├── stop_sound.py # Stop — achievement bell +├── notification_sound.py # Notification — notification sound +│ +│ ── Also wired but lives in ~/.claude/hooks/ ── +│ pre_edit_gate.py # PreToolUse — cross-branch write block, error-fix gate +│ auto_watchdog.py # PostToolUse — watchdog reminder after dispatch +│ +│ ── Testing & debugging tools ── +├── hook_log.py # Shared logger — every hook calls run_and_log() +├── hook_report.py # Report tool — reads JSONL log, shows table +├── hook_test.py # Test harness — 20 tests (11 direct + 9 integration) +│ +│ ── Legacy probes ── +└── probes/ + ├── README.md + └── probe_*.py # Opt-in per-event diagnostic hooks +``` + +## Architecture + +Hooks fire from three levels (can fire simultaneously): + +| Level | Settings file | When it fires | +|-------|--------------|---------------| +| **Provider** | `~/.claude/settings.json` | Every session, everywhere | +| **Project** | `/.claude/settings.json` | When CWD is inside the project | +| **Branch** | deeper `.claude/settings.json` | When CWD is inside that branch | + +**Critical limitation:** PreToolUse and PostToolUse ONLY fire from provider settings. +UserPromptSubmit fires from ALL levels. This means project-level PreToolUse/PostToolUse +hooks provisioned by `aipass init` are dead weight — they never execute. + +## CWD Guards + +Four UserPromptSubmit hooks have CWD-aware guards. When CWD is inside a project that +has its own UserPromptSubmit hooks, the provider hook exits silently — preventing +AIPass context from bleeding into standalone projects. + +Guarded: `global_prompt_loader.py`, `branch_prompt_loader.py`, +`identity_injector.py`, `email_notification.py`. + +## Hook Inventory + +### UserPromptSubmit (provider, CWD-guarded) +| Script | Purpose | +|--------|---------| +| `global_prompt_loader.py` | Injects AIPass global prompt (~22KB) | +| `branch_prompt_loader.py` | Injects branch-specific prompt from `.aipass/aipass_local_prompt.md` | +| `identity_injector.py` | Injects branch identity from `.trinity/passport.json` | +| `email_notification.py` | Shows unread email count from `.ai_mail.local/inbox.json` | + +### PreToolUse (provider only) +| Script | Matcher | Purpose | +|--------|---------|---------| +| `tool_use_sound.py` | Bash\|Edit\|Write\|Read\|... | Plays key-press sound | +| `pre_edit_gate.py` | Edit\|Write\|NotebookEdit | Cross-branch write block + error-fix gate | +| `git_gate.py` | Bash\|Edit\|Write\|NotebookEdit | Blocks raw git/gh, protects settings files | + +### PostToolUse (provider only) +| Script | Matcher | Purpose | +|--------|---------|---------| +| `auto_fix_diagnostics.py` | Edit\|Write\|NotebookEdit | Runs pyright + ruff on edited files | +| `auto_watchdog.py` | Bash | Reminds agent to arm watchdog after dispatch | + +### Other events (provider) +| Script | Event | Purpose | +|--------|-------|---------| +| `subagent_stop_gate.py` | SubagentStop | Runs seedgo checklist on subagent-modified files | +| `pre_compact.py` | PreCompact | Injects post-compact recovery context | +| `stop_sound.py` | Stop | Plays achievement bell | +| `notification_sound.py` | Notification | Plays notification sound | + +## Testing + +### Execution log (always-on) +Every instrumented hook writes one JSONL line to `/tmp/aipass_hook_log.jsonl` via +`hook_log.py`. Each entry: timestamp, event, source, script, CWD, session, timing, +output_bytes, exit_code. + +### Report tool +```bash +python3 .claude/hooks/hook_report.py # Last 5 minutes +python3 .claude/hooks/hook_report.py --all # All entries +python3 .claude/hooks/hook_report.py --cwd /tmp # Filter by CWD +python3 .claude/hooks/hook_report.py --json # Machine-readable +python3 .claude/hooks/hook_report.py --clear # Wipe log +``` + +### Test harness (20 tests) +```bash +python3 .claude/hooks/hook_test.py # All 20 tests +python3 .claude/hooks/hook_test.py --direct # 11 direct tests only (fast, ~3s) +python3 .claude/hooks/hook_test.py --integration # 9 integration tests only (~2min) +python3 .claude/hooks/hook_test.py --verbose # Show detail per test +python3 .claude/hooks/hook_test.py --list # List available tests +python3 .claude/hooks/hook_test.py --test # Run one test +``` + +**Direct tests** (11) pipe JSON to hook scripts via subprocess. Deterministic, +no model, HIGH confidence. Tests CWD guards, git_gate block/allow, settings schema, +project-level guards. + +**Integration tests** (9) run `claude -p` from different CWDs and read the JSONL log. +Tests full pipeline including cross-project behavior, subagent hooks, and the +`disableAllHooks` toggle. + +### Disable all hooks +Add `"disableAllHooks": true` to `~/.claude/settings.json`. Remove to re-enable. + +### Debug mode +```bash +claude --debug hooks --debug-file /tmp/debug.log +``` + +### Interactive inspection +Type `/hooks` inside a Claude session — shows all hooks with source labels +(`[User]`, `[Project]`, `[Local]`). + +## Related +- **DPLAN-0167** — Hook testing framework +- **DPLAN-0166** — Hook audit + CI health +- **DPLAN-0139** — Hook overhaul + single-path enforcement +- **DPLAN-0131** — Hook system alignment (seedgo ownership) diff --git a/.claude/hooks/auto_fix_diagnostics.py b/.claude/hooks/auto_fix_diagnostics.py index 1ed056be..30d6fd1e 100644 --- a/.claude/hooks/auto_fix_diagnostics.py +++ b/.claude/hooks/auto_fix_diagnostics.py @@ -37,35 +37,32 @@ SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"} # AIPass-specific Python patterns to check PYTHON_PATTERNS = { - "bad_optional": { - "pattern": ": str = None", - "message": "Optional param should use 'str | None = None' pattern" - }, + "bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"}, "logger_debug": { "pattern": "logger.debug(", - "message": "Use logger.info for SystemLogger (logger.debug not supported)" + "message": "Use logger.info for SystemLogger (logger.debug not supported)", }, "return_error_msg": { "pattern": "return error_msg", - "message": "Return None for error states, not error_msg string" + "message": "Return None for error states, not error_msg string", }, "open_no_encoding": { "pattern": "open(", "requires_missing": "encoding=", - "message": "open() without encoding='utf-8'" + "message": "open() without encoding='utf-8'", }, "log_not_log_operation": { "pattern": ".log(", - "message": "Use log_operation() with success/error params, not .log()" + "message": "Use log_operation() with success/error params, not .log()", }, "dict_none_no_check": { "pattern": "Dict | None", - "message": "Dict | None return: Add None check before using (if result is None: return)" - } + "message": "Dict | None return: Add None check before using (if result is None: return)", + }, } # JSON-specific patterns for emoji corruption -JSON_CORRUPTION_CHARS = ['\ufffd', '\x00'] +JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"] def run_python_checks(file_path: str) -> list[str]: @@ -75,10 +72,7 @@ def run_python_checks(file_path: str) -> list[str]: # 1. Syntax check with py_compile try: result = subprocess.run( - [sys.executable, "-m", "py_compile", file_path], - capture_output=True, - text=True, - timeout=5 + [sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5 ) if result.returncode != 0: errors.append(f"SYNTAX: {result.stderr.strip()}") @@ -91,7 +85,7 @@ def run_python_checks(file_path: str) -> list[str]: ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], capture_output=True, text=True, - timeout=10 + timeout=10, ) if result.stdout.strip(): for line in result.stdout.strip().split("\n")[:5]: @@ -103,12 +97,7 @@ def run_python_checks(file_path: str) -> list[str]: # 3. Ruff format check — detect format drift try: - result = subprocess.run( - ["ruff", "format", "--check", file_path], - capture_output=True, - text=True, - timeout=10 - ) + result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10) if result.returncode != 0: errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})") except FileNotFoundError: @@ -146,19 +135,20 @@ def run_python_checks(file_path: str) -> list[str]: return errors - def run_ruff_lint_structured(file_path: str) -> list[dict]: """Run ruff check and return structured violations for the state file. Returns list of {line, message} dicts — same format as pyright errors. Only non-empty when ruff finds real violations (not format drift). """ - if '/.claude/hooks/' in file_path: + if "/.claude/hooks/" in file_path: return [] try: result = subprocess.run( ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], - capture_output=True, text=True, timeout=10 + capture_output=True, + text=True, + timeout=10, ) if not result.stdout.strip(): return [] @@ -179,15 +169,12 @@ def run_ruff_lint_structured(file_path: str) -> list[dict]: def run_pyright_check(file_path: str) -> list[dict]: """Run pyright on a single file. Returns list of error dicts.""" # Skip hook files - they don't follow project standards - if '/.claude/hooks/' in file_path: + if "/.claude/hooks/" in file_path: return [] try: result = subprocess.run( - [sys.executable, "-m", "pyright", "--outputjson", file_path], - capture_output=True, - text=True, - timeout=15 + [sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15 ) try: @@ -201,10 +188,7 @@ def run_pyright_check(file_path: str) -> list[dict]: if severity == "error": line = diag.get("range", {}).get("start", {}).get("line", 0) message = diag.get("message", "Unknown error") - errors.append({ - "line": line, - "message": message[:100] - }) + errors.append({"line": line, "message": message[:100]}) return errors[:10] # Max 10 errors @@ -220,10 +204,7 @@ def save_diagnostics_state(file_path: str, errors: list[dict]): """Save type errors to state file for PreToolUse gate.""" try: if errors: - state = { - "file": str(Path(file_path).resolve()), - "errors": errors - } + state = {"file": str(Path(file_path).resolve()), "errors": errors} STATE_FILE.write_text(json.dumps(state), encoding="utf-8") else: # No errors — clear the state @@ -249,11 +230,11 @@ def run_json_checks(file_path: str) -> list[str]: data = json.loads(content) if isinstance(data, dict): - for key in ['allowed_emojis', 'emojis', 'emoji_list']: + for key in ["allowed_emojis", "emojis", "emoji_list"]: if key in data and isinstance(data[key], list): for item in data[key]: if isinstance(item, str) and len(item) == 1: - if ord(item) < 128 and item not in '\u2713\u2717': + if ord(item) < 128 and item not in "\u2713\u2717": errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}") break @@ -268,7 +249,7 @@ def run_json_checks(file_path: str) -> list[str]: def run_seedgo_checklist(file_path: str) -> list[str]: """Run seedgo standards checklist — returns violations only.""" - if '/.claude/hooks/' in file_path: + if "/.claude/hooks/" in file_path: return [] try: @@ -277,7 +258,7 @@ def run_seedgo_checklist(file_path: str) -> list[str]: capture_output=True, text=True, timeout=15, - cwd=str(Path.home() / "Projects" / "AIPass") + cwd=str(Path.home() / "Projects" / "AIPass"), ) if result.returncode != 0: @@ -372,17 +353,12 @@ def main(): Fix these errors in {Path(file_path).name} now. Do not skip or defer.""" output = { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": context - }, - "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing" + "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context}, + "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing", } print(json.dumps(output)) else: - output = { - "systemMessage": "[diagnostics] ok" - } + output = {"systemMessage": "[diagnostics] ok"} print(json.dumps(output)) except Exception: @@ -390,4 +366,7 @@ Fix these errors in {Path(file_path).name} now. Do not skip or defer.""" if __name__ == "__main__": - main() + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("PostToolUse", "provider", __file__, main) diff --git a/.claude/hooks/branch_prompt_loader.py b/.claude/hooks/branch_prompt_loader.py index 0b4e6895..c3d2e94e 100644 --- a/.claude/hooks/branch_prompt_loader.py +++ b/.claude/hooks/branch_prompt_loader.py @@ -76,4 +76,9 @@ def main(): if __name__ == "__main__": - main() + import sys + + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/email_notification.py b/.claude/hooks/email_notification.py index d50456d1..70f6df76 100644 --- a/.claude/hooks/email_notification.py +++ b/.claude/hooks/email_notification.py @@ -117,4 +117,9 @@ def main(): if __name__ == "__main__": - main() + import sys + + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/git_gate.py b/.claude/hooks/git_gate.py index c5acea28..2e8ae9b4 100755 --- a/.claude/hooks/git_gate.py +++ b/.claude/hooks/git_gate.py @@ -21,9 +21,21 @@ import sys from pathlib import Path BLOCKED_GIT_VERBS = ( - "commit", "push", "pull", "merge", "rebase", "reset", - "checkout", "switch", "cherry-pick", "revert", - "rm", "mv", "restore", "clean", "config", + "commit", + "push", + "pull", + "merge", + "rebase", + "reset", + "checkout", + "switch", + "cherry-pick", + "revert", + "rm", + "mv", + "restore", + "clean", + "config", ) BLOCKED_GIT_RE = re.compile( @@ -31,25 +43,19 @@ BLOCKED_GIT_RE = re.compile( r"(" + "|".join(BLOCKED_GIT_VERBS) + r")\b" ) -BLOCKED_GIT_STASH_RE = re.compile( - r"(? None: + """Append one structured log entry. Never raises.""" + try: + entry = { + "ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "v": _VERSION, + "event": event, + "source": source, + "script": Path(script).name, + "script_path": str(script), + "cwd": os.getcwd(), + "session": os.environ.get("CLAUDE_CODE_SESSION_ID", ""), + "tool": tool, + "exit_code": exit_code, + "elapsed_ms": round(elapsed_ms, 1), + "output_bytes": output_bytes, + } + if extra: + entry.update(extra) + with open(_LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except Exception: + pass + + +class HookTimer: + """Context manager for timing hook execution.""" + + def __init__(self) -> None: + self.start: float = 0.0 + self.elapsed_ms: float = 0.0 + + def __enter__(self) -> "HookTimer": + self.start = time.monotonic() + return self + + def __exit__(self, *_: object) -> None: + self.elapsed_ms = (time.monotonic() - self.start) * 1000.0 + + +def run_and_log( + event: str, + source: str, + script: str, + fn: "callable", # noqa: F821 +) -> None: + """Run a hook function, capture stdout, time it, log the result. + + Usage in __main__ block (4 lines total): + import sys + sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parent)) + from hook_log import run_and_log + run_and_log("UserPromptSubmit", "provider", __file__, main) + """ + import io + import sys as _sys + + buf = io.StringIO() + orig = _sys.stdout + _sys.stdout = buf + + _exit_code = 0 + try: + with HookTimer() as t: + fn() + except SystemExit as e: + _exit_code = e.code if isinstance(e.code, int) else 0 + finally: + _sys.stdout = orig + + output = buf.getvalue() + if output: + print(output, end="") + + log_fire( + event, + source, + script, + elapsed_ms=t.elapsed_ms, + output_bytes=len(output.encode("utf-8")), + exit_code=_exit_code, + ) + _sys.exit(_exit_code) diff --git a/.claude/hooks/hook_report.py b/.claude/hooks/hook_report.py new file mode 100644 index 00000000..9dde3335 --- /dev/null +++ b/.claude/hooks/hook_report.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Hook Execution Report — reads /tmp/aipass_hook_log.jsonl and shows what fired. + +Usage: + python3 hook_report.py # Last session (or last 5 min) + python3 hook_report.py --all # All entries in log + python3 hook_report.py --session ID # Specific session + python3 hook_report.py --cwd /path # Filter by CWD + python3 hook_report.py --clear # Wipe log and start fresh + python3 hook_report.py --json # Output raw JSON instead of table + +Version: 1.0.0 +""" + +import argparse +import json +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path + +_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl") + +_EVENT_ORDER = [ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "SubagentStop", + "PreCompact", + "Stop", + "Notification", +] + + +def _load_entries( + session: str = "", + cwd: str = "", + since_minutes: int = 0, + all_entries: bool = False, +) -> list[dict]: + if not _LOG_FILE.exists(): + return [] + + entries = [] + now = datetime.now(timezone.utc) + + for line in _LOG_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if session and entry.get("session", "") != session: + continue + if cwd and not entry.get("cwd", "").startswith(cwd): + continue + + if not all_entries and since_minutes > 0: + try: + ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00")) + age = (now - ts).total_seconds() / 60 + if age > since_minutes: + continue + except (KeyError, ValueError): + continue + + entries.append(entry) + + return entries + + +def _format_bytes(n: int) -> str: + if n == 0: + return "silent" + if n < 1024: + return f"{n}B" + return f"{n / 1024:.1f}KB" + + +def _format_table(entries: list[dict]) -> str: + if not entries: + return "No hook activity found." + + lines = [] + + sessions = set(e.get("session", "")[:8] for e in entries) + cwds = set(e.get("cwd", "") for e in entries) + ts_range = "" + if entries: + first_ts = entries[0].get("ts", "")[:19] + last_ts = entries[-1].get("ts", "")[:19] + ts_range = f"{first_ts} -> {last_ts}" if first_ts != last_ts else first_ts + + lines.append("Hook Execution Report") + lines.append("=" * 70) + if len(sessions) == 1: + lines.append(f"Session: {list(sessions)[0]}...") + else: + lines.append(f"Sessions: {len(sessions)}") + if len(cwds) == 1: + lines.append(f"CWD: {list(cwds)[0]}") + else: + lines.append(f"CWDs: {', '.join(sorted(cwds))}") + lines.append(f"Time: {ts_range}") + lines.append(f"Total fires: {len(entries)}") + lines.append("") + + hdr = f"{'#':>3} | {'Event':<22} | {'Source':<8} | {'Script':<28} | {'ms':>6} | {'Output':>8} | {'Exit':>4}" + lines.append(hdr) + lines.append("-" * len(hdr)) + + for i, e in enumerate(entries, 1): + event = e.get("event", "?") + source = e.get("source", "?") + script = e.get("script", "?") + ms = e.get("elapsed_ms", 0) + out = _format_bytes(e.get("output_bytes", 0)) + exit_code = e.get("exit_code", 0) + exit_str = str(exit_code) if exit_code != 0 else "" + + lines.append(f"{i:>3} | {event:<22} | {source:<8} | {script:<28} | {ms:>6.1f} | {out:>8} | {exit_str:>4}") + + lines.append("") + + event_counts = Counter(e.get("event", "") for e in entries) + source_counts = Counter((e.get("event", ""), e.get("source", "")) for e in entries) + + warnings = [] + for event, count in event_counts.items(): + sources = [s for (ev, s), c in source_counts.items() if ev == event] + unique_sources = set(sources) + if count > 1 and len(unique_sources) > 1: + warnings.append(f"DOUBLE-FIRE: {event} fired {count}x from {', '.join(sorted(unique_sources))}") + elif count > 4: + warnings.append(f"HIGH FREQUENCY: {event} fired {count}x") + + suppressed = [e for e in entries if e.get("output_bytes", 0) == 0 and e.get("event") == "UserPromptSubmit"] + if suppressed: + scripts = [e.get("script", "?") for e in suppressed] + warnings.append( + f"CWD-GUARDED (likely): {len(suppressed)} UserPromptSubmit hook(s) produced no output: {', '.join(scripts)}" + ) + + if warnings: + lines.append("Warnings:") + for w in warnings: + lines.append(f" ! {w}") + else: + lines.append("No warnings.") + + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Hook execution report") + parser.add_argument("--all", action="store_true", help="Show all entries") + parser.add_argument("--session", default="", help="Filter by session ID (prefix match)") + parser.add_argument("--cwd", default="", help="Filter by CWD prefix") + parser.add_argument("--minutes", type=int, default=5, help="Show last N minutes (default: 5)") + parser.add_argument("--clear", action="store_true", help="Clear log file") + parser.add_argument("--json", action="store_true", help="Output raw JSON") + + args = parser.parse_args() + + if args.clear: + if _LOG_FILE.exists(): + _LOG_FILE.unlink() + print("Log cleared.") + else: + print("No log file to clear.") + return + + entries = _load_entries( + session=args.session, + cwd=args.cwd, + since_minutes=args.minutes, + all_entries=args.all, + ) + + if args.json: + print(json.dumps(entries, indent=2)) + else: + print(_format_table(entries)) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/hook_test.py b/.claude/hooks/hook_test.py new file mode 100644 index 00000000..5d01de6d --- /dev/null +++ b/.claude/hooks/hook_test.py @@ -0,0 +1,751 @@ +#!/usr/bin/env python3 +""" +Hook Test Harness — two test layers: + + 1. DIRECT tests: pipe JSON to hook scripts via subprocess. Deterministic, + fast, no model dependency. HIGH confidence. + 2. INTEGRATION tests: run `claude -p` from different CWDs, read JSONL log. + Tests full pipeline. MEDIUM confidence (model-dependent). + +Usage: + python3 hook_test.py # Run all tests + python3 hook_test.py --direct # Direct tests only (fast, deterministic) + python3 hook_test.py --integration # Integration tests only (slower, needs claude) + python3 hook_test.py --test cwd_guard # Run one test by name + python3 hook_test.py --list # List available tests + python3 hook_test.py --verbose # Show detail per test + +Version: 2.0.0 +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl") +_AIPASS_HOME = os.environ.get("AIPASS_HOME", "/home/patrick/Projects/AIPass") + + +def _clear_log() -> None: + if _LOG_FILE.exists(): + _LOG_FILE.unlink() + + +def _read_log() -> list[dict]: + if not _LOG_FILE.exists(): + return [] + entries = [] + for line in _LOG_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + +def _run_headless(cwd: str, prompt: str = "say hi", model: str = "haiku") -> tuple[int, str]: + """Run `claude -p` from a given CWD and return (exit_code, stdout).""" + try: + result = subprocess.run( + ["claude", "-p", prompt, "--model", model], + cwd=cwd, + capture_output=True, + text=True, + timeout=120, + ) + return result.returncode, result.stdout + except subprocess.TimeoutExpired: + return -1, "TIMEOUT" + except FileNotFoundError: + return -2, "claude not found" + + +class TestResult: + def __init__(self, name: str) -> None: + self.name = name + self.passed = False + self.message = "" + self.entries: list[dict] = [] + + def ok(self, msg: str = "") -> "TestResult": + self.passed = True + self.message = msg or "PASS" + return self + + def fail(self, msg: str) -> "TestResult": + self.passed = False + self.message = msg + return self + + +_HOOKS_DIR = Path(_AIPASS_HOME) / ".claude" / "hooks" + + +def _run_hook_direct( + script: str, + payload: dict, + cwd: str = "/tmp", + env_extra: dict | None = None, +) -> tuple[int, str, str]: + """Run a hook script as a subprocess with JSON on stdin. Returns (exit_code, stdout, stderr).""" + script_path = _HOOKS_DIR / script + if not script_path.exists(): + return -1, "", f"Script not found: {script_path}" + env = {**os.environ, "AIPASS_HOME": _AIPASS_HOME} + if env_extra: + env.update(env_extra) + try: + result = subprocess.run( + ["python3", str(script_path)], + input=json.dumps(payload), + capture_output=True, + text=True, + timeout=15, + cwd=cwd, + env=env, + ) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -2, "", "TIMEOUT" + + +def _find_project_with_hooks() -> str: + """Dynamically find a project that has its own UserPromptSubmit hooks.""" + projects_dir = Path.home() / "Projects" + if not projects_dir.exists(): + return "" + for proj in sorted(projects_dir.iterdir()): + if proj.name == "AIPass": + continue + settings = proj / ".claude" / "settings.json" + if settings.exists(): + try: + data = json.loads(settings.read_text(encoding="utf-8")) + if data.get("hooks", {}).get("UserPromptSubmit"): + return str(proj) + except (json.JSONDecodeError, OSError): + continue + return "" + + +def _find_project_without_hooks() -> str: + """Dynamically find a project that has settings.json but NO UserPromptSubmit hooks.""" + projects_dir = Path.home() / "Projects" + if not projects_dir.exists(): + return "" + for proj in sorted(projects_dir.iterdir()): + if proj.name == "AIPass": + continue + settings = proj / ".claude" / "settings.json" + if settings.exists(): + try: + data = json.loads(settings.read_text(encoding="utf-8")) + if not data.get("hooks", {}).get("UserPromptSubmit"): + return str(proj) + except (json.JSONDecodeError, OSError): + return str(proj) + return "" + + +# ========================================================================= +# DIRECT TESTS — pipe JSON to hook subprocess, deterministic, HIGH confidence +# ========================================================================= + + +def test_direct_global_prompt_from_tmp(verbose: bool = False) -> TestResult: + """[DIRECT] global_prompt_loader outputs full prompt when run from /tmp.""" + r = TestResult("direct_global_prompt_from_tmp") + exit_code, stdout, stderr = _run_hook_direct("global_prompt_loader.py", {}, cwd="/tmp") + if exit_code != 0: + return r.fail(f"Exit {exit_code}: {stderr}") + if len(stdout) < 1000: + return r.fail(f"Output only {len(stdout)} chars — expected ~22KB global prompt") + return r.ok(f"{len(stdout)} chars output from /tmp") + + +def test_direct_global_prompt_guarded(verbose: bool = False) -> TestResult: + """[DIRECT] global_prompt_loader suppressed when CWD has own hooks.""" + r = TestResult("direct_global_prompt_guarded") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + exit_code, stdout, stderr = _run_hook_direct("global_prompt_loader.py", {}, cwd=cwd) + if exit_code != 0: + return r.fail(f"Exit {exit_code}: {stderr}") + if stdout.strip(): + return r.fail(f"Expected silent (CWD guard), got {len(stdout)} chars") + return r.ok("Silent output — CWD guard active") + + +def test_direct_identity_injector(verbose: bool = False) -> TestResult: + """[DIRECT] identity_injector outputs identity from branch with passport.""" + r = TestResult("direct_identity_injector") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + exit_code, stdout, _ = _run_hook_direct("identity_injector.py", {}, cwd=cwd) + if exit_code != 0: + return r.fail(f"Exit {exit_code}") + # From devpulse, CWD guard is active — should be silent + if stdout.strip(): + return r.fail("Expected silent from devpulse (CWD guard), got output") + # Test from /tmp — no branch root, should also be silent + exit_code2, stdout2, _ = _run_hook_direct("identity_injector.py", {}, cwd="/tmp") + if stdout2.strip(): + return r.fail("Expected silent from /tmp (no branch root), got output") + return r.ok("Silent from guarded CWD and no-branch CWD") + + +def test_direct_git_gate_allows_safe(verbose: bool = False) -> TestResult: + """[DIRECT] git_gate allows safe Bash commands (exit 0, no output).""" + r = TestResult("direct_git_gate_allows_safe") + payload = {"tool_name": "Bash", "tool_input": {"command": "echo hello"}, "cwd": "/tmp"} + exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload) + if exit_code != 0: + return r.fail(f"Safe command blocked — exit {exit_code}") + if stdout.strip(): + return r.fail(f"Unexpected output for safe command: {stdout[:100]}") + return r.ok("Safe Bash command allowed (exit 0, silent)") + + +def test_direct_git_gate_blocks_raw_git(verbose: bool = False) -> TestResult: + """[DIRECT] git_gate blocks raw git commit (exit 2, decision=block).""" + r = TestResult("direct_git_gate_blocks_raw_git") + payload = {"tool_name": "Bash", "tool_input": {"command": "git commit -m test"}, "cwd": "/tmp"} + exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload) + if exit_code != 2: + return r.fail(f"Expected exit 2 (block), got {exit_code}") + try: + out = json.loads(stdout) + if out.get("decision") != "block": + return r.fail(f"Expected decision=block, got {out.get('decision')}") + except json.JSONDecodeError: + return r.fail(f"Non-JSON output: {stdout[:100]}") + return r.ok("git commit blocked (exit 2, decision=block)") + + +def test_direct_git_gate_blocks_gh_push(verbose: bool = False) -> TestResult: + """[DIRECT] git_gate blocks git push (exit 2, decision=block).""" + r = TestResult("direct_git_gate_blocks_gh_push") + payload = {"tool_name": "Bash", "tool_input": {"command": "git push origin main"}, "cwd": "/tmp"} + exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload) + if exit_code != 2: + return r.fail(f"Expected exit 2 (block), got {exit_code}") + return r.ok("git push blocked (exit 2)") + + +def test_direct_tool_use_sound_exits_clean(verbose: bool = False) -> TestResult: + """[DIRECT] tool_use_sound exits 0 and produces no stdout.""" + r = TestResult("direct_tool_use_sound_exits_clean") + payload = {"hook_event_name": "PreToolUse", "tool_name": "Read"} + exit_code, stdout, _ = _run_hook_direct("tool_use_sound.py", payload) + if exit_code != 0: + return r.fail(f"Exit {exit_code}") + if stdout.strip(): + return r.fail(f"Unexpected stdout: {stdout[:100]}") + return r.ok("Clean exit, no stdout") + + +def test_direct_email_notification_no_mail(verbose: bool = False) -> TestResult: + """[DIRECT] email_notification silent when no inbox exists.""" + r = TestResult("direct_email_notification_no_mail") + exit_code, stdout, _ = _run_hook_direct("email_notification.py", {}, cwd="/tmp") + if exit_code != 0: + return r.fail(f"Exit {exit_code}") + if stdout.strip(): + return r.fail(f"Unexpected output from /tmp (no mailbox): {stdout[:100]}") + return r.ok("Silent — no mailbox at /tmp") + + +def test_direct_settings_schema(verbose: bool = False) -> TestResult: + """[DIRECT] All hooks in provider settings.json reference scripts that exist.""" + r = TestResult("direct_settings_schema") + settings_path = Path.home() / ".claude" / "settings.json" + if not settings_path.exists(): + return r.fail("~/.claude/settings.json not found") + + data = json.loads(settings_path.read_text(encoding="utf-8")) + hooks = data.get("hooks", {}) + + valid_events = { + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "SubagentStop", + "PreCompact", + "PostCompact", + "Stop", + "Notification", + "SessionStart", + "PermissionRequest", + } + missing = [] + bad_events = [] + + for event, entries in hooks.items(): + if event not in valid_events: + bad_events.append(event) + for entry in entries: + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + parts = cmd.split() + for part in parts: + if part.endswith(".py") and "/" in part: + if not Path(part).exists(): + missing.append(part) + + errors = [] + if bad_events: + errors.append(f"Invalid events: {bad_events}") + if missing: + errors.append(f"Missing scripts: {missing}") + + if errors: + return r.fail("; ".join(errors)) + + hook_count = sum(len(e.get("hooks", [])) for entries in hooks.values() for e in entries) + return r.ok(f"{len(hooks)} events, {hook_count} hooks, all scripts exist") + + +def _find_aipass_init_project() -> str: + """Find a project created by aipass init (has *_REGISTRY.json).""" + projects_dir = Path.home() / "Projects" + for proj in sorted(projects_dir.iterdir()): + if proj.name == "AIPass": + continue + registries = list(proj.glob("*_REGISTRY.json")) + settings = proj / ".claude" / "settings.json" + if registries and settings.exists(): + return str(proj) + return "" + + +def test_direct_project_settings_schema(verbose: bool = False) -> TestResult: + """[DIRECT] aipass init project has valid settings.json with expected hooks.""" + r = TestResult("direct_project_settings_schema") + + proj = _find_aipass_init_project() + if not proj: + return r.fail("No aipass init project found (needs *_REGISTRY.json)") + + settings_path = Path(proj) / ".claude" / "settings.json" + data = json.loads(settings_path.read_text(encoding="utf-8")) + hooks = data.get("hooks", {}) + + has_ups = bool(hooks.get("UserPromptSubmit")) + has_pre = bool(hooks.get("PreToolUse")) + has_post = bool(hooks.get("PostToolUse")) + + project_name = Path(proj).name + notes = [] + if has_ups: + notes.append(f"UserPromptSubmit: {len(hooks['UserPromptSubmit'])} entries") + if has_pre: + notes.append(f"PreToolUse: {len(hooks['PreToolUse'])} entries (NOTE: won't fire from project level)") + if has_post: + notes.append(f"PostToolUse: {len(hooks['PostToolUse'])} entries (NOTE: won't fire from project level)") + + for event, entries in hooks.items(): + for entry in entries: + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + if cmd.startswith("python3 ") and ".py" in cmd: + script = cmd.split()[-1] + full = Path(proj) / script + if not full.exists(): + return r.fail(f"Missing script in {project_name}: {script}") + + return r.ok(f"{project_name}: {', '.join(notes)}") + + +def test_direct_provider_guards_for_init_project(verbose: bool = False) -> TestResult: + """[DIRECT] Provider hooks are CWD-guarded when run from an aipass init project.""" + r = TestResult("direct_provider_guards_for_init_project") + + proj = _find_aipass_init_project() + if not proj: + return r.fail("No aipass init project found") + + guarded_hooks = [ + "global_prompt_loader.py", + "branch_prompt_loader.py", + "identity_injector.py", + "email_notification.py", + ] + + for script in guarded_hooks: + exit_code, stdout, _ = _run_hook_direct(script, {}, cwd=proj) + if exit_code != 0: + return r.fail(f"{script} exited {exit_code} from {Path(proj).name}") + if stdout.strip(): + return r.fail( + f"{script} produced output from {Path(proj).name} — " + f"CWD guard should suppress (project has own UserPromptSubmit hooks)" + ) + + return r.ok(f"All 4 provider hooks suppressed from {Path(proj).name}") + + +# ========================================================================= +# INTEGRATION TESTS — run claude -p, read JSONL log, MEDIUM confidence +# ========================================================================= + + +def test_aipass_branch_hooks(verbose: bool = False) -> TestResult: + """Test: hooks fire correctly from an AIPass branch CWD (devpulse).""" + r = TestResult("aipass_branch_hooks") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + + if not Path(cwd).exists(): + return r.fail(f"CWD not found: {cwd}") + + _clear_log() + exit_code, _ = _run_headless(cwd) + entries = _read_log() + r.entries = entries + + if exit_code != 0: + return r.fail(f"claude -p exited {exit_code}") + + ups = [e for e in entries if e.get("event") == "UserPromptSubmit"] + if not ups: + return r.fail("No UserPromptSubmit hooks fired") + + expected_scripts = { + "global_prompt_loader.py", + "branch_prompt_loader.py", + "identity_injector.py", + "email_notification.py", + } + fired_scripts = {e.get("script", "") for e in ups} + + missing = expected_scripts - fired_scripts + if missing: + return r.fail(f"Missing UserPromptSubmit hooks: {missing}") + + return r.ok(f"{len(ups)} UserPromptSubmit hooks fired, {len(entries)} total") + + +def test_cwd_guard_devpulse(verbose: bool = False) -> TestResult: + """Test: CWD guard suppresses provider hooks when project has own hooks.""" + r = TestResult("cwd_guard_devpulse") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + + project_settings = Path(cwd) + found_settings = False + search = project_settings + while search != Path.home() and search.parent != search: + if (search / ".claude" / "settings.json").exists(): + found_settings = True + break + search = search.parent + + if not found_settings: + return r.fail("No .claude/settings.json found in CWD hierarchy — can't test CWD guard") + + _clear_log() + exit_code, _ = _run_headless(cwd) + entries = _read_log() + r.entries = entries + + if exit_code != 0: + return r.fail(f"claude -p exited {exit_code}") + + ups = [e for e in entries if e.get("event") == "UserPromptSubmit"] + guarded = [e for e in ups if e.get("output_bytes", 0) == 0] + + if not guarded: + return r.fail( + "No UserPromptSubmit hooks were suppressed — CWD guard may not be working. " + f"Hooks fired: {[e.get('script') for e in ups]}" + ) + + return r.ok(f"{len(guarded)}/{len(ups)} UserPromptSubmit hooks suppressed by CWD guard") + + +def test_tmp_no_guard(verbose: bool = False) -> TestResult: + """Test: from /tmp (no project hooks), provider hooks fire with full output.""" + r = TestResult("tmp_no_guard") + + _clear_log() + exit_code, _ = _run_headless("/tmp") + entries = _read_log() + r.entries = entries + + if exit_code != 0: + return r.fail(f"claude -p exited {exit_code}") + + ups = [e for e in entries if e.get("event") == "UserPromptSubmit"] + global_prompt = [e for e in ups if e.get("script") == "global_prompt_loader.py"] + + if not global_prompt: + return r.fail("global_prompt_loader.py did not fire from /tmp") + + if global_prompt[0].get("output_bytes", 0) < 1000: + return r.fail( + f"global_prompt_loader.py output only {global_prompt[0].get('output_bytes')}B " + "from /tmp — expected ~22KB (CWD guard should NOT fire here)" + ) + + return r.ok( + f"global_prompt_loader.py output {global_prompt[0].get('output_bytes')}B (guard not active, as expected)" + ) + + +def test_pretooluse_fires(verbose: bool = False) -> TestResult: + """Test: PreToolUse hooks fire on tool calls.""" + r = TestResult("pretooluse_fires") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + + _clear_log() + exit_code, _ = _run_headless(cwd, prompt="run: echo hello") + entries = _read_log() + r.entries = entries + + pre = [e for e in entries if e.get("event") == "PreToolUse"] + if not pre: + return r.fail("No PreToolUse hooks fired — expected at least tool_use_sound.py") + + return r.ok(f"{len(pre)} PreToolUse fires") + + +def test_posttooluse_fires(verbose: bool = False) -> TestResult: + """Test: PostToolUse hooks fire after tool calls.""" + r = TestResult("posttooluse_fires") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + + _clear_log() + exit_code, _ = _run_headless(cwd, prompt="use the bash tool to run: echo posttooluse-test") + entries = _read_log() + r.entries = entries + + post = [e for e in entries if e.get("event") == "PostToolUse"] + if not post: + return r.fail("No PostToolUse hooks fired") + + return r.ok(f"{len(post)} PostToolUse fires") + + +def test_standalone_project_guard(verbose: bool = False) -> TestResult: + """[INTEGRATION] standalone projects with own hooks get provider hooks suppressed.""" + r = TestResult("standalone_project_guard") + + cwd = _find_project_with_hooks() + if not cwd: + return r.fail("No standalone project with UserPromptSubmit hooks found") + + _clear_log() + exit_code, _ = _run_headless(cwd) + entries = _read_log() + r.entries = entries + + if exit_code != 0: + return r.fail(f"claude -p exited {exit_code}") + + ups = [e for e in entries if e.get("event") == "UserPromptSubmit"] + guarded = [e for e in ups if e.get("output_bytes", 0) == 0] + + if not ups: + return r.fail("No UserPromptSubmit hooks fired at all") + + project_name = Path(cwd).name + if not guarded: + return r.fail( + f"Provider hooks NOT suppressed in {project_name} (has own hooks). Fired: {[e.get('script') for e in ups]}" + ) + + return r.ok(f"{len(guarded)}/{len(ups)} provider UserPromptSubmit hooks suppressed in {project_name}") + + +def test_no_hooks_project_gets_prompt(verbose: bool = False) -> TestResult: + """[INTEGRATION] projects without own hooks receive full provider prompt.""" + r = TestResult("no_hooks_project_gets_prompt") + + cwd = _find_project_without_hooks() + if not cwd: + return r.fail("No project without UserPromptSubmit hooks found") + + _clear_log() + exit_code, _ = _run_headless(cwd) + entries = _read_log() + r.entries = entries + + if exit_code != 0: + return r.fail(f"claude -p exited {exit_code}") + + global_prompt = [ + e for e in entries if e.get("script") == "global_prompt_loader.py" and e.get("output_bytes", 0) > 1000 + ] + + project_name = Path(cwd).name + if not global_prompt: + return r.fail( + f"global_prompt_loader.py did NOT output full prompt in {project_name} " + f"(no own hooks — guard should be inactive)" + ) + + return r.ok( + f"global_prompt_loader.py output {global_prompt[0]['output_bytes']}B " + f"in {project_name} (no own hooks, guard inactive)" + ) + + +def test_subagent_hooks(verbose: bool = False) -> TestResult: + """Test: SubagentStop hook fires when a subagent completes.""" + r = TestResult("subagent_hooks") + cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse") + + _clear_log() + exit_code, _ = _run_headless( + cwd, + prompt="Use the Agent tool to spawn a helper that runs echo test via Bash then reports back", + ) + entries = _read_log() + r.entries = entries + + if exit_code != 0: + return r.fail(f"claude -p exited {exit_code}") + + subagent_stops = [e for e in entries if e.get("event") == "SubagentStop"] + if not subagent_stops: + return r.fail("No SubagentStop hook fired — model may not have spawned a subagent") + + return r.ok(f"{len(subagent_stops)} SubagentStop fire(s)") + + +def test_disable_toggle(verbose: bool = False) -> TestResult: + """[INTEGRATION] disableAllHooks=true stops all hook firing (atomic backup/restore).""" + r = TestResult("disable_toggle") + import shutil + import tempfile + + settings_path = Path.home() / ".claude" / "settings.json" + if not settings_path.exists(): + return r.fail("~/.claude/settings.json not found") + + # Atomic backup — copy to temp file first, restore from backup on any failure + backup_fd, backup_path = tempfile.mkstemp(suffix=".json", prefix="settings_backup_") + os.close(backup_fd) + shutil.copy2(str(settings_path), backup_path) + + try: + original = settings_path.read_text(encoding="utf-8") + data = json.loads(original) + data["disableAllHooks"] = True + settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + _clear_log() + exit_code, _ = _run_headless("/tmp") + entries = _read_log() + r.entries = entries + finally: + # Restore from atomic backup — safe even after crash/signal + shutil.copy2(backup_path, str(settings_path)) + try: + os.unlink(backup_path) + except OSError: + pass + + if entries: + return r.fail(f"{len(entries)} hooks fired with disableAllHooks=true — toggle broken") + + return r.ok("0 hooks fired with disableAllHooks=true") + + +_DIRECT_TESTS = [ + ("direct_global_prompt_from_tmp", test_direct_global_prompt_from_tmp), + ("direct_global_prompt_guarded", test_direct_global_prompt_guarded), + ("direct_identity_injector", test_direct_identity_injector), + ("direct_git_gate_allows_safe", test_direct_git_gate_allows_safe), + ("direct_git_gate_blocks_raw_git", test_direct_git_gate_blocks_raw_git), + ("direct_git_gate_blocks_gh_push", test_direct_git_gate_blocks_gh_push), + ("direct_tool_use_sound_exits_clean", test_direct_tool_use_sound_exits_clean), + ("direct_email_notification_no_mail", test_direct_email_notification_no_mail), + ("direct_settings_schema", test_direct_settings_schema), + ("direct_project_settings_schema", test_direct_project_settings_schema), + ("direct_provider_guards_for_init_project", test_direct_provider_guards_for_init_project), +] + +_INTEGRATION_TESTS = [ + ("aipass_branch_hooks", test_aipass_branch_hooks), + ("cwd_guard_devpulse", test_cwd_guard_devpulse), + ("tmp_no_guard", test_tmp_no_guard), + ("pretooluse_fires", test_pretooluse_fires), + ("posttooluse_fires", test_posttooluse_fires), + ("standalone_project_guard", test_standalone_project_guard), + ("no_hooks_project_gets_prompt", test_no_hooks_project_gets_prompt), + ("subagent_hooks", test_subagent_hooks), + ("disable_toggle", test_disable_toggle), +] + +_ALL_TESTS = _DIRECT_TESTS + _INTEGRATION_TESTS + + +def main() -> None: + parser = argparse.ArgumentParser(description="Hook test harness") + parser.add_argument("--test", default="", help="Run specific test by name") + parser.add_argument("--direct", action="store_true", help="Run direct tests only (fast, deterministic)") + parser.add_argument("--integration", action="store_true", help="Run integration tests only (slower)") + parser.add_argument("--list", action="store_true", help="List available tests") + parser.add_argument("--verbose", action="store_true", help="Show hook log per test") + + args = parser.parse_args() + + if args.list: + for name, fn in _ALL_TESTS: + print(f" {name}: {fn.__doc__}") + return + + if args.direct: + tests = _DIRECT_TESTS + elif args.integration: + tests = _INTEGRATION_TESTS + else: + tests = _ALL_TESTS + if args.test: + tests = [(n, f) for n, f in tests if n == args.test or args.test in n] + if not tests: + print(f"Unknown test: {args.test}") + print(f"Available: {', '.join(n for n, _ in _ALL_TESTS)}") + sys.exit(1) + + passed = 0 + failed = 0 + + print(f"\nHook Test Harness — {len(tests)} test(s)") + print("=" * 60) + + for name, fn in tests: + print(f"\n Running: {name}...", end=" ", flush=True) + try: + result = fn(verbose=args.verbose) + except Exception as e: + result = TestResult(name).fail(f"Exception: {e}") + + if result.passed: + passed += 1 + print(f"PASS — {result.message}") + else: + failed += 1 + print(f"FAIL — {result.message}") + + if args.verbose and result.entries: + print(f" Log entries ({len(result.entries)}):") + for e in result.entries: + print( + f" {e.get('event'):<22} " + f"{e.get('script'):<28} " + f"{e.get('elapsed_ms', 0):>6.1f}ms " + f"{e.get('output_bytes', 0):>6}B" + ) + + print(f"\n{'=' * 60}") + print(f"Results: {passed} passed, {failed} failed, {passed + failed} total") + + sys.exit(1 if failed > 0 else 0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/identity_injector.py b/.claude/hooks/identity_injector.py index 9258ca20..69f25ff3 100644 --- a/.claude/hooks/identity_injector.py +++ b/.claude/hooks/identity_injector.py @@ -139,4 +139,9 @@ def main(): if __name__ == "__main__": - main() + import sys + + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("UserPromptSubmit", "provider", __file__, main) diff --git a/.claude/hooks/notification_sound.py b/.claude/hooks/notification_sound.py index 6930bc53..50b94f21 100644 --- a/.claude/hooks/notification_sound.py +++ b/.claude/hooks/notification_sound.py @@ -35,4 +35,7 @@ def main(): if __name__ == "__main__": - main() + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("Notification", "provider", __file__, main) diff --git a/.claude/hooks/pre_compact.py b/.claude/hooks/pre_compact.py index 8191e4b2..2e2c7e40 100644 --- a/.claude/hooks/pre_compact.py +++ b/.claude/hooks/pre_compact.py @@ -165,4 +165,7 @@ Context just compacted. Below is your live state. Use it to continue seamlessly. if __name__ == "__main__": - main() + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("PreCompact", "provider", __file__, main) diff --git a/.claude/hooks/probes/README.md b/.claude/hooks/probes/README.md index bc8c99bd..a030ca3c 100644 --- a/.claude/hooks/probes/README.md +++ b/.claude/hooks/probes/README.md @@ -1,4 +1,9 @@ -# Hook Probe Suite +# Hook Probe Suite (Legacy) + +> **Note:** The probe suite predates the `hook_log.py` always-on logger (S132, DPLAN-0167). +> For most hook debugging, use `hook_report.py` and `hook_test.py` in the parent directory +> instead — they cover all hooks automatically without manual wiring. The probes below remain +> useful for one-off event investigation when you need to enable/disable individual events. This directory contains ping-response probe scripts for each Claude Code hook event type. Probes are **opt-in** — they are never auto-wired. See below for how to enable them. @@ -11,8 +16,6 @@ Each `probe_*.py` script in this directory is a passive observer for one Claude When enabled in `settings.json`, a probe fires on its event, records a structured entry to `last_ping.jsonl`, and exits 0 immediately — it never blocks execution. -The log is used by `drone @seedgo hooks probe` to display event tables and generate reports. - --- ## Probe scripts diff --git a/.claude/hooks/probes/probe_notification.py b/.claude/hooks/probes/probe_notification.py index 1e51639d..f1dd2f14 100644 --- a/.claude/hooks/probes/probe_notification.py +++ b/.claude/hooks/probes/probe_notification.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/probes/probe_post_tool_use.py b/.claude/hooks/probes/probe_post_tool_use.py index c8f58ca3..e23526bf 100644 --- a/.claude/hooks/probes/probe_post_tool_use.py +++ b/.claude/hooks/probes/probe_post_tool_use.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/probes/probe_pre_compact.py b/.claude/hooks/probes/probe_pre_compact.py index 5d771106..5b3d22de 100644 --- a/.claude/hooks/probes/probe_pre_compact.py +++ b/.claude/hooks/probes/probe_pre_compact.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/probes/probe_pre_tool_use.py b/.claude/hooks/probes/probe_pre_tool_use.py index a1251bd7..12a216ed 100644 --- a/.claude/hooks/probes/probe_pre_tool_use.py +++ b/.claude/hooks/probes/probe_pre_tool_use.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/probes/probe_stop.py b/.claude/hooks/probes/probe_stop.py index 15850330..88aa33d0 100644 --- a/.claude/hooks/probes/probe_stop.py +++ b/.claude/hooks/probes/probe_stop.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/probes/probe_subagent_stop.py b/.claude/hooks/probes/probe_subagent_stop.py index d3b74f6c..ef42658c 100644 --- a/.claude/hooks/probes/probe_subagent_stop.py +++ b/.claude/hooks/probes/probe_subagent_stop.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/probes/probe_user_prompt_submit.py b/.claude/hooks/probes/probe_user_prompt_submit.py index 8b867b77..f788ed2f 100644 --- a/.claude/hooks/probes/probe_user_prompt_submit.py +++ b/.claude/hooks/probes/probe_user_prompt_submit.py @@ -46,18 +46,10 @@ def main() -> None: pass # --- Extract fields --- - tool = ( - payload.get("tool_name") - or payload.get("hook_event_name") - or "" - ) + tool = payload.get("tool_name") or payload.get("hook_event_name") or "" cwd = payload.get("cwd") or os.getcwd() - agent_id = ( - os.environ.get("CLAUDE_CODE_SESSION_ID") - or os.environ.get("CLAUDE_SESSION_ID") - or "unknown" - ) + agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown" cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown") env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR")) env_has_aipass_home = bool(os.environ.get("AIPASS_HOME")) diff --git a/.claude/hooks/stop_sound.py b/.claude/hooks/stop_sound.py index ed0ba427..bc055656 100644 --- a/.claude/hooks/stop_sound.py +++ b/.claude/hooks/stop_sound.py @@ -36,4 +36,7 @@ def main(): if __name__ == "__main__": - main() + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("Stop", "provider", __file__, main) diff --git a/.claude/hooks/subagent_stop_gate.py b/.claude/hooks/subagent_stop_gate.py index 4140f551..f1d2cbd8 100644 --- a/.claude/hooks/subagent_stop_gate.py +++ b/.claude/hooks/subagent_stop_gate.py @@ -111,4 +111,7 @@ def main(): if __name__ == "__main__": - main() + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("SubagentStop", "provider", __file__, main) diff --git a/.claude/hooks/tool_use_sound.py b/.claude/hooks/tool_use_sound.py index e4c2a3ab..9855c675 100644 --- a/.claude/hooks/tool_use_sound.py +++ b/.claude/hooks/tool_use_sound.py @@ -38,4 +38,7 @@ def main(): if __name__ == "__main__": - main() + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from hook_log import run_and_log + + run_and_log("PreToolUse", "provider", __file__, main) diff --git a/setup.sh b/setup.sh index 362ad2f7..f6feacfc 100755 --- a/setup.sh +++ b/setup.sh @@ -536,7 +536,7 @@ else: # Build hooks config with absolute paths settings["hooks"] = { "UserPromptSubmit": [ - {"hooks": [{"type": "command", "command": f"cat {repo_root}/.aipass/aipass_global_prompt.md 2>/dev/null || true"}]}, + {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/global_prompt_loader.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/branch_prompt_loader.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/identity_injector.py"}]}, {"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/email_notification.py"}]}, @@ -574,6 +574,8 @@ settings["hooks"] = { import os env_block = settings.get("env", {}) env_block["AIPASS_HOME"] = repo_root +env_block["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1" +env_block["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1" # Windows: force UTF-8 for Rich output in hook processes msys = os.environ.get("MSYSTEM", "") + os.environ.get("OSTYPE", "") if "MSYS" in msys or "msys" in msys or "MINGW" in msys: @@ -585,17 +587,58 @@ permissions = settings.get("permissions", {}) deny = permissions.get("deny", []) secrets_deny = [ "Read(~/.secrets/**)", - "Read(/home/*/.secrets/**)", - "Bash(cat *~/.secrets*)", - "Bash(less *~/.secrets*)", - "Bash(head *~/.secrets*)", - "Bash(tail *~/.secrets*)", - "Bash(*~/.secrets*)", + f"Read({os.path.expanduser('~')}/.secrets/**)", + "Bash(cat ~/.secrets/*)", + f"Bash(cat {os.path.expanduser('~')}/.secrets/*)", + "Bash(head ~/.secrets/*)", + f"Bash(head {os.path.expanduser('~')}/.secrets/*)", + "Bash(tail ~/.secrets/*)", + f"Bash(tail {os.path.expanduser('~')}/.secrets/*)", + "Bash(less ~/.secrets/*)", + f"Bash(less {os.path.expanduser('~')}/.secrets/*)", ] -for rule in secrets_deny: +git_deny = [ + "Bash(git reset --hard*)", + "Bash(git push --force*)", + "Bash(git push -f *)", + "Bash(git rebase*)", + "Bash(git clean*)", + "Bash(rm -rf*)", + "Bash(git reset*)", + "Bash(git merge*)", + "Bash(git config*)", + "Bash(git checkout -- *)", + "Bash(git checkout .*)", + "Bash(git restore --staged*)", + "Bash(git restore .*)", + "Bash(git branch -D*)", + "Bash(git stash drop*)", + "Bash(git stash clear*)", + "Bash(rm -r *)", + "Bash(git checkout -b*)", + "Bash(git switch -c*)", + "Bash(git switch --create*)", + "Bash(git commit*)", + "Bash(git push*)", +] +for rule in secrets_deny + git_deny: if rule not in deny: deny.append(rule) permissions["deny"] = deny + +ask = permissions.get("ask", []) +home = os.path.expanduser("~") +ask_rules = [ + f"Edit({home}/.claude/**)", + f"Write({home}/.claude/**)", + "Edit(~/.claude/**)", + "Write(~/.claude/**)", +] +for rule in ask_rules: + if rule not in ask: + ask.append(rule) +permissions["ask"] = ask + settings["permissions"] = permissions settings_path.write_text(json.dumps(settings, indent=2) + "\n") diff --git a/src/aipass/aipass/.claude/hooks/README.md b/src/aipass/aipass/.claude/hooks/README.md new file mode 100644 index 00000000..5f14a0c9 --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/README.md @@ -0,0 +1,56 @@ +# Project-Level Hooks + +These hooks are provisioned by `aipass init` and live in the project's +`.claude/settings.json`. They fire when CWD is inside this project. + +## What fires and what doesn't + +**UserPromptSubmit** hooks fire from project settings. These work: +- `branch_prompt_loader.py` — injects branch-specific prompt +- `email_notification.py` — shows unread email count +- `identity_injector.py` — injects branch identity from passport + +**PreToolUse / PostToolUse** hooks are provisioned but **DO NOT FIRE** from +project-level settings. This is a Claude Code limitation (confirmed S122, +GitHub issue #36071). These scripts exist but are dead weight: +- `pre_edit_gate.py` — intended to block cross-branch writes (never runs) +- `auto_fix_diagnostics.py` — intended to run pyright+ruff (never runs) +- `subagent_stop_gate.py` — intended to check subagent files (never runs) + +These same hooks DO fire from provider settings (`~/.claude/settings.json`) +where they are also wired. The provider copies handle all enforcement. + +**PreCompact** hooks fire from project settings: +- `pre_compact.py` — injects recovery context after compaction + +## CWD guard interaction + +When this project has UserPromptSubmit hooks (it does), the provider-level +UserPromptSubmit hooks detect this and exit silently. This prevents the AIPass +global prompt from being injected into projects that manage their own context. + +The provider-level PreToolUse/PostToolUse hooks still fire (they can only run +at provider level) — so enforcement (git_gate, pre_edit_gate, auto_fix) is +always active regardless of CWD. + +## Testing + +Provider-level test harness covers project-level behavior: +```bash +python3 $AIPASS_HOME/.claude/hooks/hook_test.py --direct +``` + +Tests include: +- `direct_provider_guards_for_init_project` — verifies provider hooks are + CWD-guarded when run from an aipass init project +- `direct_project_settings_schema` — validates project settings.json has + expected hooks and all referenced scripts exist + +## Updating hooks + +```bash +drone @cli aipass init update # Refresh managed project files to latest templates +``` + +## Related +See `$AIPASS_HOME/.claude/hooks/README.md` for the full hook system documentation. diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json index d3490bdb..63b9da7a 100644 --- a/src/aipass/aipass/.seedgo/bypass.json +++ b/src/aipass/aipass/.seedgo/bypass.json @@ -125,6 +125,26 @@ "file": "tests/test_ping_sweep.py", "standard": "encapsulation", "reason": "Unit tests must import handlers directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing." + }, + { + "file": "apps/handlers/init/bootstrap.py", + "standard": "debug_print", + "reason": "The print() on line 159 is inside a generated shell command string (python3 -c), not a bare print call in this module's code." + }, + { + "file": "apps/handlers/init/bootstrap.py", + "standard": "help_text", + "reason": "The python3 references are in generated shell commands (settings.json hook entries), not in user-facing help text." + }, + { + "file": "apps/handlers/init/bootstrap.py", + "standard": "json_structure", + "reason": "bootstrap.py is Pure Python only (no module/prax/cli imports) by design — it must work during initial project setup before any AIPass services exist." + }, + { + "file": "apps/handlers/init/bootstrap.py", + "standard": "log_visibility", + "reason": "bootstrap.py is Pure Python only (no module/prax/cli imports) by design — stdlib getLogger is correct here. prax system_logger requires AIPass to be installed, which hasn't happened at bootstrap time." } ] } diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index e8828724..8a2e1c99 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -44,25 +44,24 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc logger = logging.getLogger(__name__) -ENFORCEMENT_HOOKS = [ - "auto_fix_diagnostics.py", - "pre_edit_gate.py", - "subagent_stop_gate.py", - "pre_compact.py", -] - -INJECTOR_HOOKS = [ +PROJECT_HOOKS = [ "branch_prompt_loader.py", "email_notification.py", "identity_injector.py", + "pre_compact.py", ] -HOOKS_TO_SHIP = ENFORCEMENT_HOOKS + INJECTOR_HOOKS +# These are shipped as reference copies but NOT wired in project settings.json +# because PreToolUse/PostToolUse/SubagentStop only fire from provider settings. +PROVIDER_ONLY_HOOKS = [ + "auto_fix_diagnostics.py", + "pre_edit_gate.py", + "subagent_stop_gate.py", +] + +HOOKS_TO_SHIP = PROJECT_HOOKS + PROVIDER_ONLY_HOOKS HOOK_EVENTS: dict[str, str] = { - "auto_fix_diagnostics.py": "PostToolUse", - "pre_edit_gate.py": "PreToolUse", - "subagent_stop_gate.py": "Stop", "pre_compact.py": "PreCompact", "branch_prompt_loader.py": "UserPromptSubmit", "email_notification.py": "UserPromptSubmit", @@ -137,16 +136,17 @@ def _detect_aipass_home() -> str | None: def _claude_settings(aipass_home: str | None = None) -> str: - """Generate .claude/settings.json — hooks for prompt injection + enforcement. + """Generate .claude/settings.json — hooks for prompt injection at project level. - Wires all AIPass hooks into their respective event types: + Only wires hooks that fire from project-level settings: - UserPromptSubmit: global/local prompt injection + branch_prompt_loader, email_notification, identity_injector - - PostToolUse: auto_fix_diagnostics - - PreToolUse: pre_edit_gate - - Stop: subagent_stop_gate - PreCompact: pre_compact + PreToolUse/PostToolUse/SubagentStop hooks are NOT wired here — they only + fire from provider settings (~/.claude/settings.json). The scripts are + still shipped as reference copies. Provider wiring is handled by setup.sh. + Args: aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. """ diff --git a/src/aipass/aipass/status/.claude/hooks/README.md b/src/aipass/aipass/status/.claude/hooks/README.md new file mode 100644 index 00000000..5f14a0c9 --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/README.md @@ -0,0 +1,56 @@ +# Project-Level Hooks + +These hooks are provisioned by `aipass init` and live in the project's +`.claude/settings.json`. They fire when CWD is inside this project. + +## What fires and what doesn't + +**UserPromptSubmit** hooks fire from project settings. These work: +- `branch_prompt_loader.py` — injects branch-specific prompt +- `email_notification.py` — shows unread email count +- `identity_injector.py` — injects branch identity from passport + +**PreToolUse / PostToolUse** hooks are provisioned but **DO NOT FIRE** from +project-level settings. This is a Claude Code limitation (confirmed S122, +GitHub issue #36071). These scripts exist but are dead weight: +- `pre_edit_gate.py` — intended to block cross-branch writes (never runs) +- `auto_fix_diagnostics.py` — intended to run pyright+ruff (never runs) +- `subagent_stop_gate.py` — intended to check subagent files (never runs) + +These same hooks DO fire from provider settings (`~/.claude/settings.json`) +where they are also wired. The provider copies handle all enforcement. + +**PreCompact** hooks fire from project settings: +- `pre_compact.py` — injects recovery context after compaction + +## CWD guard interaction + +When this project has UserPromptSubmit hooks (it does), the provider-level +UserPromptSubmit hooks detect this and exit silently. This prevents the AIPass +global prompt from being injected into projects that manage their own context. + +The provider-level PreToolUse/PostToolUse hooks still fire (they can only run +at provider level) — so enforcement (git_gate, pre_edit_gate, auto_fix) is +always active regardless of CWD. + +## Testing + +Provider-level test harness covers project-level behavior: +```bash +python3 $AIPASS_HOME/.claude/hooks/hook_test.py --direct +``` + +Tests include: +- `direct_provider_guards_for_init_project` — verifies provider hooks are + CWD-guarded when run from an aipass init project +- `direct_project_settings_schema` — validates project settings.json has + expected hooks and all referenced scripts exist + +## Updating hooks + +```bash +drone @cli aipass init update # Refresh managed project files to latest templates +``` + +## Related +See `$AIPASS_HOME/.claude/hooks/README.md` for the full hook system documentation. diff --git a/src/aipass/devpulse/.claude/hooks/README.md b/src/aipass/devpulse/.claude/hooks/README.md new file mode 100644 index 00000000..5f14a0c9 --- /dev/null +++ b/src/aipass/devpulse/.claude/hooks/README.md @@ -0,0 +1,56 @@ +# Project-Level Hooks + +These hooks are provisioned by `aipass init` and live in the project's +`.claude/settings.json`. They fire when CWD is inside this project. + +## What fires and what doesn't + +**UserPromptSubmit** hooks fire from project settings. These work: +- `branch_prompt_loader.py` — injects branch-specific prompt +- `email_notification.py` — shows unread email count +- `identity_injector.py` — injects branch identity from passport + +**PreToolUse / PostToolUse** hooks are provisioned but **DO NOT FIRE** from +project-level settings. This is a Claude Code limitation (confirmed S122, +GitHub issue #36071). These scripts exist but are dead weight: +- `pre_edit_gate.py` — intended to block cross-branch writes (never runs) +- `auto_fix_diagnostics.py` — intended to run pyright+ruff (never runs) +- `subagent_stop_gate.py` — intended to check subagent files (never runs) + +These same hooks DO fire from provider settings (`~/.claude/settings.json`) +where they are also wired. The provider copies handle all enforcement. + +**PreCompact** hooks fire from project settings: +- `pre_compact.py` — injects recovery context after compaction + +## CWD guard interaction + +When this project has UserPromptSubmit hooks (it does), the provider-level +UserPromptSubmit hooks detect this and exit silently. This prevents the AIPass +global prompt from being injected into projects that manage their own context. + +The provider-level PreToolUse/PostToolUse hooks still fire (they can only run +at provider level) — so enforcement (git_gate, pre_edit_gate, auto_fix) is +always active regardless of CWD. + +## Testing + +Provider-level test harness covers project-level behavior: +```bash +python3 $AIPASS_HOME/.claude/hooks/hook_test.py --direct +``` + +Tests include: +- `direct_provider_guards_for_init_project` — verifies provider hooks are + CWD-guarded when run from an aipass init project +- `direct_project_settings_schema` — validates project settings.json has + expected hooks and all referenced scripts exist + +## Updating hooks + +```bash +drone @cli aipass init update # Refresh managed project files to latest templates +``` + +## Related +See `$AIPASS_HOME/.claude/hooks/README.md` for the full hook system documentation.