From b73161a1336ce737ebb9f3056676d739ad9ab7d5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 27 Jun 2026 13:02:30 +0300 Subject: [PATCH] chore(claude-config): secret-read + psycopg2 hooks, tradein.md rule, tradein-mvp globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/claude-hooks/check-secret-read.py — PreToolUse(Bash) guard: блокирует чтение/передачу .env*/.pgpass/ssh-key (cat/Get-Content/curl @file/ # админ-роуты: -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()) -- 2.45.3