Мастер Claude · Блок 5. Hooks

Урок 44 из 93 · ~10 мин чтения

Exit codes и JSON-вывод из hooks

Урок пока закрыт

Курс проходится последовательно.

К текущему уроку

Почему это важно именно вам

Представьте: Claude помогает вам разбирать папку с документами. В какой-то момент он решает, что файл «устарел» и собирается его удалить. Файл называется договор_2021_финал.docx — и вы, может быть, сами не помните, нужен ли он. Но удаление необратимо. Было бы хорошо, если бы что-то остановило Клода прямо в этот момент — до того, как действие совершилось.

Или другой сценарий: вы настроили агента, который каждую ночь обрабатывает отчёты. В половине случаев он пишет в папку, куда не должен заходить — в архив прошлого квартала, который уже закрыт. Вы узнаёте об этом только утром, когда разбираете, что пошло не так. Хотелось бы, чтобы система сама проверяла перед каждым шагом: «я вообще имею право это делать?»

Третий сценарий, более тонкий. Вы хотите, чтобы Claude каждый раз, когда собирается отправить письмо от вашего имени, добавлял к своим инструкциям уточнение: «напомни себе проверить тон — это идёт руководителю, не коллеге». Не хочется прописывать это в CLAUDE.md навсегда — только для конкретного класса действий, динамически.

Всё это — задачи для hooks с грамотным управлением exit codes и JSON-ответами. Именно сейчас, в фазе 5 курса, мы разбираем, как hook не просто выполняется, но и общается с Claude на языке, который тот понимает.


Что это такое

Hook — это скрипт, который запускается до или после того, как Claude что-то делает. Мы разобрали это в предыдущих уроках. Но скрипт — не немой исполнитель. Он может ответить Claude на специальном языке: «продолжай», «стоп», «продолжай, но учти вот это». Этот язык состоит из двух частей: exit code и JSON в стандартном выводе.

Аналогия из жизни: вы подаёте заявку на командировку. Финансовый отдел может ответить тремя способами. Первый — молча подписать и вернуть. Второй — вернуть с красным штампом «отказано: превышен лимит». Третий — подписать, но дописать внизу от руки: «обратите внимание: гостиница должна быть согласована отдельно». Claude ведёт себя точно так же в зависимости от того, что вернул hook.

Exit code — это число, которое скрипт возвращает в момент завершения. В Unix-мире это стандарт: 0 означает «всё хорошо», не-ноль — что-то пошло не так. Claude Code использует это разграничение, но добавляет своё значение для числа 2.

Три значения, которые важны

0 — успех. Claude смотрит, есть ли JSON в stdout. Если есть — читает и применяет инструкции из него. Если нет — просто продолжает. 2 — блокировка. Claude останавливает действие и показывает пользователю текст из stderr. Это жёсткое «нет». Любое другое число (1, 3, 127...) — ошибка выполнения самого хука. Claude не блокируется, но записывает произошедшее в лог. Действие продолжается.


Как работает на практике

Разберём по порядку: сначала блокировка, потом управление через JSON.

Блокировка через exit code 2

Допустим, вы хотите, чтобы Claude никогда не удалял файлы из папки ~/Documents/Архив/. Это священная территория. Создаёте hook-скрипт guard_archive.sh:

#!/usr/bin/env bash
# Читаем входные данные от Claude (JSON через stdin)
INPUT=$(cat)

# Извлекаем команду, которую собирается выполнить Claude
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)

# Проверяем: есть ли в команде путь к архиву
if echo "$COMMAND" | grep -q "$HOME/Documents/Архив"; then
  echo "Нельзя изменять файлы в папке Архив. Обратитесь к Павлу." >&2
  exit 2
fi

exit 0

Что здесь происходит: Claude собирается выполнить команду. Перед выполнением запускается этот скрипт. Скрипт получает через stdin JSON с описанием действия, извлекает команду, проверяет путь. Если путь ведёт в архив — скрипт выводит сообщение в stderr и завершается с кодом 2. Claude видит этот код, останавливается и показывает пользователю текст из stderr.

stderr, не stdout

Сообщение для пользователя при блокировке должно идти в stderr (>&2). Если вы напишете в stdout — Claude может попытаться это разобрать как JSON и запутаться. Простое правило: пишете для человека — stderr, пишете для Claude — stdout.

В настройках хука (.claude/settings.json) это выглядит так:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/guard_archive.sh"
          }
        ]
      }
    ]
  }
}

Теперь каждый раз, когда Claude собирается выполнить bash-команду, скрипт проверяет путь. Попытка тронуть архив блокируется автоматически.

