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:
parent
1c184959a8
commit
4a33347aee
7 changed files with 270 additions and 6 deletions
|
|
@ -68,8 +68,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
||||||
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
|
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
|
||||||
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
|
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
|
||||||
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m.
|
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: 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):
|
3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path):
|
||||||
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
|
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
|
||||||
|
|
@ -185,7 +185,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
||||||
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
|
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
|
||||||
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
|
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
|
||||||
БЕЗ передачи токенов/credentials (runner логирует).
|
БЕЗ передачи токенов/credentials (runner логирует).
|
||||||
- Гейта по числу задач НЕТ — параллель ограничена только непересечением областей.
|
- Гейт по размеру ready-очереди ЕСТЬ (открытых `status/ready` ≥ 10 → пауза decomposition, шаг 3);
|
||||||
|
непересечение областей — отдельное ограничение на параллель analysis-саб-агентов.
|
||||||
|
|
||||||
## Запрос «поменяй лейблы» ⇒ также аудит тела issue
|
## Запрос «поменяй лейблы» ⇒ также аудит тела issue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"postgres-gendesign": {
|
"postgres-gendesign": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "docker",
|
"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}" }
|
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||||
},
|
},
|
||||||
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },
|
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"postgres-gendesign": {
|
"postgres-gendesign": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "docker",
|
"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}" }
|
"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.
|
# Agents and rules ARE tracked: they are team-shared config.
|
||||||
# Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern
|
# Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern
|
||||||
# does not work because git cannot re-include files under an ignored parent.
|
# does not work because git cannot re-include files under an ignored parent.
|
||||||
.claude/settings.json
|
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/cache/
|
.claude/cache/
|
||||||
.claude/sessions/
|
.claude/sessions/
|
||||||
|
|
|
||||||
73
scripts/claude-hooks/check-dangerous-commands.py
Normal file
73
scripts/claude-hooks/check-dangerous-commands.py
Normal 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())
|
||||||
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