3 ловушки hooks в Claude Code: exit 1, таймаут и .env
Я неделю не понимал, почему хук не блокирует правки .env. Оказалось - exit 1 в Claude Code не останавливает действие. Только exit 2 работает как стоп.
Поставил себе хук на PreToolUse против любых правок файлов вида .env, .env.local, .env.production. В JSON-конфиге всё аккуратно: matcher на Edit|Write|MultiEdit, shell-команда вытаскивает путь через jq, грепит по шаблону, на совпадении возвращает exit 2 с понятным сообщением. Claude должен был получать stderr и пасовать. Не пасовал. Раз в неделю в .env уезжали то лишний токен, то переменная с дублирующим именем, переписывающая рабочую. Каждый раз я думал, что просто забыл хук обновить. Не забыл. Хук падал на пустом входе, jq возвращал exit 1, Claude Code видел «не блок» и спокойно продолжал.
Дальше - что я узнал про exit-коды в hooks Claude Code, три капкана которые ломают всю защиту незаметно для тебя, и шаблон теста, который ловит такие баги до коммита. Никаких теорий жизненного цикла - только то, что реально стреляет на практике.
Чёрная магия exit-кодов: почему exit 1 не блокирует
Самая первая вещь, которую стоит зафиксировать в голове - в Claude Code exit-коды работают не как в обычной shell-логике. По POSIX-конвенции exit 0 это успех, любой ненулевой - ошибка. В Claude Code exit 1 ошибкой считается тоже, но эта ошибка не блокирует действие модели. Блок даёт только exit 2.
Вот разбивка по каждому варианту:
Если ты приходишь из мира bash и DevOps, exit 1 для тебя автоматически = «вызывающий процесс должен остановиться». Здесь не должен. Это первое, что я бы повесил себе на стену перед написанием первого хука.
Проверить это можно за минуту прямо в терминале. Берёшь JSON, который Claude Code прислал бы хуку, и подаёшь его на stdin своей команды:
Ожидаешь увидеть в stderr BLOCKED .env edit и exit code: 2. Если получил exit code: 0 - регекс не сработал. Если получил exit code: 1 - где-то падает jq или grep на неожиданном входе. Это нормальный режим разработки хуков: написал команду, прогнал в терминале с фейковым stdin, увидел корректный код, и только потом катишь в settings.json.
Ниже разберу три ловушки по очереди. Все три встречаются почти в каждом первом хуке, который пишут с нуля.
Молчаливый exit 1: хук падает, а Claude этого не замечает
Самая частая и самая болезненная. Хук падает на технической ошибке (нет файла, jq не распарсил пустой JSON, grep не нашёл нужный шаблон в шаблонной команде), shell возвращает exit 1, Claude Code считает это «хук просто не справился» и продолжает действие модели как ни в чём не бывало. Никакой обратной связи Клоду не идёт. Ты смотришь на лог, видишь «hook failed», но действие уже выполнено.
У меня это выглядело так. Хук-защитник .env был написан с прямым доступом к полю через jq -r '.tool_input.file_path'. Если Claude Code присылал событие, где tool_input пустой или поля file_path нет (а такое бывает на Read/Glob-операциях, если matcher настроен слишком широко), jq возвращал пустую строку или падал с ошибкой. exit code хука уходил в 1, Claude продолжал, файл редактировался. Я неделю думал, что хук работает.
Что лечит - две строки кода. Во-первых, проверка на null до основной логики. Во-вторых, явный || exit 0 в конце команды, чтобы любой неожиданный сценарий проваливался в безопасный exit 0, а не в нестабильный exit 1.
Разница в трёх местах. // empty в jq - возвращает пустую строку вместо ошибки, если поля нет. [ -z "$FILE" ] && exit 0 - явный безопасный выход на пустом входе. || exit 0 в конце - страховка от любого падения внутри логики. Это правило безопасной разработки хуков, и я бы поставил его в шаблон по умолчанию для всех PreToolUse-запретов.
Если интересно копнуть глубже в саму механику событий - у меня есть полный гайд по 31 событию хуков Claude Code с разбором 4 типов обработчиков (Command, HTTP, Prompt, Agent), 7 готовыми примерами под копипаст и тестом из 5 вопросов «когда правило идёт в hook».
60 секунд на хук: почему eslint вылетает на больших файлах
Стандартный таймаут для одного хука в Claude Code - 60 секунд. Кажется, что с запасом: что такое 60 секунд для shell-команды? У меня был хук на PostToolUse с матчером на Edit|Write|MultiEdit, который запускал npx eslint --fix "$FILE". На небольших файлах работало моментально. На больших файлах (1000-3000 строк типичной React-компоненты с кучей импортов) eslint считал свои правила по 8-12 секунд.
Сам по себе один файл проходил. Проблема всплыла, когда Claude писал серию правок подряд - правил три-четыре файла за минуту. Хуки начали накапливаться в очереди, ESLint крутился по 10 секунд на каждый, общая длина обработки переваливала за минуту. Claude Code молча убивал часть хуков по таймауту. Я заметил это, только когда в лог вышло hook timed out, а на проде через день обнаружил несколько файлов с длинным тире, которое должно было блокироваться предыдущим хуком №4.
Лечится одной строкой. Любую тяжёлую работу выносишь в фон через & без ожидания результата:
Конкретно (npx eslint --fix "$FILE" 2>/dev/null &) запускает eslint в подоболочке в фоне, stderr выкидывается в /dev/null, чтобы не засорять лог. Хук завершается с exit 0 за миллисекунды. Eslint крутится своим темпом, и если упадёт - ничего не сломает.
Логика тут такая. PostToolUse + exit 2 нужен только для финальной проверки результата (типа «после редактирования файла убедись, что в нём нет длинного тире, иначе блок»). Тяжёлое форматирование - это не проверка, это правка постфактум. Для правок exit 2 не нужен, нужен фон.
Если хочешь жёсткую блокирующую проверку - оставляй её синхронной и держи в рамках 2-3 секунд. Всё что дольше - в фон. Это правило экономит дни отладки.
Невидимый .env: почему OPENAI_API_KEY не доезжает до хука
Этот баг я ловил полдня. Сделал хук на PostToolUse, который после изменений в .env.example должен был дёргать curl на сервис аудита с моим OPENAI_API_KEY. Ключ у меня лежит в проектном .env (а не в ~/.zshrc - я не люблю смешивать секреты разных проектов). Запускаю хук - получаю 401 от сервиса. Лезу в shell - переменной нет.
Объяснение простое и неочевидное. Хук запускается под shell от родительского процесса Claude Code. Этот shell наследует переменные окружения из твоего .zshrc или .bashrc, плюс то, что Claude Code пробрасывает явно. Проектный .env он не читает: это файл приложения, shell его игнорирует. Если твой хук зависит от чего-то, что лежит в .env - надо это явно сделать самому в shell-команде:
Ключевые куски. cd "$PWD" - переходим в текущую директорию проекта (если хук стартовал откуда-то ещё, типа из ~/.claude глобального уровня). set -a && source .env && set +a - читаем .env так, чтобы переменные стали доступны последующим командам в этой строке. Дальше curl уходит с правильным заголовком авторизации.
Особенно больно в монорепо, где у разных пакетов свои .env. В таких случаях я держу в начале каждого хука явный cd к корню репозитория через git:
Это даёт стабильное поведение независимо от того, откуда хук запустился. Один раз потратил час, разбираясь, почему в монорепо хуки apps/web тянут .env пакета apps/api. Ответ оказался банальным: я не делал явный cd, Claude Code запускал хук из текущей рабочей директории, а она была не та, что я ожидал.
Шесть прогонов в терминале вместо недели отладки
Все три ловушки выше - воспроизводятся на стенде до того, как ты добавишь хук в settings.json. Это та практика, которая отделяет «у меня вечно хуки чудят» от «у меня хуки железно работают».
Каждый хук в Claude Code это shell-команда. У неё есть стандартный stdin (JSON-объект от Claude Code), stdout (которое возвращается Клоду как контекст), stderr (которое возвращается Клоду как причина блокировки на exit 2), и exit code. Все четыре можно эмулировать с помощью одной строки в bash:
Шесть сценариев, которыми я всегда прогоняю каждый новый хук:
- Базовый позитив - вход, на котором хук должен сработать (например {"tool_input":{"file_path":".env"}} для защиты .env). Ожидаешь нужный stderr и нужный exit code.
- Базовый негатив - вход, на котором хук должен пропустить (например {"tool_input":{"file_path":"src/index.ts"}} для того же хука). Ожидаешь exit 0 и пустой stderr.
- Пустой tool_input - вход {"tool_input":{}}. Хук должен молча выйти с exit 0, не упасть с exit 1 на jq.
- Полностью пустой JSON - вход {}. То же самое: молчаливый exit 0.
- Битый JSON - вход not-a-json. Хук должен выйти с exit 0 (потому что мы добавили || exit 0 в конце), а не уронить очередь.
- Файл с пробелами в имени - например {"tool_input":{"file_path":"my docs/.env"}}. Хук должен корректно обработать пробелы (поэтому пути всегда в двойных кавычках).
Прогон занимает минут пять. Возвращает обратно недели отладки.
Скрипт-обёртку держу прямо рядом с .claude/settings.json:
Используется так:
На выходе видишь, что хук делает в каждом сценарии. Я в первый месяц работы с hooks сэкономил так дни. Сейчас просто не пишу хуки без этого скрипта.
Тест пяти вопросов: какие правила реально переехать в хук
Не каждое правило стоит превращать в хук. Я для себя свёл выбор к простому тесту из пяти вопросов. Если хотя бы три ответа «да» - правило идёт в хук. Меньше трёх - остаётся в текстовом контракте с моделью.
Тест:
- Это правило про действие, а не про контекст? («не запускай rm -rf» - действие. «у меня TypeScript-проект» - контекст.)
- Это правило про безопасность, деньги или данные? («не трогай .env», «не пушь ключи в публичный репозиторий».)
- Можно проверить через grep, regex или exit code? («после Edit .md проверь, что нет длинного тире».)
- Если правило нарушится, последствия будут болезненно отменять? («force push в main» - сложно откатить. «забыл точку в конце» - легко.)
- Я уже видел, как Claude забывал это правило хотя бы один раз?
Пример «не используй длинное тире, только дефис». Действие? Да. Безопасность? Нет. Проверяется grep? Да. Сложно отменить? Нет. Видел нарушение? Да. Три из пяти - в хук.
Контр-пример «у меня вайб-кодеры, не разработчики, пиши без сложных терминов». Действие? Скорее контекст. Безопасность? Нет. Проверяется регексом? Слишком сложно. Сложно отменить? Нет. Видел нарушение? Да. Один из пяти - остаётся в CLAUDE.md.
У меня через тест прошли 5 правил из 80 строк CLAUDE.md. Они переехали в хуки. Остальные 75% остались в текстовом файле. Это контекст проекта, тон, маршрутизация. Хуки решают действия.
Если правило остаётся в CLAUDE.md - у меня есть готовый шаблон CLAUDE.md и 6 правил под копипаст с разбором, какие куски реально читаются моделью, а какие висят балластом.
Что я бы сделал на твоём месте
Если у тебя сейчас не было ни одного хука - не нужно тащить себе сразу 7 готовых. Возьми один блокирующий и один форматирующий, доведи их до железной работы, потом докидывай по одному.
Моя последовательность для нового проекта:
- Первый хук - защита .env через PreToolUse + exit 2 с null-проверкой и || exit 0 в конце. У меня этот один хук убрал почти все инциденты с .env за четыре месяца.
- Второй хук - блокировка длинного тире и других «ИИ-маркеров» через PostToolUse + exit 2 с проверкой по UTF-8 байтам