chore(claude): security hardening — hooks/settings в git, dangerous-commands hook, restricted DB для reviewer/qa

- .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
  вернулся к обычным промптам)
This commit is contained in:
bot-backend 2026-07-02 18:10:26 +03:00
parent 1c184959a8
commit 4a33347aee
7 changed files with 270 additions and 6 deletions

View file

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

View file

@ -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 },

View file

@ -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}" }
}
}

144
.claude/settings.json Normal file
View file

@ -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"
}
}

1
.gitignore vendored
View file

@ -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/

View file

@ -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())

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Claude Code statusLine ` <branch> · <model> · <dir>`.
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())