Управление поведением через JSON в stdout

Exit code 2 — это жёсткий запрет. Но иногда нужно что-то тоньше: не остановить, а скорректировать. Для этого используется JSON в stdout при exit code 0.

Claude читает этот JSON и применяет инструкции из него. Вот какие поля он понимает:

continue (boolean) — продолжать ли выполнение. true по умолчанию. Если поставить false — это мягкая остановка, аналог exit code 2, но через JSON.

stopReason (строка) — сообщение, которое Claude покажет, если continue: false. Объяснение для пользователя.

suppressOutput (boolean) — поле принимается, но ни на что не влияет. Полезно для технических действий, которые должны произойти тихо.

additionalContext (строка, внутри hookSpecificOutput) — текст, который попадёт в контекст Claude для этого шага. Это динамическое расширение контекста прямо в момент действия. Не путайте с полем systemMessage: оно показывает предупреждение вам, а Claude его не видит.

permissionDecision (строка, внутри hookSpecificOutput рядом с обязательным hookEventName) — ответ на запрос разрешения. Значения: "allow", "deny", "ask" (спросить пользователя) и "defer" (выйти, не принимая решения). Значений allow_permanent и deny_permanent не существует.

Пример: динамическое предупреждение для писем

Вы хотите, чтобы перед каждым вызовом инструмента записи в файл Claude получал напоминание проверить, не является ли файл финансовым документом. Не всегда — только когда речь идёт о папке Финансы/. Создаёте скрипт finance_reminder.sh:

#!/usr/bin/env bash
INPUT=$(cat)

# Извлекаем путь файла, который собирается изменить Claude
FILE_PATH=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
# путь и у Write, и у Edit лежит в tool_input.file_path
print(d.get('tool_input', {}).get('file_path', ''))
" 2>/dev/null)

# Если путь содержит папку Финансы — добавляем напоминание
if echo "$FILE_PATH" | grep -q "Финансы"; then
  python3 -c "
import json
print(json.dumps({
  'continue': True,
  'hookSpecificOutput': {
    'hookEventName': 'PreToolUse',
    'additionalContext': 'ВАЖНО: этот файл находится в папке Финансы. Перед записью убедись, что изменения не затрагивают итоговые суммы и формулы расчёта. Если неуверен — спроси пользователя.'
  }
}))
"
  exit 0
fi

exit 0

Что происходит: Claude собирается записать файл. Хук проверяет путь. Если путь ведёт в папку Финансы/ — хук возвращает JSON с полем additionalContext. Этот текст попадает в контекст Claude, и запись выполняется уже с оглядкой на предупреждение.

additionalContext — это разговор с Клодом, systemMessage — с вами

Поле additionalContext кладёт текст в контекст Claude. Поле systemMessage, наоборот, показывает предупреждение вам в интерфейсе, а Claude его не видит. Это как тихая записка ассистенту перед встречей: «напомни руководителю про ограничение бюджета, когда дойдут до пункта 3».

Пример: автоматическое разрешение для конкретной папки

Если Claude каждый раз спрашивает разрешения на чтение файлов из рабочей папки ~/Documents/Отчёты/, это можно автоматизировать:

#!/usr/bin/env bash
INPUT=$(cat)

FILE_PATH=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('file_path', '') or d.get('path', ''))
" 2>/dev/null)

if echo "$FILE_PATH" | grep -q "$HOME/Documents/Отчёты"; then
  python3 -c "import json; print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow', 'permissionDecisionReason': 'Рабочая папка Отчёты'}}))"
  exit 0
fi

exit 0

Теперь для файлов из папки Отчёты/ хук автоматически отвечает «разрешено», и Claude не прерывает работу запросом подтверждения.


Частые ошибки

Ошибка 1: Перепутать stdout и stderr

Самая распространённая ошибка. Вы хотите показать пользователю сообщение при блокировке, но пишете его в stdout, а не stderr:

# Неправильно
echo "Нельзя удалять файлы из архива"
exit 2

# Правильно
echo "Нельзя удалять файлы из архива" >&2
exit 2

При exit code 2 Claude читает stderr для показа пользователю. Если сообщение в stdout — пользователь ничего не увидит, или Claude попытается разобрать текст как JSON и получит ошибку парсинга.

Если же вы возвращаете JSON при exit code 0, он должен быть в stdout. Перепутаете — Claude не увидит ваши инструкции.

Ошибка 2: Невалидный JSON обрушивает поток

