# Claude Code hooks: рецепты, exit codes и безопасность

> Рабочие рецепты хуков Claude Code: события и matcher, exit 2 против JSON-ответа, защитные PreToolUse, Stop-gate, уведомления и безопасность самих хуков.
> Author: Roman Belov · Published: 2026-10-12 · Source: https://futurecraft.pro/ru/blog/claude-code-hooks/

Инструкция в CLAUDE.md - просьба: модель учтёт её с высокой вероятностью, но не всегда. Hook в Claude Code - код, который выполняется в заданной точке цикла каждый раз. Если правило «не трогать `.env`» должно работать без исключений, его место в PreToolUse-хуке, а не в тексте промпта.

Механизм простой: Claude Code передаёт хуку JSON на stdin, хук отвечает кодом выхода и, при желании, JSON на stdout. Сложности начинаются в деталях: exit 1 не блокирует, таймаут защитного хука пропускает команду, Stop-хук без проверки флага зацикливается, а JSON-решение, напечатанное в stderr, игнорируется. Ни одна из этих ошибок не выдаёт себя явно, и хук выглядит рабочим, пока не понадобится.

Статья о том, как устроены хуки, чем блокировать на каком событии, и о рецептах для защиты, автоматизации, контекста и уведомлений. Отдельный раздел посвящён безопасности самих хуков. Факты сверены с [Hooks reference](https://code.claude.com/docs/en/hooks) и [Hooks guide](https://code.claude.com/docs/en/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](https://code.claude.com/docs/en/hooks#hook-lifecycle). Для рецептов из этой статьи хватает десятка:

| Событие | Когда срабатывает | Matcher фильтрует | Может блокировать |
|---|---|---|---|
| `SessionStart` | старт, resume, `/clear`, компакция | `startup`, `resume`, `clear`, `compact`, `fork` | нет |
| `UserPromptSubmit` | промпт отправлен | - | да |
| `PreToolUse` | до вызова инструмента | имя инструмента | да |
| `PermissionRequest` | показ запроса разрешения | имя инструмента | через `decision` |
| `PostToolUse` | после успешного вызова | имя инструмента | нет |
| `Notification` | Claude ждёт ввода или разрешения | тип уведомления | нет |
| `Stop` | Claude закончил ответ | - | да (продолжить работу) |
| `SubagentStop` | субагент закончил | тип агента | да |
| `PreCompact` | перед компакцией | `manual`, `auto` | да |
| `ConfigChange` | изменился файл настроек или skill | `user_settings`, `project_settings` и др. | да, кроме `policy_settings` |

Matcher читается по составу символов. `*`, пустая строка или отсутствие поля означает «всё». Если в строке только буквы, цифры, `_`, `-`, пробелы, `,` и `|`, это точный список: `Edit|Write` совпадёт ровно с двумя инструментами. Любой другой символ превращает matcher в JavaScript-regex без якорей, и `Edit.*` поймает заодно `NotebookEdit`; для точного совпадения нужен `^Edit$`. Регистр важен.

### Конфигурация и где она лежит

Конфигурация трёхуровневая: событие, группа с matcher, список обработчиков.

```json
{
  "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: как хук отвечает

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

| Что в stdout | Exit 0 | Exit 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`, `PostToolBatch` | `PostToolUseFailure`, `PermissionDenied` |

Полная таблица по всем событиям, включая `WorktreeCreate`, `Elicitation` и `PreModelSwitch`, есть в разделе [Exit code 2 behavior per event](https://code.claude.com/docs/en/hooks#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`, конфигурация как в примере выше. Скрипт:

```bash
#!/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`.

```bash
#!/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.deny` | PreToolUse-хук |
|---|---|---|
| Что проверяет | правило по инструменту и аргументам | любую логику на входном 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](https://futurecraft.pro/ru/blog/claude-code-complete-guide/).

### Смежный приём: 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`. Если форматтер должен сообщать об ошибке, нужен скрипт:

```bash
#!/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: тесты перед остановкой

```bash
#!/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 секунды.

```bash
#!/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 с первого сообщения знал ветку, последние коммиты и незакоммиченные файлы:

```bash
#!/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-хук читает журнал из предыдущего рецепта, и два хука связываются через файл: один пишет, другой читает.

```bash
#!/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](https://futurecraft.pro/ru/blog/context-engineering-guide/).

### Повторная инъекция после компакции

Компакция сжимает разговор, и детали, данные только в чате, теряются. Корневой CLAUDE.md после `/compact` перечитывается с диска (подробнее в [гайде по CLAUDE.md](https://futurecraft.pro/ru/blog/claude-md-guide/)). Для динамического контекста документация предлагает SessionStart с matcher `compact`:

```json
{ "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`:

```bash
#!/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`:

```bash
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`:

```json
{ "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-сервера ([подробнее о таких атаках](https://futurecraft.pro/ru/blog/mcp-security-guide/)). Переменные всегда в кавычках (`"$P"`), никакого `eval` и `sh -c` на содержимом команды, проверка путей на `..`, абсолютные пути к скриптам.

**Режимы отказа.** По умолчанию защитный хук работает в режиме fail-open:

| Ситуация | Что происходит с вызовом | Как закрыть |
|---|---|---|
| Хук завершился с exit 1 | проходит | `exit 2` или JSON deny |
| Таймаут PreToolUse-хука | проходит | короткий `timeout` + `onFailure: "block"` |
| Скрипт не найден или не запустился | проходит | `onFailure: "block"` |
| Нет `jq` | `jq` падает, переменная пустая, скрипт выходит с 0 | проверка `command -v jq` в начале, `onFailure: "block"` |
| Windows без Git Bash | PreToolUse и 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` может держать сессию десять минут. Таймаут - часть контракта хука, его стоит задавать для каждой роли:

| Роль хука | Событие | Таймаут | Источник |
|---|---|---|---|
| Журнал активности | PostToolUse | 3 с | конфигурация автора |
| Ориентация и контекст | SessionStart | 5 с | конфигурация автора |
| Защитный guard | PreToolUse | 5 с + `onFailure: "block"` | рекомендация по docs |
| Сохранение памяти | Stop | 30 с | конфигурация автора |
| Сохранение состояния | PreCompact | 60 с | конфигурация автора |
| Код-ревью перед остановкой | Stop | 900 с | хук одного из плагинов |
| Проверка промпта | UserPromptSubmit | 30 с по умолчанию | docs |
| Финализация | SessionEnd | 1,5 с общий бюджет, до 60 с | docs |

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

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

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

Хуки можно объявлять и во frontmatter skill или субагента: тогда они действуют только пока работает этот компонент. Как это устроено у субагентов и событий SubagentStart и SubagentStop, разобрано в [статье о субагентах Claude Code](https://futurecraft.pro/ru/blog/claude-code-subagents/). Обзор skills, agents и memory - в [отдельном гайде](https://futurecraft.pro/ru/blog/claude-code-skills-agents/).

## Итоги

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 по тексту команды - удобный отказ с объяснением, а не граница безопасности.
