From 4a33347aeeced4ed108c2c947442ecebc33ee734 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 18:10:26 +0300 Subject: [PATCH 1/3] =?UTF-8?q?chore(claude):=20security=20hardening=20?= =?UTF-8?q?=E2=80=94=20hooks/settings=20=D0=B2=20git,=20dangerous-commands?= =?UTF-8?q?=20hook,=20restricted=20DB=20=D0=B4=D0=BB=D1=8F=20reviewer/qa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/settings.json закоммичен (секретов нет): бот-окна и чистые checkout получают hooks/deny/statusLine вместо молчаливого их отсутствия - новый PreToolUse hook check-dangerous-commands.py: ловит force/amend/no-verify/ rm -rf/reset --hard/push в main И внутри powershell-обёрток (deny-префиксы позиционны); 24/24 тест-кейсов; + Read-deny **/.env - postgres MCP reviewer/qa → --access-mode=restricted (декларируемый read-only теперь enforced, не проза) - auto-analyst: принята throttle-политика ready>=10 (конфликт с work-as-analyst устранён, just-in-time нарезка) - defaultMode:auto перенесён на project-уровень (глобально снят — balance_platform вернулся к обычным промптам) --- .claude/agents/auto-analyst.md | 7 +- .claude/mcp/qa.json | 2 +- .claude/mcp/reviewer.json | 2 +- .claude/settings.json | 144 ++++++++++++++++++ .gitignore | 1 - .../claude-hooks/check-dangerous-commands.py | 73 +++++++++ scripts/claude-hooks/statusline.py | 47 ++++++ 7 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.json create mode 100644 scripts/claude-hooks/check-dangerous-commands.py create mode 100644 scripts/claude-hooks/statusline.py diff --git a/.claude/agents/auto-analyst.md b/.claude/agents/auto-analyst.md index 93a1400b..ca44de98 100644 --- a/.claude/agents/auto-analyst.md +++ b/.claude/agents/auto-analyst.md @@ -68,8 +68,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе - По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг): ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa», чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m. - - ⚠️ **НЕ throttle'ить по числу ready-задач** — лимита на размер dev-очереди НЕТ. Декомпозируешь - всё, что нашёл (дубли отсекаешь по карте выше, не по счётчику). + - ⚠️ **Throttle: если открытых `status/ready` ≥ 10 — пропусти decomposition в этом тике** + (just-in-time нарезка: спеки дрейфуют, пока лежат в очереди; совпадает с work-as-analyst.md). 3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path): Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3): @@ -185,7 +185,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе - **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать. - Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings), БЕЗ передачи токенов/credentials (runner логирует). -- Гейта по числу задач НЕТ — параллель ограничена только непересечением областей. +- Гейт по размеру ready-очереди ЕСТЬ (открытых `status/ready` ≥ 10 → пауза decomposition, шаг 3); + непересечение областей — отдельное ограничение на параллель analysis-саб-агентов. ## Запрос «поменяй лейблы» ⇒ также аудит тела issue diff --git a/.claude/mcp/qa.json b/.claude/mcp/qa.json index 1e8b51fe..3e2c7a54 100644 --- a/.claude/mcp/qa.json +++ b/.claude/mcp/qa.json @@ -14,7 +14,7 @@ "postgres-gendesign": { "type": "stdio", "command": "docker", - "args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"], + "args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" } }, "playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true }, diff --git a/.claude/mcp/reviewer.json b/.claude/mcp/reviewer.json index c3e7516f..075c2bca 100644 --- a/.claude/mcp/reviewer.json +++ b/.claude/mcp/reviewer.json @@ -15,7 +15,7 @@ "postgres-gendesign": { "type": "stdio", "command": "docker", - "args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"], + "args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" } } } diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..8ba54a09 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "mcp__obsidian__obsidian_list_files_in_vault", + "mcp__obsidian__obsidian_simple_search", + "mcp__obsidian__obsidian_get_file_contents", + "mcp__obsidian__obsidian_list_files_in_dir", + "mcp__postgres-gendesign__get_object_details", + "mcp__postgres-gendesign__explain_query", + "mcp__postgres-gendesign__list_objects", + "mcp__postgres-gendesign__list_schemas", + "mcp__postgres-gendesign__analyze_query_indexes", + "Bash(curl *)", + "Bash(powershell *)", + "Bash(gh pr comment:*)", + "Bash(powershell.exe:*)", + "Bash(powershell:*)", + "Bash(pwsh:*)", + "mcp__forgejo", + "mcp__forgejo__create_issue", + "mcp__forgejo__update_issue", + "mcp__forgejo__add_issue_labels", + "mcp__forgejo__remove_issue_labels", + "mcp__forgejo__issue_state_change", + "mcp__forgejo__create_issue_comment", + "mcp__forgejo__create_pull_request", + "mcp__forgejo__merge_pull_request", + "mcp__forgejo__create_pull_review", + "mcp__forgejo__get_pull_request_diff", + "mcp__forgejo__list_repo_issues", + "mcp__forgejo__list_repo_pull_requests", + "mcp__forgejo__get_pull_request_by_index", + "mcp__forgejo__list_pull_request_files", + "mcp__forgejo__list_pull_reviews", + "mcp__forgejo__list_repo_labels", + "mcp__forgejo__get_issue_by_index", + "mcp__forgejo__list_issue_comments", + "Write", + "Edit", + "Read", + "Glob", + "Grep", + "LS", + "Task", + "TodoWrite", + "EnterWorktree", + "WebFetch", + "WebSearch", + "NotebookEdit", + "mcp__obsidian", + "mcp__postgres-gendesign", + "mcp__playwright", + "mcp__a11y", + "mcp__lighthouse", + "mcp__shadcn", + "mcp__glitchtip", + "mcp__context7", + "mcp__fetch", + "Bash(*)" + ], + "deny": [ + "Bash(rm -rf *)", + "Bash(rm -rf /*)", + "Bash(git push --force *)", + "Bash(git push -f *)", + "Bash(git reset --hard *)", + "Bash(git commit --amend *)", + "Bash(git rebase --interactive *)", + "Bash(git commit --no-verify*)", + "Bash(git push --no-verify*)", + "Bash(git push --force-with-lease*)", + "Bash(git push --force-if-includes*)", + "Bash(docker compose down -v *)", + "Bash(docker volume rm *)", + "Read(./.env)", + "Read(./.env.*)", + "Read(./backend/.env)", + "Read(./backend/.env.*)", + "Read(**/.env)", + "Read(**/.env.*)" + ], + "defaultMode": "auto" + }, + "enabledMcpjsonServers": [ + "obsidian", + "context7", + "fetch", + "forgejo", + "a11y", + "lighthouse", + "shadcn" + ], + "worktree": { + "baseRef": "fresh", + "bgIsolation": "worktree" + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-secret-read.py" + }, + { + "type": "command", + "command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-dangerous-commands.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-no-print.py" + }, + { + "type": "command", + "command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-sql-pitfalls.py" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/session-preflight.py" + } + ] + } + ] + }, + "statusLine": { + "type": "command", + "command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/statusline.py" + } +} diff --git a/.gitignore b/.gitignore index 081f32f3..415ae9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,6 @@ tradein-mvp/scripts/.*cookies*.json # Agents and rules ARE tracked: they are team-shared config. # Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern # does not work because git cannot re-include files under an ignored parent. -.claude/settings.json .claude/settings.local.json .claude/cache/ .claude/sessions/ diff --git a/scripts/claude-hooks/check-dangerous-commands.py b/scripts/claude-hooks/check-dangerous-commands.py new file mode 100644 index 00000000..47f3e5f0 --- /dev/null +++ b/scripts/claude-hooks/check-dangerous-commands.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: блокирует запрещённые git/fs/docker команды, включая shell-обёртки. + +Deny-префиксы в settings.json позиционны и обходятся обёртками +(`powershell -c "git push --force"` не матчится ни одним deny-паттерном). +Этот hook regex-сканирует ВСЮ строку команды Bash-тула, поэтому ловит и пейлоады +внутри powershell/pwsh/bash -c. + +Ограничение (как у check-secret-read.py): видит только текст top-level команды — +опасная операция внутри вызываемого скрипта не детектится. Fail-open по дизайну: +это tripwire-слой поверх deny-правил и review, а не единственная защита. + +Протокол: exit 0 = allow, exit 2 + stderr = block (сообщение уходит Claude). +""" + +import json +import re +import sys + +# (pattern, объяснение). Все паттерны применяются к полной строке команды. +PATTERNS: list[tuple[str, str]] = [ + (r"git\s+push\b[^\n]*?\s(-f|--force(?:-with-lease|-if-includes)?)\b", + "git push --force/-f/--force-with-lease запрещён без явного approval юзера"), + (r"git\s+push\b[^\n]*?--no-verify", + "git push --no-verify запрещён (pre-push hooks обязательны)"), + (r"git\s+push\b[^\n]*\s\S+\s+(\S+:)?main\b", + "прямой push в main запрещён — только ветка + PR (git-pr.md)"), + (r"git\s+commit\b[^\n]*?--amend\b", + "git commit --amend запрещён без явного approval юзера"), + (r"git\s+commit\b[^\n]*?(--no-verify\b|\s-n\b)", + "git commit --no-verify/-n запрещён (pre-commit hooks обязательны)"), + (r"git\s+rebase\b[^\n]*?(\s-i\b|--interactive\b)", + "git rebase -i/--interactive запрещён"), + (r"git\s+reset\b[^\n]*?--hard\b", + "git reset --hard запрещён (уничтожает локальные изменения)"), + (r"git\s+clean\b[^\n]*?\s-[a-zA-Z]*f", + "git clean -f* запрещён (удаляет untracked-файлы)"), + (r"git\s+checkout\b[^\n]*?\s-f\b", + "git checkout -f запрещён (теряет незакоммиченные изменения)"), + (r"\brm\b(?=[^\n|;&]*\s-[a-zA-Z]*r)(?=[^\n|;&]*\s-[a-zA-Z]*f)", + "rm -rf (и вариации -fr / -r -f) запрещён"), + (r"(?i)remove-item\b(?=[^\n]*-recurse)(?=[^\n]*-force)", + "Remove-Item -Recurse -Force запрещён"), + (r"(?i)docker(?:-|\s+)compose\s+down\b[^\n]*(\s-v\b|--volumes\b)", + "docker compose down -v удаляет volumes"), + (r"docker\s+volume\s+rm\b", + "docker volume rm запрещён"), +] + + +def main() -> int: + try: + data = json.load(sys.stdin) + if data.get("tool_name") != "Bash": + return 0 + cmd = (data.get("tool_input") or {}).get("command") or "" + for pattern, reason in PATTERNS: + if re.search(pattern, cmd): + print( + f"BLOCKED (check-dangerous-commands): {reason}. " + "Правило CLAUDE.md #5 / git-pr.md § Запреты. Если операция реально нужна — " + "попроси юзера выполнить её самому или дать явное разрешение; " + "не пытайся обойти через обёртки/скрипты.", + file=sys.stderr, + ) + return 2 + return 0 + except Exception: + return 0 # fail-open: tripwire-слой, не единственная защита + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/claude-hooks/statusline.py b/scripts/claude-hooks/statusline.py new file mode 100644 index 00000000..ecb077cd --- /dev/null +++ b/scripts/claude-hooks/statusline.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Claude Code statusLine — `⎇ · · `. + +Wired in `.claude/settings.json` statusLine. Reads session JSON from stdin +(model.display_name, workspace.current_dir). Branch via a 1s git probe in cwd. + +Why: 3-5 parallel bg-windows on shared worktrees → branch/persona confusion is a +documented fail-class (orphan commits). A persistent branch+model line кат this risk. +Always exits 0; never blocks. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys + + +def main() -> int: + try: + data = json.load(sys.stdin) + except Exception: + data = {} + + model = (data.get("model") or {}).get("display_name") or "?" + cwd = (data.get("workspace") or {}).get("current_dir") or data.get("cwd") or os.getcwd() + base = os.path.basename(str(cwd).rstrip("/\\")) or str(cwd) + + branch = "?" + try: + out = subprocess.run( + ["git", "-C", str(cwd), "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=1, + ) + branch = out.stdout.strip() or "?" + except Exception: + pass + + print(f"⎎ {branch} · {model} · {base}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.45.3 From 0bbfd0e13e0e516340affcf688d18238912fb1d3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 18:15:50 +0300 Subject: [PATCH 2/3] fix(claude-hooks): strip commit-message contents before pattern match (false positive) --- scripts/claude-hooks/check-dangerous-commands.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/claude-hooks/check-dangerous-commands.py b/scripts/claude-hooks/check-dangerous-commands.py index 47f3e5f0..fb9f7127 100644 --- a/scripts/claude-hooks/check-dangerous-commands.py +++ b/scripts/claude-hooks/check-dangerous-commands.py @@ -48,12 +48,27 @@ PATTERNS: list[tuple[str, str]] = [ ] +def strip_commit_messages(cmd: str) -> str: + """Вырезает содержимое -m/--message аргументов git commit. + + Иначе false-positive: `git commit -m "запретили rm -rf"` блокируется из-за + текста сообщения. Пейлоады обёрток (powershell -c "git push --force") не + страдают — они не привязаны к -m. + """ + return re.sub( + r"(\s(?:-m|--message))\s+(\"(?:[^\"\\]|\\.)*\"|'[^']*')", + r"\1 MSG", + cmd, + ) + + def main() -> int: try: data = json.load(sys.stdin) if data.get("tool_name") != "Bash": return 0 cmd = (data.get("tool_input") or {}).get("command") or "" + cmd = strip_commit_messages(cmd) for pattern, reason in PATTERNS: if re.search(pattern, cmd): print( -- 2.45.3 From 4ecb639492166566d938dd869dd4aa73c5dfee5c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 18:22:10 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(claude-hooks):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20git=20-C=20variant,=20main-hotfix=20false=20positiv?= =?UTF-8?q?e,=20known-bypass=20docstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claude-hooks/check-dangerous-commands.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/claude-hooks/check-dangerous-commands.py b/scripts/claude-hooks/check-dangerous-commands.py index fb9f7127..f602a2e3 100644 --- a/scripts/claude-hooks/check-dangerous-commands.py +++ b/scripts/claude-hooks/check-dangerous-commands.py @@ -7,8 +7,10 @@ Deny-префиксы в settings.json позиционны и обходятс внутри powershell/pwsh/bash -c. Ограничение (как у check-secret-read.py): видит только текст top-level команды — -опасная операция внутри вызываемого скрипта не детектится. Fail-open по дизайну: -это tripwire-слой поверх deny-правил и review, а не единственная защита. +опасная операция внутри вызываемого скрипта не детектится. Known-bypass (осознанно): +long-form `rm --recursive --force`, PowerShell-алиасы ri/rd/del, сокращения -Rec. +Fail-open по дизайну: это tripwire-слой поверх deny-правил и review, а не +единственная защита. Протокол: exit 0 = allow, exit 2 + stderr = block (сообщение уходит Claude). """ @@ -19,23 +21,23 @@ import sys # (pattern, объяснение). Все паттерны применяются к полной строке команды. PATTERNS: list[tuple[str, str]] = [ - (r"git\s+push\b[^\n]*?\s(-f|--force(?:-with-lease|-if-includes)?)\b", + (r"git\s+(?:-C\s+\S+\s+)?push\b[^\n]*?\s(-f|--force(?:-with-lease|-if-includes)?)\b", "git push --force/-f/--force-with-lease запрещён без явного approval юзера"), - (r"git\s+push\b[^\n]*?--no-verify", + (r"git\s+(?:-C\s+\S+\s+)?push\b[^\n]*?--no-verify", "git push --no-verify запрещён (pre-push hooks обязательны)"), - (r"git\s+push\b[^\n]*\s\S+\s+(\S+:)?main\b", + (r"git\s+(?:-C\s+\S+\s+)?push\b[^\n]*\s\S+\s+(\S+:)?main(?![\w/-])", "прямой push в main запрещён — только ветка + PR (git-pr.md)"), - (r"git\s+commit\b[^\n]*?--amend\b", + (r"git\s+(?:-C\s+\S+\s+)?commit\b[^\n]*?--amend\b", "git commit --amend запрещён без явного approval юзера"), - (r"git\s+commit\b[^\n]*?(--no-verify\b|\s-n\b)", + (r"git\s+(?:-C\s+\S+\s+)?commit\b[^\n]*?(--no-verify\b|\s-n\b)", "git commit --no-verify/-n запрещён (pre-commit hooks обязательны)"), - (r"git\s+rebase\b[^\n]*?(\s-i\b|--interactive\b)", + (r"git\s+(?:-C\s+\S+\s+)?rebase\b[^\n]*?(\s-i\b|--interactive\b)", "git rebase -i/--interactive запрещён"), - (r"git\s+reset\b[^\n]*?--hard\b", + (r"git\s+(?:-C\s+\S+\s+)?reset\b[^\n]*?--hard\b", "git reset --hard запрещён (уничтожает локальные изменения)"), - (r"git\s+clean\b[^\n]*?\s-[a-zA-Z]*f", + (r"git\s+(?:-C\s+\S+\s+)?clean\b[^\n]*?\s-[a-zA-Z]*f", "git clean -f* запрещён (удаляет untracked-файлы)"), - (r"git\s+checkout\b[^\n]*?\s-f\b", + (r"git\s+(?:-C\s+\S+\s+)?checkout\b[^\n]*?\s-f\b", "git checkout -f запрещён (теряет незакоммиченные изменения)"), (r"\brm\b(?=[^\n|;&]*\s-[a-zA-Z]*r)(?=[^\n|;&]*\s-[a-zA-Z]*f)", "rm -rf (и вариации -fr / -r -f) запрещён"), -- 2.45.3