Вот проблема с указанием Claude что-то делать в файле CLAUDE.md: это просьба, а не гарантия. Можно написать "always format after editing", и Claude обычно послушается. Обычно. Но на длинном прогоне, за которым вы не наблюдаете, «обычно» недостаточно. Хук это исправляет. Хук — это детерминированный код, который выполняется в фиксированной точке цикла, поэтому он может гарантировать поведение, а не надеяться на него. Он превращает правило из «Claude обычно слушается» в «Claude не может пропустить».
Это весь питч. Теперь посмотрим, как это реально работает.
События хуков
Claude Code вызывает около 30 событий хуков за сессию. Знать их все не нужно. Есть небольшая горсть, к которой вы будете тянуться снова и снова, и они соответствуют точкам в агентном цикле, где хотелось бы вмешаться.
Вот как они сидят в цикле. Сессия начинается, приходят промпты, вызываются инструменты, и ход в итоге заканчивается. У каждого из этих моментов есть хук, на который можно повесить код.
Те, что стоит знать:
- PreToolUse срабатывает до вызова инструмента. Это ваш примитив принуждения. Тот, который может остановить что-то до того, как оно случится.
- PostToolUse срабатывает после успешного вызова инструмента. Обычно сюда вешают автоформатирование или автолинтер.
- Stop срабатывает, когда Claude хочет завершить свой ход. Можно отказать и сказать «нет, ты ещё не закончил», если какое-то условие не выполнено. Есть парный SubagentStop на случай завершения субагента.
- PreCompact и PostCompact срабатывают до и после компактизации.
- InstructionsLoaded срабатывает, когда загружается CLAUDE.md или файл правил. Удобно для аудита того, что реально попало в контекст.
- SessionStart срабатывает в начале и готовит окружение. Используйте источник startup, если хотите только свежие старты.
Одна вещь, на которой все спотыкаются: чтобы повторно внедрить контекст после компактизации, не используйте PostCompact. Используйте SessionStart с матчером compact. Именно он реально возвращает свой вывод обратно в беседу.
PreToolUse: возврат решения в виде JSON
PreToolUse — место настоящей силы, потому что он может заблокировать вызов инструмента до его выполнения. Способ ответить Claude — напечатать JSON и завершиться с нулем. Ключевое поле — permissionDecision, и оно принимает одно из трёх значений:
- allow — пропустить вызов
- deny — остановить вызов
- ask — вернуть на решение пользователю
Технически есть четвёртое значение, defer, но оно применяется только к неинтерактивным запускам с -p, где вызывающий процесс приостанавливает инструмент и возобновляет его позже. К нему вы будете тянуться редко.
Форма выглядит так:
```
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "...",
"updatedInput": {
"command": "..."
}
}
}
```
Обратите внимание на updatedInput. Вместо блокировки вызова его можно переписать. Так можно вырезать секрет из bash-команды и всё равно дать ей выполниться. Одна загвоздка: updatedInput заменяет весь объект входных данных, так что нужно вернуть обратно поля, которые вы не меняете, иначе вы их потеряете.
Коды выхода — для хуков, которые не возвращают JSON
Не каждому хуку нужно говорить на JSON. Для более простых хуков хватает кодов выхода. Важны три числа.
- 0 — успех. Если стандартный вывод — JSON, Claude его разберёт. Простой текст игнорируется на большинстве событий, но на SessionStart, UserPromptSubmit и UserPromptExpansion простой текст добавляется в контекст. Именно так работает хук-сохранитель состояния.
- 2 — блокирующая ошибка. Стандартная ошибка подаётся Claude как контекст. Это блокирующий код выхода почти везде.
- Всё остальное — неблокирующее. Стандартная ошибка логируется, и Claude продолжает.
Та, на которой все ловятся, — это код выхода 1. Он ощущается как ошибка, но не блокирует. Claude всё равно выполняет команду. Так что если хотели что-то остановить, выходите с 2, а не с 1.
Ещё пара тонкостей. Код 2 может заблокировать даже Stop — так Claude говорят, что он не закончил. Но PostToolUse срабатывает уже после выполнения инструмента, так что блокировать там слишком поздно, чтобы остановить вызов, хотя текст обратно Claude передать ещё можно. А несколько событий вообще игнорируют блокировку, например Notification и SessionStart. Они покажут вашу стандартную ошибку и продолжат как ни в чём не бывало.
Настоящее ограждение: редактировать вместо блокировки
Соберём всё вместе на практичном примере. Скажем, хотите ограждение PreToolUse на инструменте Bash. Матчер выбирает инструмент для наблюдения, а необязательное условие if может сузить его до конкретной команды.
Очевидный ход — вернуть deny и остановить опасный вызов. Это хорошо. Но менее известный и более интересный ход — вернуть updatedInput, чтобы переписать вызов. Так можно вырезать секрет из команды и всё равно дать ей выполниться, вместо простого отказа.
Вот как это выглядит на практике. Claude просят выполнить команду, содержащую живой на вид секрет. Хук перехватывает её, замечает паттерн sk_live_ и подменяет его на плейсхолдер до того, как команда вообще выполнится.
Команда всё равно выполнилась. Работа всё равно сделана. Но секрет дальше не прошёл. В этом разница между блокировкой и редактированием, и это то, что хук может обеспечивать каждый раз.
Сохранение состояния между компактизациями
Ещё один паттерн, который стоит настроить. Когда Claude компактизирует длинную беседу, он отбрасывает много деталей. Хук SessionStart с матчером compact выполняется сразу после компактизации. Пусть он печатает короткое резюме файлов, над которыми вы работали. Это резюме возвращается в контекст, так что Claude продолжает с того места, где остановился, вместо холодного старта.
Завершая
Хуки превращают правило, которому Claude обычно следует, в правило, которому он следует всегда. Идите дальше автоформатирования: охраняйте инструменты через PreToolUse, закрывайте ход через Stop и сохраняйте состояние между компактизациями. Настройка требует немного усилий заранее, но окупается в первый же раз, когда ловит что-то на прогоне, за которым вы даже не наблюдали.