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после успешного вызоваимя инструментанет
NotificationClaude ждёт ввода или разрешениятип уведомлениянет
StopClaude закончил ответ-да (продолжить работу)
SubagentStopсубагент закончилтип агентада
PreCompactперед компакциейmanual, autoда
ConfigChangeизменился файл настроек или skilluser_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: как хук отвечает

Что значит каждый код выхода

Что в stdoutExit 0Exit 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, PostToolBatchPostToolUseFailure, PermissionDenied

Полная таблица по всем событиям, включая WorktreeCreate, Elicitation и PreModelSwitch, есть в разделе Exit code 2 behavior per event.

Три способа сказать «нет»

  1. Exit 2 + stderr. Самый простой вариант. Stderr становится причиной, Claude видит её как ошибку инструмента: PreToolUse:Write hook error: [<команда хука>]: <ваш текст>.
  2. Exit 0 + hookSpecificOutput.permissionDecision. Только PreToolUse. Значения allow, deny, ask и defer (последнее только в -p). Плюс updatedInput, чтобы переписать аргументы вызова. Объект заменяется целиком, а не сливается.
  3. 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.denyPreToolUse-хук
Что проверяетправило по инструменту и аргументамлюбую логику на входном 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"
Нет jqjq падает, переменная пустая, скрипт выходит с 0проверка command -v jq в начале, onFailure: "block"
Windows без Git BashPreToolUse и 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 может держать сессию десять минут. Таймаут - часть контракта хука, его стоит задавать для каждой роли:

Роль хукаСобытиеТаймаутИсточник
Журнал активностиPostToolUse3 сконфигурация автора
Ориентация и контекстSessionStart5 сконфигурация автора
Защитный guardPreToolUse5 с + onFailure: "block"рекомендация по docs
Сохранение памятиStop30 сконфигурация автора
Сохранение состоянияPreCompact60 сконфигурация автора
Код-ревью перед остановкойStop900 схук одного из плагинов
Проверка промптаUserPromptSubmit30 с по умолчаниюdocs
ФинализацияSessionEnd1,5 с общий бюджет, до 60 сdocs

Для UserPromptSubmit дефолт короче не случайно: хук выполняется перед каждым промптом, и пока он не завершится, модель не начнёт работу. SessionEnd делит общий бюджет 1,5 секунды на все хуки; бюджет растёт до 60 секунд, если в настройках задан больший timeout.

Что выбрать: хук, skill или CLAUDE.md

ЗадачаМеханизмПочему
Запретить правку .envpermissions.deny + PreToolUse-хукдетерминированно, с понятной причиной
Форматировать после каждой правкиPostToolUse-хукпроисходит всегда, без участия модели
Не завершать работу с красными тестамиStop-хукмодель не может «забыть» проверку
Конвенции кода и архитектураCLAUDE.mdконтекст, а не механическое правило
Процедура релиза, чеклист ревьюSkillподгружается по задаче, не занимает контекст постоянно
Изолированная подзадача с узкими правамиСубагентсвой контекст и набор инструментов

Хуки можно объявлять и во frontmatter skill или субагента: тогда они действуют только пока работает этот компонент. Как это устроено у субагентов и событий SubagentStart и SubagentStop, разобрано в статье о субагентах Claude Code. Обзор skills, agents и memory - в отдельном гайде.

Итоги

  1. Блокируйте через exit 2 со stderr или через exit 0 с JSON в stdout. exit 1 не блокирует.
  2. Каждому защитному хуку задавайте короткий timeout и onFailure: "block", иначе таймаут или падение пропустит вызов.
  3. В Stop-хуке первой строкой проверяйте stop_hook_active.
  4. Собирайте JSON через jq -n --arg, проверяйте хук на тестовом stdin до подключения и смотрите --debug-file после.
  5. Хук дополняет permissions и песочницу, но не заменяет их. Regex по тексту команды - удобный отказ с объяснением, а не граница безопасности.

Часто задаваемые вопросы

Чем exit 2 отличается от JSON permissionDecision: deny?
По эффекту на PreToolUse они равнозначны: вызов не выполняется, причина видна Claude. Exit 2 проще, причина пишется plain-текстом в stderr. JSON печатается в stdout при exit 0 и даёт больше вариантов: allow, ask, defer, подмену аргументов через updatedInput. JSON в stderr при exit 2 решением не считается, это просто текст причины.
Блокирует ли хук в режиме --dangerously-skip-permissions?
Да. В живой проверке на Claude Code 2.1.318 и exit 2, и JSON deny заблокировали Write в режиме bypassPermissions. Обратное не работает: хук с allow не обходит deny-правила из permissions, они проверяются независимо от ответа хука.
Почему Stop-хук зацикливается?
Stop срабатывает после каждого ответа Claude, а exit 2 на нём означает «продолжай работать». Если условие не выполнимо, хук блокирует снова и снова. Защита - выходить с 0, когда во входном JSON stop_hook_active равен true. Claude Code ограничивает серию 8 продолжениями подряд, но счётчик сбрасывается при каждом вызове инструмента.
Нужно ли перезапускать Claude Code после правки хуков?
Обычно нет. Правки в файлах settings подхватывает файловый вотчер; в живой проверке хук, добавленный в .claude/settings.json посреди сессии, сработал на следующем же вызове. Если изменения не появились через несколько секунд, документация советует перезапустить сессию.
Что выбрать: хук, skill или CLAUDE.md?
CLAUDE.md - для инструкций, которые модель учитывает по своему усмотрению. Skill - для процедур и знаний, которые подгружаются по задаче. Хук - для того, что должно происходить каждый раз без участия модели: блокировка, форматирование, логирование, уведомление.
Безопасно ли открывать чужой проект с .claude/settings.json?
В интерактивной сессии Claude Code не запускает хуки, пока вы не примете диалог доверия к папке. В режиме claude -p и в SDK диалога нет, и хуки из репозитория выполняются сразу. Перед запуском -p на чужом коде стоит прочитать .claude/ или передать --settings '{"disableAllHooks": true}'.