$ sudo teach IT

В главе про CLAUDE.md вы записывали правила проекта словами: «не трогай .env», «не запускай деструктивные команды». Это работает в большинстве случаев, но остаётся просьбой. Правило в CLAUDE.md — часть контекста, который читает модель, и при длинном разговоре модель может о нём не вспомнить. О нарушении вы узнаете только по факту.

PreToolUse — единственная точка, где просьба превращается в гарантию. Хук на этом событии запускает не модель, а сам движок Claude Code, и запускает до того, как инструмент выполнится. Если хук говорит «нет», вызова не будет — независимо от решения модели и режима разрешений.

Что уже умеем / что добавим

Вы уже знаете устройство конфигурации хуков, разбираетесь в матчерах и умеете вешать PostToolUse — например, прогонять форматтер после каждой правки. У PostToolUse есть принципиальное ограничение: он реагирует на уже случившееся. Инструмент отработал, файл записан — отменить это хук не может.

В этом уроке мы разберём событие, которое умеет останавливать: два способа заблокировать вызов, ловушку с кодами выхода, поведение хука в разных режимах разрешений и границы хук-политики.

Что вы узнаете в рамках статьи

  • Чем правило в CLAUDE.md отличается от правила, закреплённого хуком
  • Два способа заблокировать вызов инструмента: exit 2 и JSON-решение
  • Почему exit 1 ничего не блокирует и как это выглядит в транскрипте
  • Как PreToolUse взаимодействует с режимами разрешений, включая bypassPermissions
  • Где проходит граница между хук-политикой и системой разрешений

Ключевые термины

  • PreToolUse — событие перед выполнением каждого вызова инструмента. Единственное событие инструментов, способное этот вызов отменить.
  • Блокирующая ошибка — завершение хука с кодом 2. Действие блокируется, stderr передаётся Claude как объяснение отказа.
  • Неблокирующая ошибка — завершение с любым другим ненулевым кодом. Действие продолжается, в транскрипте остаётся отметка об ошибке хука.
  • permissionDecision — поле JSON-решения хука со значениями allow, deny и ask. Определяет судьбу вызова, когда хук отвечает структурированным выводом, а не кодом выхода.

Просьба и гарантия — в чём разница

Формулировка «не редактируй package-lock.json» в CLAUDE.md попадает в контекст и влияет на решения модели вероятностно. Тот же запрет, оформленный хуком PreToolUse, выполняется детерминированно: движок вызывает ваш скрипт перед каждым Edit и Write, читает ответ и либо пропускает вызов, либо отменяет.

Разница видна и в обратной связи. Нарушенное правило из CLAUDE.md никак себя не проявляет — файл просто изменён. Заблокированный хуком вызов возвращает Claude текст причины, и модель на него реагирует: меняет подход или спрашивает вас. Запрет становится частью разговора.

Два способа сказать «нет»

Первый способ — код выхода. Хук печатает причину в stderr и завершается с кодом 2. Вызов блокируется, stderr уходит Claude.

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Сохраните это в .claude/hooks/protect-files.sh, сделайте файл исполняемым (chmod +x) и зарегистрируйте на матчер Edit|Write:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

Что вы увидите: попросите Claude поправить .env — правки не будет, а Claude получит строку Blocked: .env matches protected pattern '.env' и, скорее всего, предложит .env.example или спросит, куда писать. Обычные файлы проходят через хук незаметно.

Второй способ — структурированный ответ. Хук завершается с кодом 0, но печатает на stdout JSON-объект решения:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by hook"
  }
}

Значения permissionDecision: "allow", "deny", "ask". Правило разбора: если вывод начинается с { и заканчивается }, он разбирается как JSON; иначе весь stdout считается обычным текстом. Если объект прошёл валидацию схемы, код выхода игнорируется — решение принимает JSON.

Отсюда важное следствие: не смешивайте подходы в одном хуке. Либо exit 2 с сообщением в stderr, либо exit 0 с JSON на stdout. Выберите один способ и держитесь его.

Блокировка опасной команды через JSON

Тот же приём применим к Bash. Скрипт разбирает команду, которую собирается выполнить Claude, и печатает решение:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Destructive command blocked by hook"
    }
  }'
fi
exit 0

Регистрируется он на матчер "Bash", в остальном конфигурация такая же. Обратите внимание: если совпадения нет, скрипт ничего не печатает и выходит с кодом 0. Это не одобрение, а отсутствие решения — вызов пойдёт дальше по обычной проверке разрешений. Хук может запретить вызов, но молчание его не разрешает.

