chore(claude-config): secret-read + psycopg2 hooks, tradein.md rule, tradein-mvp globs #1975

Merged
lekss361 merged 1 commit from chore/claude-config-hooks into main 2026-06-27 10:03:20 +00:00
7 changed files with 287 additions and 3 deletions

View file

@ -1,5 +1,7 @@
---
paths: backend/**/*.py
paths:
- backend/**/*.py
- tradein-mvp/backend/**/*.py
---
# Backend conventions — Python 3.12 / FastAPI

View file

@ -1,5 +1,7 @@
---
paths: frontend/**/*.{ts,tsx,jsx,js}
paths:
- frontend/**/*.{ts,tsx,jsx,js}
- tradein-mvp/frontend/**/*.{ts,tsx,jsx,js}
---
# Frontend conventions — Next.js 15 / React 19 / TypeScript 5

View file

@ -1,5 +1,7 @@
---
paths: data/sql/**/*.sql
paths:
- data/sql/**/*.sql
- tradein-mvp/backend/data/sql/**/*.sql
---
# SQL conventions — PostgreSQL 16 / PostGIS 3.4

53
.claude/rules/tradein.md Normal file
View file

@ -0,0 +1,53 @@
---
paths:
- tradein-mvp/**/*.py
- tradein-mvp/**/*.sql
- tradein-mvp/frontend/**/*.{ts,tsx}
---
# trade-in (Mera) conventions — `tradein-mvp/`
Отдельный продукт + отдельный стек от Site Finder. Backend `tradein-mvp/backend/app/**`,
SQL `tradein-mvp/backend/data/sql/NN_*.sql`, frontend `tradein-mvp/frontend/`. Backend Python
подчиняется `.claude/rules/backend.md` (psycopg v3, CAST, ruff-100), SQL — `.claude/rules/sql.md`
(NN naming, idempotency). Ниже — то, что СПЕЦИФИЧНО для trade-in.
## Две БД — не путай
- **`postgres-tradein`** (db=tradein) — скрейпленные листинги avito/cian/yandex, estimator,
coverage, houses. Для ЛЮБОЙ tradein-задачи метрики/схему бери отсюда (`mcp__postgres-tradein__*`).
- **`postgres-gendesign`** (db=gendesign) — Site Finder, НЕ trade-in.
## Тестировать HTTP только ВНУТРИ контейнера
SSH-туннель `localhost:8000``gendesign-backend` (Site Finder, db=gendesign, старый код),
**НЕ** tradein-backend (порт не опубликован на хост). curl на туннель:8000 по trade-in endpoint =
мусор / чужая БД (стоило ~2ч). Тест trade-in API только изнутри контейнера:
```bash
ssh gendesign # затем:
docker exec tradein-backend curl -s localhost:8000/<route> # админ-роуты: -H "X-Authenticated-User: admin"
docker exec tradein-postgres psql -U <user> -d tradein -c "..."
```
## Scheduler крутится в `tradein-scraper`, не `tradein-backend`
In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
`SCHEDULER_ENABLE=true`) живёт в контейнере **`tradein-scraper`**; в `tradein-backend` намеренно
`false`. Статус scheduled-задач смотри в scraper-контейнере (logs/printenv), не в backend.
Ручной smoke: `UPDATE scrape_schedules SET next_run_at=now() WHERE source='X'` → подхват ≤60s.
## SQL авто-применяется на ПРОД (strict)
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
не доверяй `tail`.
## Rapid-merge trap
2 tradein-PR мержа за секунды → backend `test`-job cancelled → `build-backend` пропущен →
«deploy success» на СТАРОМ образе (нет нового кода/deps). Сверяй `:latest` Created-timestamp vs
время мержа + smoke в контейнере; не верь «деплой прошёл». Recovery: ручной `workflow_dispatch`
для `deploy-tradein.yml`.

View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
Claude Code PreToolUse hook blocks Bash commands that READ or TRANSMIT secret
files (.env*, .pgpass, ssh private keys).
Why: `.claude/settings.json` permissions end with a blanket `Bash(*)` allow, which
sidesteps the `Read(./.env*)` deny rule `cat backend/.env`, `Get-Content .env`,
`curl -F @backend/.env ` all exfiltrate prod DB passwords + FORGEJO/GLITCHTIP tokens
without a single prompt. This hook closes that bypass at the command layer.
Wired in `.claude/settings.json` hooks.PreToolUse with matcher "Bash".
Behavior:
- Reads hook input JSON from stdin; extracts tool_input.command.
- Blocks (exit 2, stderr -> Claude) iff the command references a REAL secret file
AND uses a read/transmit verb, OR `< file` redirection, OR `@file` upload, OR `open(`.
- Template files (.env.example / .sample / .template / .dist / .md) are allowed.
- `ls`/`stat`/`test -f` on a secret file are allowed (they don't read content).
- Fail-OPEN on parse errors (exit 0): this is a tripwire layered on top of the deny
list + (future) sandbox, not the sole guard better to under-block than to wedge
every Bash call on a malformed payload.
"""
from __future__ import annotations
import json
import re
import sys
# A secret-file token: `.env` with any number of dotted segments, `.pgpass`, or an
# ssh private key. The leading delimiter ([start | space | quote | = : < @ ( / ])
# anchors it as a filename/path component, not a substring of another word.
SECRET_RE = re.compile(
r"(?:^|[\s'\"=:<@(/])"
r"(\.env(?:\.[\w-]+)*|\.pgpass|id_rsa[\w.]*|id_ed25519[\w.]*|id_ecdsa[\w.]*)"
r"(?![\w-])",
re.IGNORECASE,
)
TEMPLATE_SUFFIXES = (".example", ".sample", ".template", ".dist", ".md")
# Verbs that read a file's content or push it off-box.
READ_VERB_RE = re.compile(
r"(?i)(?<![\w-])("
r"cat|tac|nl|head|tail|less|more|od|xxd|hexdump|strings|base64|"
r"type|gc|get-content|gp|select-string|sls|"
r"cp|copy|scp|rsync|sftp|"
r"curl|wget|invoke-webrequest|iwr|"
r"sed|awk|grep|findstr|sort|paste|cut|tr|dd|source|\."
r")(?![\w-])"
)
def _is_template(token: str) -> bool:
low = token.lower()
return any(low.endswith(s) for s in TEMPLATE_SUFFIXES)
def main() -> int:
try:
payload = json.load(sys.stdin)
except Exception:
return 0
tool = payload.get("tool_name") or payload.get("tool") or ""
if tool != "Bash":
return 0
cmd = (payload.get("tool_input") or {}).get("command") or ""
if not cmd:
return 0
hits = [m.group(1) for m in SECRET_RE.finditer(cmd) if not _is_template(m.group(1))]
if not hits:
return 0
danger = (
READ_VERB_RE.search(cmd)
or re.search(r"<\s*\S*\.env\b", cmd, re.IGNORECASE)
or re.search(r"@\S*\.env\b", cmd, re.IGNORECASE)
or re.search(r"open\(\s*['\"][^'\"]*\.env", cmd, re.IGNORECASE)
)
if not danger:
return 0
msg = [
f"Blocked: команда читает/передаёт секрет-файл ({hits[0]}).",
"Blanket `Bash(*)` обходит `Read(./.env*)` deny — этот PreToolUse-hook закрывает дыру.",
"Секрет-файлы содержат prod DB-пароли + FORGEJO/GLITCHTIP токены.",
"Если правда нужно прочитать — сделай это вручную вне Claude, либо используй `.env.example`.",
]
print("\n".join(msg), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,87 @@
#!/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())

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""
Claude Code SessionStart hook one-line preflight status printed into session context.
Surfaces the session-setup facts that have caused real lost time:
- FORGEJO_ACCESS_TOKEN present? (goern forgejo-MCP needs it BEFORE launch; missing -> idle bot)
- OBSIDIAN_API_KEY present? (vault MCP)
- DB tunnel localhost:15432 reachable? ("tunnel :8000 is gendesign not tradein" / tunnel-down traps)
Wired in `.claude/settings.json` hooks.SessionStart. ALWAYS exits 0 informational only,
never blocks. Fast (<~0.6s): single non-blocking TCP probe with a short timeout.
"""
from __future__ import annotations
import os
import socket
import sys
def _tcp_ok(host: str, port: int, timeout: float = 0.5) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def main() -> int:
try:
forgejo = "set" if os.environ.get("FORGEJO_ACCESS_TOKEN") else "MISSING"
obsidian = "set" if os.environ.get("OBSIDIAN_API_KEY") else "MISSING"
tunnel = "up" if _tcp_ok("localhost", 15432) else "DOWN"
print(
f"[preflight] FORGEJO_ACCESS_TOKEN={forgejo} · OBSIDIAN_API_KEY={obsidian} "
f"· db-tunnel(:15432)={tunnel}"
)
except Exception:
pass
return 0
if __name__ == "__main__":
sys.exit(main())