Если при exit code 0 в stdout оказывается невалидный JSON — Claude не применит инструкции. Хуже — это может нарушить работу всего шага. Всегда проверяйте, что генерируете корректный JSON:

# Проверка перед запуском
echo '{"continue": true, "systemMessage": "тест"}' | python3 -m json.tool

Если python3 выводит отформатированный JSON — всё в порядке. Если ошибку — нашли проблему.

Пустой stdout — не то же самое, что отсутствие JSON

Если скрипт завершается с exit code 0 и ничего не пишет в stdout — Claude просто продолжает. Это нормальное поведение. Как JSON разбирается только stdout, содержащий один JSON-объект и ничего кроме него — посторонний текст, например баннер из профиля шелла, ломает разбор. Поэтому весь отладочный вывод — только в stderr. Весь отладочный вывод — только в stderr.

Ошибка 3: Не учесть exit code 1 при ошибках скрипта

Если ваш скрипт падает с ошибкой (например, python3 не установлен, или опечатка в коде), он завершится с кодом 1 или другим ненулевым числом, отличным от 2. Claude не заблокируется — он просто запишет ошибку в лог и продолжит работу.

Это может создать ложное ощущение безопасности: вы думаете, что защита работает, а она тихо падает и ничего не делает. Регулярно проверяйте логи и тестируйте хуки изолированно:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ~/Documents/Архив/file.docx"}}' | bash ~/.claude/hooks/guard_archive.sh
echo "Exit code: $?"

Если видите «Exit code: 2» — хук работает. Если «Exit code: 0» или «Exit code: 1» — что-то не так.

Ошибка 4: JSON с continue: false вместо exit code 2 — не одно и то же

Технически оба способа останавливают выполнение. Но есть разница в поведении. Exit code 2 + stderr — это сигнал «произошла намеренная блокировка», Claude отображает текст из stderr как сообщение об ошибке. JSON с continue: false + stopReason — это мягкая остановка, Claude представляет stopReason как часть своего ответа.

Для защитных хуков, где важно чёткое «запрещено», используйте exit code 2. Для логических остановок (например, «нет данных для обработки, останови работу») — JSON с continue: false выглядит естественнее.


Когда нужно / когда нет

Используйте exit code 2 (жёсткая блокировка), когда:

Используйте JSON с additionalContext, когда:

Используйте JSON с permissionDecision, когда:

Не добавляйте хуки с блокировкой, если:

Хук — это код, а не инструкция

Разница между правилом в CLAUDE.md и хуком с блокировкой принципиальная. CLAUDE.md — это инструкция, которую Claude читает и старается соблюдать. Хук — это код, который выполняется независимо от того, решил ли Claude что-то соблюдать. Для критических запретов (архив, финансы, production-данные) выбирайте хук.


Связь с другими уроками

День 41 (Hooks: события) — там мы разобрали, как настроить хук и на какое событие его навесить: PreToolUse, PostToolUse, SessionEnd. Сегодняшний урок — следующий слой: как хук общается с Claude после того, как сработал. Если день 41 — «куда подключить провод», то день 44 — «что по нему передаётся».

День 45 (Command hooks) — command hooks передают управление внешнему скрипту. Именно в этих скриптах живут exit codes и JSON-ответы, которые мы разбираем сегодня. Паттерны оттуда применяются напрямую.

День 48 (Аудит через hooks) — практический урок покажет, как использовать хуки для логирования всех действий Claude без вмешательства в их выполнение. Там exit code 0 без JSON — основной паттерн: скрипт пишет в лог и молча пропускает действие дальше.


Задание на сегодня

Создайте один защитный хук за 5 минут. Возьмите папку, которую не хотите случайно затронуть — например, папку с шаблонами или финансовыми документами.

Создайте файл ~/.claude/hooks/protect_folder.sh:

#!/usr/bin/env bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)

PROTECTED="$HOME/Documents/Финансы"  # замените на свой путь

if echo "$COMMAND" | grep -q "$PROTECTED"; then
  echo "Заблокировано: попытка изменить защищённую папку $PROTECTED" >&2
  exit 2
fi
exit 0

Сделайте его исполняемым: chmod +x ~/.claude/hooks/protect_folder.sh

Добавьте в .claude/settings.json под hooks > PreToolUse > Bash.

Проверьте: запустите в терминале вручную:

echo '{"command": "rm ~/Documents/Финансы/test.txt"}' | bash ~/.claude/hooks/protect_folder.sh; echo "Exit: $?"

Критерий выполнения: терминал показал сообщение «Заблокировано...» и «Exit: 2».


Резюме

Следующий урок откроется после отметки.