Merge pull request #522 from AIOSAI/work/system
feat(system): feat(hooks): DPLAN-0167 hook testing framework + bootstrap fix — 20-test harness with direct+integration layers, hook execution logger, CWD guard verification across projects, setup.sh provider wiring update (global_prompt_loader + env vars + git deny rules), bootstrap.py removes dead PreToolUse/PostToolUse from project settings, hook READMEs at provider+project level
This commit is contained in:
@@ -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** | `<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 <name> # 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+29
-16
@@ -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"(?<![@\w/.])git\s+stash\s+(drop|clear|pop|apply)\b"
|
||||
)
|
||||
BLOCKED_GIT_STASH_RE = re.compile(r"(?<![@\w/.])git\s+stash\s+(drop|clear|pop|apply)\b")
|
||||
|
||||
BLOCKED_GIT_BRANCH_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+branch\s+.*(-[dDmMcC]\b|--delete|--move|--copy|--force|--set-upstream-to|--unset-upstream)"
|
||||
)
|
||||
|
||||
BLOCKED_GIT_TAG_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+tag\s+.*(-d\b|--delete|--force|-f\b)"
|
||||
)
|
||||
BLOCKED_GIT_TAG_RE = re.compile(r"(?<![@\w/.])git\s+tag\s+.*(-d\b|--delete|--force|-f\b)")
|
||||
|
||||
BLOCKED_GIT_REMOTE_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+remote\s+(add|remove|rename|set-url|set-branches|set-head|prune)\b"
|
||||
)
|
||||
|
||||
BLOCKED_GH_API_RE = re.compile(
|
||||
r"(?<![@\w/.])gh\s+api\b"
|
||||
)
|
||||
BLOCKED_GH_API_RE = re.compile(r"(?<![@\w/.])gh\s+api\b")
|
||||
|
||||
BLOCKED_GH_RE = re.compile(
|
||||
r"(?<![@\w/.])gh\s+(pr|issue|repo|release|workflow|run|cache|secret|variable|gist)"
|
||||
@@ -136,9 +142,13 @@ def main():
|
||||
# (PR descriptions, commit messages, examples in docs), not code to enforce.
|
||||
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
|
||||
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
|
||||
if (BLOCKED_GIT_RE.search(scan) or BLOCKED_GIT_STASH_RE.search(scan)
|
||||
or BLOCKED_GIT_BRANCH_RE.search(scan) or BLOCKED_GIT_TAG_RE.search(scan)
|
||||
or BLOCKED_GIT_REMOTE_RE.search(scan)):
|
||||
if (
|
||||
BLOCKED_GIT_RE.search(scan)
|
||||
or BLOCKED_GIT_STASH_RE.search(scan)
|
||||
or BLOCKED_GIT_BRANCH_RE.search(scan)
|
||||
or BLOCKED_GIT_TAG_RE.search(scan)
|
||||
or BLOCKED_GIT_REMOTE_RE.search(scan)
|
||||
):
|
||||
_block(GIT_REDIRECT)
|
||||
if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan):
|
||||
if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)):
|
||||
@@ -163,4 +173,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)
|
||||
|
||||
@@ -46,4 +46,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)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared hook execution logger for AIPass.
|
||||
|
||||
Every hook imports this and calls log_fire() once. The log file is a JSONL
|
||||
append-only stream at /tmp/aipass_hook_log.jsonl — one line per hook invocation.
|
||||
|
||||
Usage in any hook script:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import log_fire
|
||||
|
||||
# At the end of main():
|
||||
log_fire("UserPromptSubmit", "provider", __file__, elapsed_ms=12.3, output_bytes=21400)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
|
||||
_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def log_fire(
|
||||
event: str,
|
||||
source: str,
|
||||
script: str,
|
||||
*,
|
||||
elapsed_ms: float = 0.0,
|
||||
output_bytes: int = 0,
|
||||
exit_code: int = 0,
|
||||
tool: str = "",
|
||||
extra: dict | None = None,
|
||||
) -> 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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user