From 845f6991761dc9b09f1e0c66963e4ee783b02f2b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 27 Jun 2026 10:03:19 +0000 Subject: [PATCH] chore(claude-config): secret-read + psycopg2 hooks, tradein.md rule, tradein-mvp globs (#1975) --- .claude/rules/backend.md | 4 +- .claude/rules/frontend.md | 4 +- .claude/rules/sql.md | 4 +- .claude/rules/tradein.md | 53 ++++++++++++ scripts/claude-hooks/check-secret-read.py | 95 ++++++++++++++++++++++ scripts/claude-hooks/check-sql-pitfalls.py | 87 ++++++++++++++++++++ scripts/claude-hooks/session-preflight.py | 43 ++++++++++ 7 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 .claude/rules/tradein.md create mode 100644 scripts/claude-hooks/check-secret-read.py create mode 100644 scripts/claude-hooks/check-sql-pitfalls.py create mode 100644 scripts/claude-hooks/session-preflight.py diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 39aee438..d4978802 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -1,5 +1,7 @@ --- -paths: backend/**/*.py +paths: + - backend/**/*.py + - tradein-mvp/backend/**/*.py --- # Backend conventions — Python 3.12 / FastAPI diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 7b14b15f..4c1a4f84 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -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 diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md index b657593d..f221b4ef 100644 --- a/.claude/rules/sql.md +++ b/.claude/rules/sql.md @@ -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 diff --git a/.claude/rules/tradein.md b/.claude/rules/tradein.md new file mode 100644 index 00000000..f54f8d34 --- /dev/null +++ b/.claude/rules/tradein.md @@ -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/ # админ-роуты: -H "X-Authenticated-User: admin" +docker exec tradein-postgres psql -U -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`. diff --git a/scripts/claude-hooks/check-secret-read.py b/scripts/claude-hooks/check-secret-read.py new file mode 100644 index 00000000..a7361e70 --- /dev/null +++ b/scripts/claude-hooks/check-secret-read.py @@ -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)(? 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()) diff --git a/scripts/claude-hooks/check-sql-pitfalls.py b/scripts/claude-hooks/check-sql-pitfalls.py new file mode 100644 index 00000000..6871733a --- /dev/null +++ b/scripts/claude-hooks/check-sql-pitfalls.py @@ -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()) diff --git a/scripts/claude-hooks/session-preflight.py b/scripts/claude-hooks/session-preflight.py new file mode 100644 index 00000000..378315b3 --- /dev/null +++ b/scripts/claude-hooks/session-preflight.py @@ -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())