🪝 Hooks — автоматизация
Hooks — скрипты Node.js (CJS), которые запускаются автоматически до/после действий Claude. Они перехватывают опасные команды, форматируют код, защищают критические файлы и считают стоимость сессии.
📋 Обзор hooks системы
| Hook | Событие | Матчер | Действие |
|---|---|---|---|
dangerous-command-guard | PreToolUse | Bash | Блокирует DROP TABLE, migrate:fresh и др. |
bash-mcp-guard | PreToolUse | Bash | Перенаправляет docker → mcp__docker__* |
secret-scanner | PreToolUse | Bash | Блокирует утечку API ключей |
critical-files-guard | PreToolUse | Edit|Write|MultiEdit | Защищает Caddyfile, .env.prod и др. |
auto-format | PostToolUse | Edit|Write|MultiEdit | Авто-форматирование pint/ruff/eslint |
cost-tracker | Stop | .* | Логирует стоимость сессии |
Как hooks регистрируются в settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node .claude/helpers/dangerous-command-guard.cjs" },
{ "type": "command", "command": "node .claude/helpers/bash-mcp-guard.cjs" },
{ "type": "command", "command": "node .claude/helpers/secret-scanner.cjs" }
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "node .claude/helpers/critical-files-guard.cjs" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "node .claude/helpers/auto-format.cjs" }
]
}
],
"Stop": [
{
"matcher": ".*",
"hooks": [
{ "type": "command", "command": "node .claude/helpers/cost-tracker.cjs" }
]
}
]
}
}
Exit codes:
exit 0 — OK, продолжить. exit 1 — предупреждение (stderr), но продолжить. exit 2 — БЛОК, Claude отменяет действие и видит сообщение из stderr.🛑 dangerous-command-guard.cjs
PreToolUse
Главный защитник. Блокирует команды, которые могут уничтожить данные или нарушить систему. Два уровня: HARD BLOCK (exit 2) и WARN (stderr, exit 0).
// dangerous-command-guard.cjs
'use strict';
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const cmd = (input.tool_input?.command || '').toLowerCase();
// УРОВЕНЬ 1: HARD BLOCK — exit 2, Claude видит ошибку
const hardBlocked = [
// Laravel
'artisan migrate:fresh', 'artisan db:wipe', 'artisan migrate:reset',
// SQL
'drop table', 'drop database', 'truncate table',
'delete from', 'delete *',
// Git
'git push --force', 'git push -f', 'git reset --hard',
'git checkout --', 'git restore',
'--no-verify',
// БД напрямую
'psql ', 'pg_dump', 'mysqldump',
// Docker volume
'docker volume rm', 'docker system prune',
// rm -rf
'rm -rf', 'rm -r /',
];
for (const pattern of hardBlocked) {
if (cmd.includes(pattern)) {
process.stderr.write(`[BLOCKED] Опасная команда: "${pattern}"\n`);
process.stderr.write(`Используйте MCP-инструменты или выполните вручную с CONFIRM_CRITICAL=yes\n`);
process.exit(2);
}
}
// УРОВЕНЬ 2: WARN — предупреждение, но продолжаем
const warned = [
'artisan migrate', 'npm ci', 'composer install --no-dev'
];
for (const pattern of warned) {
if (cmd.includes(pattern)) {
process.stderr.write(`[WARN] Потенциально опасная команда: "${pattern}". Убедитесь что это намеренно.\n`);
}
}
process.exit(0);
🐳 bash-mcp-guard.cjs
PreToolUse
Перехватывает прямые docker-команды и подсказывает правильный MCP-инструмент. Это нужно потому что docker не в PATH на Windows и Bash hook блокирует прямой вызов.
// bash-mcp-guard.cjs
'use strict';
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const cmd = (input.tool_input?.command || '');
const dockerMappings = [
{ pattern: /^docker exec/, mcp: 'mcp__docker__docker_exec' },
{ pattern: /^docker logs/, mcp: 'mcp__docker__docker_container_logs' },
{ pattern: /^docker ps/, mcp: 'mcp__docker__docker_list_containers' },
{ pattern: /^docker compose ps/, mcp: 'mcp__docker__docker_compose_ps' },
{ pattern: /^docker compose up/, mcp: 'mcp__docker__docker_compose_up' },
{ pattern: /^docker compose down/, mcp: 'mcp__docker__docker_compose_down' },
{ pattern: /^docker inspect/, mcp: 'mcp__docker__docker_inspect_container' },
{ pattern: /^docker stats/, mcp: 'mcp__docker__docker_container_stats' },
{ pattern: /^docker restart/, mcp: 'mcp__docker__docker_restart_container' },
];
for (const { pattern, mcp } of dockerMappings) {
if (pattern.test(cmd.trim())) {
process.stderr.write(
`[DOCKER→MCP] Используй MCP инструмент: ${mcp}\n` +
`Прямые docker команды заблокированы на Windows Server. MCP работает через Docker SDK.\n`
);
process.exit(2);
}
}
process.exit(0);
📁 critical-files-guard.cjs
PreToolUse
Защищает критически важные файлы от случайного изменения. При попытке редактирования — блок с объяснением. Обходится через переменную CONFIRM_CRITICAL=yes.
// critical-files-guard.cjs
'use strict';
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const filePath = input.tool_input?.file_path || input.tool_input?.path || '';
if (process.env.CONFIRM_CRITICAL === 'yes') process.exit(0);
const criticalPatterns = [
// Reverse proxy
/Caddyfile/, /proxy[\/\\].*\.json/,
// Системные
/.wslconfig/, /docker-compose\.production/,
// БД миграции (осторожно)
/database[\/\\]migrations[\/\\]/,
// Auth middleware
/Middleware[\/\\]Authenticate/,
// Env файлы
/.env\.production/, /.env\.prod/,
];
for (const pattern of criticalPatterns) {
if (pattern.test(filePath)) {
process.stderr.write(
`[PROTECTED] Файл "${filePath}" защищён от автоматического изменения.\n` +
`Для редактирования: set CONFIRM_CRITICAL=yes (PowerShell: $env:CONFIRM_CRITICAL="yes")\n`
);
process.exit(2);
}
}
process.exit(0);
🔑 secret-scanner.cjs
PreToolUse
Сканирует bash-команды на наличие API ключей, токенов и других секретов. Самое важное — блокирует
git add .env.// secret-scanner.cjs
'use strict';
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const cmd = input.tool_input?.command || '';
const secretPatterns = [
{ pattern: /sk-[a-zA-Z0-9]{48}/, name: 'OpenAI API key' },
{ pattern: /sk-ant-[a-zA-Z0-9]/, name: 'Anthropic API key' },
{ pattern: /AKIA[0-9A-Z]{16}/, name: 'AWS Access Key' },
{ pattern: /[0-9]{10}:[a-zA-Z0-9_-]{35}/, name: 'Telegram Bot Token' },
{ pattern: /Bearer\s+[a-zA-Z0-9._-]{20,}/, name: 'Bearer Token' },
{ pattern: /password\s*=\s*[^\s]{8,}/i, name: 'Password in command' },
{ pattern: /git add .*\.env/, name: '.env файл в git!', hard: true },
{ pattern: /git add -A/, name: 'git add -A (может включить .env)' },
];
for (const { pattern, name, hard } of secretPatterns) {
if (pattern.test(cmd)) {
process.stderr.write(`[SECRET] Возможная утечка: ${name}\n`);
process.stderr.write(`Команда: ${cmd.substring(0, 100)}\n`);
if (hard) process.exit(2);
}
}
process.exit(0);
✨ auto-format.cjs
PostToolUse
Автоматически форматирует файл после каждого изменения. PHP → Pint, Python → Ruff, TypeScript/Vue → ESLint. Никогда не блокирует (exit 0 всегда).
// auto-format.cjs
'use strict';
const { execSync } = require('child_process');
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const filePath = input.tool_input?.file_path || input.tool_input?.path || '';
function run(cmd) {
try { execSync(cmd, { stdio: 'pipe' }); }
catch (e) { /* format errors не критичны */ }
}
if (filePath.endsWith('.php')) {
// PSR-12 + declare(strict_types=1)
run(`./vendor/bin/pint "${filePath}"`);
}
else if (filePath.endsWith('.py')) {
// PEP 8 + imports sort
run(`ruff check --fix "${filePath}"`);
run(`ruff format "${filePath}"`);
}
else if (['.ts', '.vue', '.js'].some(ext => filePath.endsWith(ext))) {
// ESLint flat config + Prettier
run(`npx eslint --fix "${filePath}"`);
}
process.exit(0); // PostToolUse hooks НИКОГДА не должны блокировать
💰 cost-tracker.cjs
Stop
Запускается в конце каждой сессии. Рассчитывает стоимость по использованным токенам, пишет в JSONL файл. Алерт при превышении дневного бюджета.
// cost-tracker.cjs
'use strict';
const fs = require('fs');
const path = require('path');
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
const PRICES = {
'claude-opus-4-7': { input: 15, output: 75 }, // $ per 1M tokens
'claude-sonnet-4-6': { input: 3, output: 15 },
'claude-haiku-4-5': { input: 0.8, output: 4 },
};
const model = input.model || 'claude-sonnet-4-6';
const usage = input.usage || {};
const price = PRICES[model] || PRICES['claude-sonnet-4-6'];
const inputCost = (usage.input_tokens || 0) / 1_000_000 * price.input;
const outputCost = (usage.output_tokens || 0) / 1_000_000 * price.output;
const totalCost = inputCost + outputCost;
const logDir = path.join(require('os').homedir(), '.claude', 'logs');
const today = new Date().toISOString().slice(0, 7);
const logFile = path.join(logDir, `cost-${today}.jsonl`);
fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(logFile, JSON.stringify({
timestamp: new Date().toISOString(),
model, totalCost: totalCost.toFixed(4),
inputTokens: usage.input_tokens, outputTokens: usage.output_tokens
}) + '\n');
const threshold = parseFloat(process.env.COST_ALERT_THRESHOLD || '5');
if (totalCost > threshold) {
process.stderr.write(`[COST ALERT] Сессия: $${totalCost.toFixed(2)} (порог: $${threshold})\n`);
}
process.exit(0);
📥 Формат входных данных hook
Каждый hook получает JSON через stdin. Структура зависит от типа события:
// PreToolUse (Bash) — input.tool_input.command
{
"tool_name": "Bash",
"tool_input": {
"command": "php artisan migrate"
}
}
// PreToolUse (Edit/Write) — input.tool_input.file_path
{
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/file.php",
"old_string": "...",
"new_string": "..."
}
}
// Stop — содержит usage статистику токенов
{
"model": "claude-sonnet-4-6",
"usage": {
"input_tokens": 45000,
"output_tokens": 8000
}
}