chore(claude): security hardening — dangerous-commands hook, settings в git, restricted DB, throttle (#2150)
This commit is contained in:
parent
abef3e6e02
commit
b7d35cfee0
7 changed files with 287 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
144
.claude/settings.json
Normal 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
1
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
90
scripts/claude-hooks/check-dangerous-commands.py
Normal file
90
scripts/claude-hooks/check-dangerous-commands.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#!/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 команды —
|
||||
опасная операция внутри вызываемого скрипта не детектится. 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).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# (pattern, объяснение). Все паттерны применяются к полной строке команды.
|
||||
PATTERNS: list[tuple[str, str]] = [
|
||||
(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+(?:-C\s+\S+\s+)?push\b[^\n]*?--no-verify",
|
||||
"git push --no-verify запрещён (pre-push hooks обязательны)"),
|
||||
(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+(?:-C\s+\S+\s+)?commit\b[^\n]*?--amend\b",
|
||||
"git commit --amend запрещён без явного approval юзера"),
|
||||
(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+(?:-C\s+\S+\s+)?rebase\b[^\n]*?(\s-i\b|--interactive\b)",
|
||||
"git rebase -i/--interactive запрещён"),
|
||||
(r"git\s+(?:-C\s+\S+\s+)?reset\b[^\n]*?--hard\b",
|
||||
"git reset --hard запрещён (уничтожает локальные изменения)"),
|
||||
(r"git\s+(?:-C\s+\S+\s+)?clean\b[^\n]*?\s-[a-zA-Z]*f",
|
||||
"git clean -f* запрещён (удаляет untracked-файлы)"),
|
||||
(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) запрещён"),
|
||||
(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 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(
|
||||
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())
|
||||
47
scripts/claude-hooks/statusline.py
Normal file
47
scripts/claude-hooks/statusline.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue