Claude Code hooks: рецепты, exit codes и безопасность
Что такое hooks в Claude Code?
Hooks в Claude Code - пользовательские обработчики (shell-команды, HTTP-эндпоинты, MCP-инструменты, LLM-промпты, субагенты), которые Claude Code запускает в фиксированных точках жизненного цикла: до и после вызова инструмента, при старте и остановке сессии, перед компакцией. В отличие от инструкций в CLAUDE.md, хук выполняется детерминированно, а блокировка в PreToolUse действует даже в режиме bypassPermissions.
TL;DR
- -Хук запрещает действие двумя способами: exit 2 с причиной в stderr или exit 0 с JSON-решением в stdout. Exit 1 без JSON считается некритичной ошибкой, и вызов проходит.
- -PreToolUse-блокировка (exit 2 или permissionDecision: deny) сработала в режиме bypassPermissions в живой проверке на Claude Code 2.1.318. Таймаут или падение хука вызов не блокирует, если не задан onFailure: "block".
- -Stop-хук с exit 2 заставляет Claude продолжать работу. Хук обязан проверять stop_hook_active, иначе Claude Code сам прервёт серию после 8 продолжений подряд без вызова инструментов.
- -Plain-text stdout попадает в контекст Claude только на UserPromptSubmit, UserPromptExpansion, SessionStart и PostModelSwitch; на остальных событиях он уходит в debug-лог.
- -Хук запускается с правами пользователя. В режиме claude -p диалога доверия нет, поэтому хуки из чужого .claude/settings.json выполняются без вопроса.
Инструкция в CLAUDE.md - просьба: модель учтёт её с высокой вероятностью, но не всегда. Hook в Claude Code - код, который выполняется в заданной точке цикла каждый раз. Если правило «не трогать .env» должно работать без исключений, его место в PreToolUse-хуке, а не в тексте промпта.
Механизм простой: Claude Code передаёт хуку JSON на stdin, хук отвечает кодом выхода и, при желании, JSON на stdout. Сложности начинаются в деталях: exit 1 не блокирует, таймаут защитного хука пропускает команду, Stop-хук без проверки флага зацикливается, а JSON-решение, напечатанное в stderr, игнорируется. Ни одна из этих ошибок не выдаёт себя явно, и хук выглядит рабочим, пока не понадобится.
Статья о том, как устроены хуки, чем блокировать на каком событии, и о рецептах для защиты, автоматизации, контекста и уведомлений. Отдельный раздел посвящён безопасности самих хуков. Факты сверены с Hooks reference и Hooks guide на 6 ноября 2026 года, основные механизмы (блокировка в PreToolUse, Stop-gate, перезагрузка конфигурации) проверены живым запуском на Claude Code 2.1.318.
О происхождении рецептов. В пользовательском settings.json автора 23 обработчика на 17 событиях. 15 из них - обёртки одного стороннего приложения, 2 - вызовы сторонней утилиты памяти. Остальные 6 запускают собственные скрипты: ориентацию при старте, журнал активности, уведомления. Из них и взяты рецепты для разделов про контекст и уведомления.
Защитных PreToolUse-хуков в постоянной конфигурации автора нет: защита построена на permissions и git-хуке. Рецепты блокировки, ConfigChange и auto-approve взяты из документации, скрипты прогнаны на тестовом входе, а оба защитных рецепта к тому же проверены живым запуском. В тексте помечено, что откуда.
Как устроены hooks в Claude Code
События и matcher
Событий больше 30, и список растёт почти с каждым релизом, поэтому полный перечень лучше смотреть в таблице reference. Для рецептов из этой статьи хватает десятка:
| Событие | Когда срабатывает | Matcher фильтрует | Может блокировать |
|---|---|---|---|
SessionStart | старт, resume, /clear, компакция | startup, resume, clear, compact, fork | нет |
UserPromptSubmit | промпт отправлен | - | да |
PreToolUse | до вызова инструмента | имя инструмента | да |
PermissionRequest | показ запроса разрешения | имя инструмента | через decision |
PostToolUse | после успешного вызова | имя инструмента | нет |
Notification | Claude ждёт ввода или разрешения | тип уведомления | нет |
Stop | Claude закончил ответ | - | да (продолжить работу) |
SubagentStop | субагент закончил | тип агента | да |
PreCompact | перед компакцией | manual, auto | да |
ConfigChange | изменился файл настроек или skill | user_settings, project_settings и др. | да, кроме policy_settings |
Matcher читается по составу символов. *, пустая строка или отсутствие поля означает «всё». Если в строке только буквы, цифры, _, -, пробелы, , и |, это точный список: Edit|Write совпадёт ровно с двумя инструментами. Любой другой символ превращает matcher в JavaScript-regex без якорей, и Edit.* поймает заодно NotebookEdit; для точного совпадения нужен ^Edit$. Регистр важен.
Конфигурация и где она лежит
Конфигурация трёхуровневая: событие, группа с matcher, список обработчиков.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"timeout": 5 }
]
}
]
}
}
Хуки читаются из ~/.claude/settings.json, .claude/settings.json (в git), .claude/settings.local.json (не в git), managed-политики, hooks/hooks.json плагина и frontmatter skill или субагента. Одинаковый обработчик из нескольких файлов настроек выполняется один раз. Все подходящие обработчики события запускаются параллельно.
Типов обработчиков пять: command, http, mcp_tool, prompt и agent (последний помечен как experimental). Полезные поля, о которых гайды часто молчат:
if- фильтр в синтаксисе правил permissions, например"Bash(git *)"или"Edit(*.ts)". Работает только на tool-событиях (PreToolUse,PostToolUse,PostToolUseFailure,PermissionRequest,PermissionDenied). На любом другом событии хук сifне запустится никогда.args- если поле задано, команда запускается без shell (exec form). Помогает, когда ломаются кавычки.onFailure-"continue"по умолчанию или"block"(с v2.1.295). Без"block"упавший или зависший хук пропускает вызов.timeout- в секундах. По умолчанию 600 дляcommand,httpиmcp_tool, 30 дляprompt, 60 дляagent.
Вход и переменные окружения
Хук получает JSON на stdin. Общие поля: session_id, transcript_path, cwd, permission_mode, hook_event_name и ещё несколько служебных. Tool-события добавляют tool_name, tool_input и tool_use_id, UserPromptSubmit добавляет prompt. Stop получает stop_hook_active, last_assistant_message, background_tasks и session_crons; этот набор подтвердился в живом запуске.
Переменная CLAUDE_PROJECT_DIR указывает на корень проекта, через неё удобно ссылаться на скрипты. CLAUDE_ENV_FILE доступна на SessionStart, Setup, CwdChanged и FileChanged: строки, дописанные в этот файл через >>, станут переменными окружения сессии. Идентификатор сессии в окружение не передаётся. Встречающийся в сторонних скриптах CLAUDE_SESSION_ID в документации не описан, поэтому session_id надёжнее брать из stdin через jq.
Exit codes и JSON: как хук отвечает
Что значит каждый код выхода
| Что в stdout | Exit 0 | Exit 2 | Другой код (в т.ч. 1) |
|---|---|---|---|
| Валидный JSON-объект | успех, поля применяются | блок; поля читаются, но блок не отменяют | поля решают, код игнорируется |
| Битый JSON | некритичная ошибка, действие идёт дальше | блок, причина из stderr | некритичная ошибка |
| Текст или ничего | успех | блок, причина из stderr | некритичная ошибка, действие идёт дальше |
Для защитного хука главное в этой таблице - правая нижняя клетка. Скрипт, который при проблеме делает exit 1 по unix-привычке, ничего не блокирует: Claude Code покажет hook error с первой строкой stderr и выполнит вызов. Документация говорит прямо: если хук применяет политику, нужен exit 2.
Stdout при exit 0 Claude видит не всегда. Plain-текст добавляется в контекст только на UserPromptSubmit, UserPromptExpansion, SessionStart и PostModelSwitch. На остальных событиях он уходит в debug-лог. Stderr при exit 0 Claude не видит никогда. Текст, который хук добавляет в контекст, ограничен 10 000 символов: остаток Claude Code сохраняет в файл и подставляет путь с превью.
Что блокирует exit 2 на каком событии
| Exit 2 блокирует | Exit 2 не блокирует |
|---|---|
PreToolUse - вызов не выполняется | PostToolUse - инструмент уже отработал, stderr видит Claude |
UserPromptSubmit - промпт не уходит в модель | PermissionRequest - код игнорируется, нужен decision |
Stop, SubagentStop - Claude продолжает работу | Notification, Setup, InstructionsLoaded - код игнорируется |
PreCompact - компакция отменяется | SessionStart, SessionEnd, PostCompact - stderr видит только пользователь |
ConfigChange - изменение не применяется (кроме policy_settings) | StopFailure - вывод и код игнорируются |
TaskCreated, TaskCompleted, TeammateIdle, PostToolBatch | PostToolUseFailure, PermissionDenied |
Полная таблица по всем событиям, включая WorktreeCreate, Elicitation и PreModelSwitch, есть в разделе Exit code 2 behavior per event.
Три способа сказать «нет»
- Exit 2 + stderr. Самый простой вариант. Stderr становится причиной, Claude видит её как ошибку инструмента:
PreToolUse:Write hook error: [<команда хука>]: <ваш текст>. - Exit 0 +
hookSpecificOutput.permissionDecision. Только PreToolUse. Значенияallow,deny,askиdefer(последнее только в-p). ПлюсupdatedInput, чтобы переписать аргументы вызова. Объект заменяется целиком, а не сливается. - Exit 0 + верхнеуровневый
decision: "block"иreason. ДляUserPromptSubmit,PostToolUse,Stop,SubagentStop,ConfigChange,PreCompactи ещё нескольких событий. На PostToolUse он не отменяет результат:reasonпросто ставится рядом с выводом инструмента.
В живой проверке на Claude Code 2.1.318 (режим claude -p, --permission-mode bypassPermissions) первый и второй способы дали одинаковый результат: файл не изменился, причина дошла до модели. В варианте с exit 2 Claude процитировал её дословно и не пытался обойти запрет через Bash. Смешивать способы документация не советует: при exit 2 JSON-поля читаются, но блок не отменяют, а JSON, выведенный в stderr, считается обычным текстом причины.
Несколько хуков на одном вызове
Если несколько PreToolUse-хуков вернули разные решения, приоритет такой: deny > defer > ask > allow. Отрабатывают все хуки, и побочные эффекты остаются: хук-логгер запишет строку, даже если соседний хук вызов запретил. Два хука, которые возвращают updatedInput для одного вызова, ставить не стоит: порядок и слияние не гарантируются.
Хук с allow не обходит permissions: deny- и ask-правила из настроек проверяются независимо от ответа хука. В обратную сторону хук сильнее режима: deny из PreToolUse сработал и при bypassPermissions.
Типичные ошибки формата
- Поля не на том уровне.
permissionDecisionилиadditionalContextна верхнем уровне JSON вместоhookSpecificOutputмолча игнорируются. В debug-логе ищитеHook JSON output had unrecognized keys. - Эхо в профиле оболочки. Если
~/.zshrcили~/.bashrcчто-то печатает, stdout хука перестаёт начинаться с{, и JSON читается как текст. Признак в логе -Hook output does not start with {. Лечится обёрткойif [[ $- == *i* ]]; then ... fiвокруг вывода в профиле. - JSON из конкатенации строк. Путь с кавычкой ломает объект. Собирайте через
jq -n --arg. - Код выхода последней строки. Если скрипт кончается на
[ условие ] && действие, при ложном условии он вернёт 1, и каждый вызов будет даватьhook error. Последней строкой ставьтеexit 0.
Рецепты защиты (PreToolUse)
Скрипты этого раздела прогнаны сначала на тестовом stdin (echo '{...}' | ./hook.sh; echo $?), затем живьём: claude -p --debug-file на Claude Code 2.1.318, изолированная папка, только проектные настройки. Оба рецепта заблокировали то, что должны, и пропустили остальное. В постоянной конфигурации автора этих хуков нет.
Защита файлов от правки
Matcher Edit|Write, конфигурация как в примере выше. Скрипт:
#!/bin/bash
P=$(jq -r '.tool_input.file_path // empty')
[ -z "$P" ] && exit 0
case "$P" in
*.env|*/.env.*|*/.git/*|*/package-lock.json|*.pem|*.key)
echo "Заблокировано: $P в списке защищённых путей" >&2
exit 2 ;;
esac
exit 0
На тестовом входе /p/.env.local и /p/.env дают exit 2, src/a.ts и secrets_manager.py проходят. Паттерны привязаны к границе пути намеренно: подстрока вроде *secrets* блокировала бы и secrets_manager.py. Причина пишется в stderr обычным текстом. Распространённая ошибка в примерах из сети - выводить при exit 2 JSON вида {"decision":"deny"} в stderr: решением он не станет.
Живой запуск: модель попыталась дописать строку в .env через Edit, правка не прошла, и модель дословно процитировала причину из stderr. Правка notes.txt прошла. Фрагмент debug-лога:
Hooks: PreToolUse:Edit [...protect-files.sh] finished with status 2 (18ms)
Hook result has permissionBehavior=deny
Hook denied tool use for Edit
Хук закрывает только Edit и Write. Команду echo X > .env через Bash он не увидит, для этого нужен deny-rule в permissions или песочница.
Guard для Bash: force-push и rm -rf
Здесь ответ в JSON, чтобы показать второй способ. Matcher Bash, без if: с "if": "Bash(git push *)" проверка про rm запускалась бы только на командах, где есть git push.
#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
deny() {
jq -n --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0
}
if grep -Eq 'git +push\b.*(--force\b|--force-with-lease|-f\b)' <<<"$CMD"; then
deny "force-push запрещён политикой проекта"
fi
if grep -Eq '\brm +(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b' <<<"$CMD" &&
! grep -Eq '\brm +-[a-zA-Z]+ +/tmp/' <<<"$CMD"; then
deny "rm -rf вне /tmp запрещён"
fi
exit 0
На тестовом входе git push --force, git push -f, npm test && git push --force-with-lease, rm -rf ./build и rm -fr src получают deny. git push origin main и rm -rf /tmp/x проходят.
Живьём git push --force origin main и rm -rf victim заблокированы, папка осталась на месте, git status --short выполнился. У JSON-хука в логе стоит status 0, решение видно строкой ниже:
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 0 (16ms)
Hook PreToolUse (...guard-bash.sh) returned permissionDecision: deny (reason: force-push запрещён политикой проекта)
Hook denied tool use for Bash
Те же тесты показывают, почему такой guard не граница безопасности. Пропущены git push origin +main (force-push через + в refspec), rm -r -f src (флаги раздельно) и rm -rf /tmp/x && rm -rf src (исключение для /tmp сработало на всю строку). Переменные, eval и скрипты в файлах regex не разберёт вообще. Хук хорош как быстрый понятный отказ с объяснением для Claude. Жёсткую политику держат permissions.deny и песочница.
Хук или permissions.deny
permissions.deny | PreToolUse-хук | |
|---|---|---|
| Что проверяет | правило по инструменту и аргументам | любую логику на входном JSON |
| Сообщение для Claude | стандартный отказ | своя причина в stderr или JSON |
| Поведение при сбое | сбоя нет, правило статично | таймаут и падение пропускают вызов, если нет onFailure: "block" |
| Обход через другой инструмент | зависит от правил | хук видит только свой matcher |
| Работает в bypassPermissions | да, по документации permission modes | да, проверено на 2.1.318 |
Поле if в документации прямо названо best-effort: если Claude Code не может определить, какие команды выполнит Bash, хук запускается независимо от паттерна. Docs рекомендуют для жёсткого allow или deny использовать систему permissions, а не хук. Как устроены правила, разобрано в гайде по Claude Code.
Смежный приём: git-хук второго эшелона
Хук Claude Code видит текст Bash-команды. Git-хук reference-transaction видит сам переход ссылок, как бы его ни вызвали. В репозитории этого блога такой хук журналирует операции со refs/stash и блокирует удаление stash, в котором есть untracked-файлы (stash@{N}^3); осознанный обход - FORCE_STASH_DROP=1. Поводом стал реальный случай: в августе 2026 года git stash -u унёс исходники четырёх опубликованных статей. Это не hook Claude Code, но тот же принцип: правило проверяется на уровне, который агент не обходит перефразированием команды.
Рецепты после действий (PostToolUse, Stop)
Форматирование после правки
Базовый вариант из документации - одна строка: jq -r '.tool_input.file_path' | xargs npx prettier --write на matcher Edit|Write. Если форматтер должен сообщать об ошибке, нужен скрипт:
#!/bin/bash
F=$(jq -r '.tool_input.file_path // empty')
[ -z "$F" ] && exit 0
case "$F" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md) CMD=(npx prettier --write "$F") ;;
*.py) CMD=(ruff format "$F") ;;
*) exit 0 ;;
esac
if ! OUT=$("${CMD[@]}" 2>&1); then
echo "Форматирование $F не прошло: $OUT" >&2
exit 2
fi
exit 0
Почему exit 2: на PostToolUse он не откатывает правку (по документации, «the tool already ran»), но stderr уходит Claude, и модель может исправить синтаксис. При exit 1 ошибку увидит только пользователь, а при exit 0 никто. Скрипт расширяет однострочник из docs и проверен только синтаксически.
Stop-gate: тесты перед остановкой
#!/bin/bash
IN=$(cat)
[ "$(jq -r '.stop_hook_active // false' <<<"$IN")" = "true" ] && exit 0
if ! OUT=$(npm test --silent 2>&1); then
echo "Тесты падают, исправьте перед завершением:" >&2
tail -n 15 <<<"$OUT" >&2
exit 2
fi
exit 0
Механизм проверен живым запуском на 2.1.318 с более простым условием (наличие файла-флага вместо тестов). Хук сработал дважды. Первый вызов пришёл с stop_hook_active: false, хук вернул exit 2, Claude получил stderr, создал файл и ответил снова. Второй вызов пришёл с stop_hook_active: true, хук вернул 0, сессия завершилась.
Подводные камни Stop-gate:
- Stop срабатывает после каждого ответа Claude, даже после короткого уточнения, и не срабатывает при прерывании пользователем. Тяжёлый
npm testна каждом ответе дорог, поэтому разумно проверять, менялись ли файлы. - Без проверки
stop_hook_activeхук на невыполнимом условии зацикливается. Claude Code переопределяет блок после 8 продолжений подряд (кап настраивается переменнойCLAUDE_CODE_STOP_HOOK_BLOCK_CAP), но счётчик сбрасывается при каждом вызове инструмента. Агент, который чинит тесты, вызывает инструменты постоянно, и кап может не сработать. - Проверка
stop_hook_activeдаёт ровно одну попытку: если тесты всё ещё красные, второй Stop пройдёт. Нужно больше попыток - храните счётчик в файле поsession_id. onFailure: "block"на Stop не действует: Claude не может починить хук, который не запускается.
Альтернатива без скрипта - type: "prompt": модель возвращает {"ok": false, "reason": "..."}, и Claude продолжает работу. Это LLM-вызов на каждый Stop, со своей ценой и таймаутом 30 секунд. Встроенная команда /goal - то же самое в виде готового Stop-хука на сессию.
Журнал активности
Этот хук автор держит в постоянной конфигурации. PostToolUse пишет в JSONL, какие файлы читались и правились, чтобы следующая сессия знала контекст. В исходной версии фильтр по инструменту стоит внутри скрипта, а JSON-строка склеивается из переменных в echo. Ниже исправленный вариант: фильтр перенесён в matcher Read|Edit|Write|Grep|Glob, строка собирается через jq -nc, таймаут 3 секунды.
#!/bin/bash
set -euo pipefail
IN=$(cat)
TOOL=$(jq -r '.tool_name // empty' <<<"$IN")
FILE=$(jq -r '.tool_input.file_path // .tool_input.pattern // empty' <<<"$IN")
[ -z "$FILE" ] && exit 0
LOG="$CLAUDE_PROJECT_DIR/.claude/activity.jsonl"
jq -nc --arg ts "$(date -u +%FT%TZ)" --arg tool "$TOOL" --arg file "${FILE#"$PWD"/}" \
'{ts: $ts, tool: $tool, file: $file}' >> "$LOG"
if [ "$(wc -l < "$LOG")" -gt 1000 ]; then
tail -n 500 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
exit 0
Путь с кавычкой (src/a "q".ts) на тестовом входе записался корректно, а склейка строк на нём даёт невалидный JSON. Ротация держит файл в пределах тысячи строк. Файл стоит добавить в .gitignore.
Контекст и состояние
Ориентация при старте сессии
SessionStart - одно из четырёх событий, где plain-текст из stdout попадает в контекст. Автор использует его, чтобы Claude с первого сообщения знал ветку, последние коммиты и незакоммиченные файлы:
#!/bin/bash
git rev-parse --is-inside-work-tree &>/dev/null || exit 0
echo "Branch: $(git branch --show-current 2>/dev/null || echo detached)"
echo "Recent commits:"; git log --oneline -5
CHANGES=$(git status --porcelain | head -15)
if [ -n "$CHANGES" ]; then echo "Uncommitted changes:"; echo "$CHANGES"; fi
exit 0
В конфигурации стоит "timeout": 5, вне git-репозитория хук молчит. Второй SessionStart-хук читает журнал из предыдущего рецепта, и два хука связываются через файл: один пишет, другой читает.
#!/bin/bash
LOG="$CLAUDE_PROJECT_DIR/.claude/activity.jsonl"
[ -f "$LOG" ] || exit 0
SINCE=$(date -u -v-7d +%FT%TZ 2>/dev/null || date -u -d '7 days ago' +%FT%TZ)
echo "Most touched files (7d):"
jq -r --arg s "$SINCE" 'select(.ts >= $s) | .file' "$LOG" |
sort | uniq -c | sort -rn | head -15
Вывод держите коротким: всё, что хук печатает, расходует контекст каждой сессии. О балансе между полезным и лишним контекстом подробнее в статье про context engineering.
Повторная инъекция после компакции
Компакция сжимает разговор, и детали, данные только в чате, теряются. Корневой CLAUDE.md после /compact перечитывается с диска (подробнее в гайде по CLAUDE.md). Для динамического контекста документация предлагает SessionStart с matcher compact:
{ "hooks": { "SessionStart": [ { "matcher": "compact", "hooks": [
{ "type": "command",
"command": "echo 'Reminder: use Bun, not npm. Current sprint: auth refactor.'; git log --oneline -5" }
] } ] } }
В пару к нему можно поставить PreCompact: хук сохраняет состояние задачи в файл до сжатия, а SessionStart с compact печатает его после. Plain-текст stdout у PreCompact становится инструкцией для самого резюме компакции. Рецепт взят из документации и в живой сессии не проверялся.
Уведомления без спама
Базовый пример из документации - osascript -e 'display notification ...' на событии Notification. На macOS он молча не работает, пока у Script Editor нет разрешения на уведомления. Один раз запустите osascript -e 'display notification "test"' в терминале, затем включите Script Editor в System Settings > Notifications.
Если повесить уведомление и на Notification, и на Stop, в конце хода часто приходят два уведомления подряд. У автора это решено lock-файлом на сессию: уведомление отправляется один раз, а UserPromptSubmit-хук снимает блокировку. Исходная версия брала ключ из CLAUDE_SESSION_ID, которого в документации нет (судя по всему, срабатывал запасной вариант $PPID), и подставляла текст в AppleScript без экранирования. Исправленный вариант берёт session_id из stdin и передаёт текст через argv:
#!/bin/bash
# notify-once.sh "<текст>" - на событиях Notification и Stop
SID=$(jq -r '.session_id // "unknown"')
LOCK="${TMPDIR:-/tmp}/claude-notified-$SID"
[ -f "$LOCK" ] && exit 0
touch "$LOCK"
osascript -e 'on run argv' \
-e 'display notification (item 1 of argv) with title "Claude Code"' \
-e 'end run' "$1"
Сброс на UserPromptSubmit занимает одну строку: rm -f "${TMPDIR:-/tmp}/claude-notified-$(jq -r '.session_id')". Передача через argv важна, даже если сейчас текст константный: как только в уведомление попадёт имя файла или фрагмент ответа, подстановка в строку AppleScript превращается в инъекцию.
Аудит и политика
Аудит изменений конфигурации. ConfigChange срабатывает, когда во время сессии меняется файл настроек или skill. Пример из документации на matcher user_settings|project_settings|skills:
jq -c '{timestamp: now | todate, source: .source, file: .file_path}' >> ~/claude-config-audit.log
Exit 2 или decision: "block" не дают изменению вступить в силу, кроме источника policy_settings: managed-настройки применяются всегда. Блокировка проходит тихо, ни пользователь, ни Claude сообщения не видят, остаётся только строка в debug-логе.
Узкий auto-approve. PermissionRequest-хук может ответить на запрос разрешения вместо человека. Пример из документации снимает диалог только для ExitPlanMode:
{ "hooks": { "PermissionRequest": [ { "matcher": "ExitPlanMode", "hooks": [
{ "type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'" }
] } ] } }
Exit 2 на этом событии не работает, решение передаётся только через decision.behavior. Matcher держите узким, * здесь превращает хук в автоматическое «да» на всё. Deny-правила из permissions такой хук не обходит. Оба рецепта взяты из документации и проверены только на тестовом входе.
Политика для команды. В managed settings allowManagedHooksOnly оставляет только хуки администратора, disableAllHooks из пользовательских и проектных настроек их не отключает, а allowedHttpHookUrls и httpHookAllowedEnvVars ограничивают, куда HTTP-хуки могут отправлять данные и какие переменные подставлять в заголовки.
Безопасность самих хуков
Command-хук выполняет shell-команды с полными правами пользователя: читает, меняет и удаляет всё, к чему у пользователя есть доступ. Документация выносит это предупреждение в отдельный блок, и из него вытекает несколько правил.
Доверие к папке. В интерактивной сессии Claude Code не запускает хуки ни из одного файла настроек, включая пользовательский, пока не принят диалог доверия к папке. В режиме claude -p и в SDK диалога нет: папка считается доверенной, и хуки из .claude/settings.json чужого репозитория выполняются сразу. Перед скриптовым прогоном -p по чужому коду прочитайте .claude/, используйте --bare или отключите хуки на запуск через --settings '{"disableAllHooks": true}'.
Входные данные. tool_input формирует модель, а модель могла прочитать что угодно, включая промпт-инъекцию в файле или ответе MCP-сервера (подробнее о таких атаках). Переменные всегда в кавычках ("$P"), никакого eval и sh -c на содержимом команды, проверка путей на .., абсолютные пути к скриптам.
Режимы отказа. По умолчанию защитный хук работает в режиме fail-open:
| Ситуация | Что происходит с вызовом | Как закрыть |
|---|---|---|
| Хук завершился с exit 1 | проходит | exit 2 или JSON deny |
| Таймаут PreToolUse-хука | проходит | короткий timeout + onFailure: "block" |
| Скрипт не найден или не запустился | проходит | onFailure: "block" |
Нет jq | jq падает, переменная пустая, скрипт выходит с 0 | проверка command -v jq в начале, onFailure: "block" |
| Windows без Git Bash | PreToolUse и PermissionRequest хук блокирует при любом коде кроме 0 и 2 | явный shell и onFailure |
С onFailure: "block" (v2.1.295 и новее) таймаут, падение, отсутствие скрипта или невалидный JSON делают то же, что exit 2 на этом событии. Ошибка в логе выглядит как failed; blocking because onFailure is "block". Тестировать деградацию стоит явно: удалить jq из PATH, поставить sleep больше таймаута и посмотреть, что произойдёт с вызовом.
Чистый stdout и логи. Профиль оболочки не должен ничего печатать в неинтерактивном режиме. Журналы хуков не должны содержать содержимое команд с токенами, а файлы журналов нужно ротировать.
Хуки не заменяют permissions и песочницу. Хук видит один matcher и один вызов, regex по тексту команды обходится, if работает в режиме best-effort. Хук хорошо дополняет статичные правила, но не подменяет их.
Отладка
/hooks- просмотр всех настроенных хуков, только чтение.claude --debug-file /tmp/claude.log- журнал выполнения хуков в известный файл, параллельноtail -f. Без флага:claude --debug(лог в~/.claude/debug/<session-id>.txt) или/debugпосреди сессии.Ctrl+O- transcript view: блокирующие ошибки с причиной и некритичныеhook error.- Тест без Claude Code:
echo '{"tool_input":{"file_path":"/p/.env"}}' | ./protect-files.sh; echo $?.
В debug-логе полезны пять строк. Hook output does not start with { значит, что вывод прочитан как текст: при пустом выводе и exit 0 это норма, при попытке вернуть JSON это ошибка формата. Hook JSON output had unrecognized keys указывает на поля не на своём уровне. Строки Hooks: PostToolUse:Write [...] finished with status 0 (31ms) (с v2.1.296) показывают код и длительность каждого хука, по ним ищут медленные. Но status 0 не означает «пропустил»: JSON-хук отказывает при exit 0, и его решение видно в строке returned permissionDecision: deny (reason: ...). timed out after <N>ms означает таймаут.
Перезапуск после правки хуков обычно не нужен. В живой проверке модель посреди сессии записала в .claude/settings.json новый PreToolUse-хук, в debug появилась строка Settings changed from projectSettings, updating app state, и следующий Write уже был заблокирован. Документация оговаривает, что вотчер изредка пропускает изменения; тогда помогает перезапуск. Хуки из плагинов и правку ~/.claude/settings.json в этой проверке не тестировали.
Таймауты по ролям
Дефолт 600 секунд означает, что зависший command-хук без явного timeout может держать сессию десять минут. Таймаут - часть контракта хука, его стоит задавать для каждой роли:
| Роль хука | Событие | Таймаут | Источник |
|---|---|---|---|
| Журнал активности | PostToolUse | 3 с | конфигурация автора |
| Ориентация и контекст | SessionStart | 5 с | конфигурация автора |
| Защитный guard | PreToolUse | 5 с + onFailure: "block" | рекомендация по docs |
| Сохранение памяти | Stop | 30 с | конфигурация автора |
| Сохранение состояния | PreCompact | 60 с | конфигурация автора |
| Код-ревью перед остановкой | Stop | 900 с | хук одного из плагинов |
| Проверка промпта | UserPromptSubmit | 30 с по умолчанию | docs |
| Финализация | SessionEnd | 1,5 с общий бюджет, до 60 с | docs |
Для UserPromptSubmit дефолт короче не случайно: хук выполняется перед каждым промптом, и пока он не завершится, модель не начнёт работу. SessionEnd делит общий бюджет 1,5 секунды на все хуки; бюджет растёт до 60 секунд, если в настройках задан больший timeout.
Что выбрать: хук, skill или CLAUDE.md
| Задача | Механизм | Почему |
|---|---|---|
Запретить правку .env | permissions.deny + PreToolUse-хук | детерминированно, с понятной причиной |
| Форматировать после каждой правки | PostToolUse-хук | происходит всегда, без участия модели |
| Не завершать работу с красными тестами | Stop-хук | модель не может «забыть» проверку |
| Конвенции кода и архитектура | CLAUDE.md | контекст, а не механическое правило |
| Процедура релиза, чеклист ревью | Skill | подгружается по задаче, не занимает контекст постоянно |
| Изолированная подзадача с узкими правами | Субагент | свой контекст и набор инструментов |
Хуки можно объявлять и во frontmatter skill или субагента: тогда они действуют только пока работает этот компонент. Как это устроено у субагентов и событий SubagentStart и SubagentStop, разобрано в статье о субагентах Claude Code. Обзор skills, agents и memory - в отдельном гайде.
Итоги
- Блокируйте через
exit 2со stderr или черезexit 0с JSON в stdout.exit 1не блокирует. - Каждому защитному хуку задавайте короткий
timeoutиonFailure: "block", иначе таймаут или падение пропустит вызов. - В Stop-хуке первой строкой проверяйте
stop_hook_active. - Собирайте JSON через
jq -n --arg, проверяйте хук на тестовом stdin до подключения и смотрите--debug-fileпосле. - Хук дополняет permissions и песочницу, но не заменяет их. Regex по тексту команды - удобный отказ с объяснением, а не граница безопасности.