- scripts/claude-hooks/check-secret-read.py — PreToolUse(Bash) guard: блокирует чтение/передачу .env*/.pgpass/ssh-key (cat/Get-Content/curl @file/<redirect), закрывает Bash(*) обход Read(.env) deny. Allow: .env.example/templates, ls/stat. Tested 12/12. - scripts/claude-hooks/check-sql-pitfalls.py — PostToolUse(Edit|Write|MultiEdit): блокирует import psycopg2 в backend + tradein-mvp/backend .py (psycopg v3). Cast-trap НЕ чекаем (FP на comment/test-mentions). Tested 7/7. - scripts/claude-hooks/session-preflight.py — SessionStart: статус FORGEJO/OBSIDIAN token + db-tunnel. - .claude/rules/tradein.md — НОВОЕ: tradein-mvp gotchas (2 БД, :8000=gendesign, scheduler в tradein-scraper, strict SQL auto-apply + NN-collisions, rapid-merge trap). - backend.md/sql.md/frontend.md: paths globs расширены на tradein-mvp/**. Wiring (settings.json) — local/gitignored, отдельно. Audit 2026-06-27 P0+P1.
87 lines
3 KiB
Python
87 lines
3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Claude Code PostToolUse hook — blocks `import psycopg2` in backend Python.
|
||
|
||
GenDesign uses psycopg v3 (`psycopg[binary]>=3.2.0`); psycopg2 is NOT installed,
|
||
so `import psycopg2` is a guaranteed `ModuleNotFoundError` at runtime — a recurring
|
||
bug class (see `.claude/rules/backend.md`). This catches it at edit-time, not on CI.
|
||
|
||
NOTE: the related `:param::type` cast trap is deliberately NOT checked here — the
|
||
codebase documents that anti-pattern in dozens of comments/test-docstrings
|
||
("never :param::type"), so a regex would false-positive constantly. That trap is
|
||
already covered by dedicated unit tests.
|
||
|
||
Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write|MultiEdit".
|
||
Watches `backend/**/*.py` (also `tradein-mvp/backend/**/*.py`, normalized to `backend/`).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
WATCH_PATH_PREFIX = "backend/"
|
||
# Real import statement at line start (after optional indent); not a comment or a
|
||
# docstring sentence like "never import psycopg2" (which starts with a word, not `import`).
|
||
PSYCOPG2_RE = re.compile(
|
||
r"(?m)^\s*(?:import\s+psycopg2|from\s+psycopg2(?:\.[\w.]+)?\s+import)\b"
|
||
)
|
||
|
||
|
||
def normalize_path(raw: str) -> str:
|
||
"""Repo-relative POSIX path anchored on the first `backend` segment."""
|
||
parts = Path(raw).parts
|
||
try:
|
||
idx = parts.index("backend")
|
||
except ValueError:
|
||
return Path(raw).as_posix()
|
||
return Path(*parts[idx:]).as_posix()
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
payload = json.load(sys.stdin)
|
||
except Exception:
|
||
return 0
|
||
|
||
tool_name = payload.get("tool_name") or payload.get("tool") or ""
|
||
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
||
return 0
|
||
|
||
tool_input = payload.get("tool_input") or {}
|
||
file_path = tool_input.get("file_path") or tool_input.get("path") or ""
|
||
if not file_path:
|
||
return 0
|
||
|
||
norm = normalize_path(file_path)
|
||
if not norm.startswith(WATCH_PATH_PREFIX) or not norm.endswith(".py"):
|
||
return 0
|
||
|
||
edits = tool_input.get("edits")
|
||
if isinstance(edits, list):
|
||
new_text = "\n".join(
|
||
str(e.get("new_string") or "") for e in edits if isinstance(e, dict)
|
||
)
|
||
else:
|
||
new_text = tool_input.get("new_string") or tool_input.get("content") or ""
|
||
if not new_text:
|
||
return 0
|
||
|
||
for lineno, line in enumerate(new_text.splitlines(), start=1):
|
||
if line.lstrip().startswith("#"):
|
||
continue
|
||
if PSYCOPG2_RE.match(line):
|
||
msg = [
|
||
f"Blocked: `import psycopg2` в {norm} (line {lineno}).",
|
||
"psycopg2 НЕ установлен → ModuleNotFoundError. Используй `import psycopg` (v3).",
|
||
"Bulk INSERT: `cur.executemany()` / COPY (НЕ execute_values). См. .claude/rules/backend.md.",
|
||
]
|
||
print("\n".join(msg), file=sys.stderr)
|
||
return 2
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|