Проверить скрипт до подключения можно вручную, подав на вход тот же JSON, что даёт движок:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | ./deny-rm.sh

Ловушка кода выхода: почему exit 1 ничего не останавливает

Самая частая ошибка здесь — привычка из обычных скриптов, где «ошибка» это exit 1. В хуках exit 1 не блокирует.

Значимы ровно три случая. Код 0 — успех: stdout уходит в отладочный лог или разбирается как JSON-решение. Код 2 — блокирующая ошибка: действие отменяется, stderr идёт Claude. Любой другой ненулевой код, включая 1, — неблокирующая ошибка: действие продолжается, а в транскрипте появляется уведомление <hook name> hook error и первая строка stderr с префиксом Failed with non-blocking status code:.

Коварство в том, что такой хук выглядит работающим: он запускается, печатает грозное сообщение, в транскрипте видно упоминание ошибки — и при этом файл всё равно изменён. Правило одной строкой: если хук предназначен для соблюдения политики, используйте exit 2 или JSON с permissionDecision: "deny".

Хуки и режимы разрешений

Это самый сильный аргумент в пользу PreToolUse. Хуки этого события срабатывают до любой проверки режима разрешений, во всех режимах. Хук, вернувший deny, блокирует инструмент даже в bypassPermissions и при запуске с флагом --dangerously-skip-permissions. Смена режима такую политику не обходит.

Обратное неверно и сделано намеренно: хук, вернувший allow, не обходит правила запрета из настроек. Хуки могут ужесточать ограничения, но не ослаблять их сверх того, что позволяет система разрешений.

Отсюда практический вывод: если нужно жёстко закрыть класс действий, основой должна быть система разрешений, а хук — дополнительным слоем. Поле if в конфигурации хука (например, "Bash(git *)") — фильтр best-effort: оно удобно, чтобы не запускать скрипт зря, но полагаться на него как на запрет документация не советует.

И последнее предупреждение: опечатка в пути к скрипту в settings.json молча выключает вашу политику. Хук не найдётся, вызов пройдёт, заметного сигнала вы не получите. Настроив политику впервые, проверьте её на живом примере — попросите Claude тронуть защищённый файл и убедитесь, что отказ приходит.

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

  • Использовать exit 1 для запрета. Любой ненулевой код кроме 2 — неблокирующая ошибка: действие выполнится, а в транскрипте останется лишь Failed with non-blocking status code:. Для политики только exit 2 или JSON с deny.
  • Смешивать exit 2 и JSON-вывод в одном хуке. Правила разбора у них разные: валидный JSON на stdout перебивает код выхода. Выберите один механизм на скрипт.
  • Считать, что exit 0 без вывода одобряет вызов. Это «решение не принято». Одобрение — только явное permissionDecision: "allow".
  • Полагаться на хук как на единственный барьер. Опечатка в пути или неисполняемый файл тихо выключают политику. Жёсткий запрет закрывайте системой разрешений, хук держите вторым слоем.

Наиболее частые вопросы

  • Заблокирует ли хук вызов, если я запустил Claude Code с -dangerously-skip-permissions? — Да. PreToolUse срабатывает до проверки режима разрешений, и deny действует во всех режимах, включая bypassPermissions.
  • Можно ли хуком разрешить то, что запрещено в настройках? — Нет. allow не обходит правила запрета: хуки ужесточают ограничения, но не ослабляют их.
  • Почему хук не сработал, хотя скрипт правильный? — Обычно дело в матчере (он чувствителен к регистру), неисполняемом файле, относительном пути вместо ${CLAUDE_PROJECT_DIR} или отсутствии jq.
  • Поймает ли матчер Edit|Write изменение файла через Bash? — Нет. Это разные инструменты. Чтобы закрыть и такой путь, матчите также Bash или используйте событие FileChanged.
  • Как увидеть, что хук отработал без запрета? — Успешный хук в разговоре не показывается: проверяйте по эффекту или включайте лог claude --debug-file /tmp/claude.log.

Итоги

Вы разобрались с самым важным событием главы. Теперь вы умеете превращать правило из пожелания в механизм: писать скрипт защиты файлов с exit 2, отдавать решение через hookSpecificOutput, отличать блокирующую ошибку от неблокирующей и объяснять, почему exit 1 — тихий провал политики, а не запрет. Вы также знаете, что хук с deny держится во всех режимах разрешений.

Это качественно другой уровень контроля: до сих пор вы настраивали, что Claude знает, а теперь — что ему доступно. Дальше в главе мы посмотрим на события за пределами вызовов инструментов и на то, как собрать из отдельных хуков связную конфигурацию проекта.

На следующий урок