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
|
# 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
|
# 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
|
# 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