chore(claude-config): secret-read + psycopg2 hooks, tradein.md rule, tradein-mvp globs #1975
7 changed files with 287 additions and 3 deletions
|
|
@ -1,5 +1,7 @@
|
|||
---
|
||||
paths: backend/**/*.py
|
||||
paths:
|
||||
- backend/**/*.py
|
||||
- tradein-mvp/backend/**/*.py
|
||||
---
|
||||
|
||||
# Backend conventions — Python 3.12 / FastAPI
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
53
.claude/rules/tradein.md
Normal 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`.
|
||||
95
scripts/claude-hooks/check-secret-read.py
Normal file
95
scripts/claude-hooks/check-secret-read.py
Normal 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())
|
||||
87
scripts/claude-hooks/check-sql-pitfalls.py
Normal file
87
scripts/claude-hooks/check-sql-pitfalls.py
Normal 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())
|
||||
43
scripts/claude-hooks/session-preflight.py
Normal file
43
scripts/claude-hooks/session-preflight.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue