Claude Code сливает ключи в коммиты вдвое чаще среднего: почему хук PreToolUse их пропускает

—

от автора

В публичных коммитах, сделанных с помощью Claude Code, секреты встречаются в 3,2% случаев, а в среднем по GitHub — в 1,5% (GitGuardian). Кто вписал ключ, агент или человек, по этим данным не видно. Запретить это строкой в CLAUDE.md мало: на длинной сессии агент правило теряет, поэтому ключи я проверяю хуком PreToolUse перед каждым вызовом инструмента. Но простой хук ключ пропустит: он срабатывает до git add и видит пустой индекс, а упав с ошибкой, не блокирует ничего.

CLAUDE.md не гарантирует, что Claude выполнит правило

По документации Claude Code, CLAUDE.md — это инструкции человека, которые подгружаются в каждую сессию. Гарантии исполнения там нет: правило лежит в том же контекстном окне, что и вся переписка. А факт из середины длинного контекста языковая модель находит хуже (Liu et al.). Правило из начала сессии к середине работы попадает как раз туда. У меня агент так однажды нарушил прямой запрет из CLAUDE.md и ради одной цифры для статьи запустил платную серию запросов к API.

Агент Replit летом 2025 года нарушил прямой запрет заказчика на изменения и удалил рабочую базу с данными больше тысячи компаний (AIID).

Как написать хук Claude Code, который остановит команду агента, даже если сам упадёт

Claude Code hooks — это скрипты, которые Claude Code запускает на событиях сессии. Перед вызовом инструмента событие PreToolUse приходит на stdin JSON-объектом с полями tool_name и tool_input. Код выхода 2 блокирует вызов, текст из stderr агент получает как причину отказа; второй способ — выйти с 0 и вернуть JSON с permissionDecision: deny. (документация).

Хук подключается в .claude/settings.json на PreToolUse с matcher: "Bash" и таймаутом 120 секунд. Движок один, правила лежат в JSON: какую команду ловить, какой проверкой, с какой причиной отказа.

import json, os, shlex, subprocess, sysROOT = os.environ.get("CLAUDE_PROJECT_DIR", ".")def segments(command):  # && || ; | и перевод строки, кавычки учтены    lex = shlex.shlex(command, posix=True, punctuation_chars=";&|\n")    lex.whitespace, lex.whitespace_split = " \t\r", True    seg = []    for tok in [*lex, "\n"]:        if set(tok) <= set(";&|\n"):            while seg and "=" in seg[0]:  # VAR=x git push                seg.pop(0)            yield seg            seg = []        else:            seg.append(tok)def check(payload):    rules = json.load(open(os.path.join(ROOT, ".claude/rules.json")))    for seg in segments(payload["tool_input"]["command"]):        for rule in rules:            prefix = rule["command"].split()            head = [os.path.basename(t) for t in seg[:1]] + seg[1:len(prefix)]            if head != prefix:                continue  # команду упомянули, но не запускают            args = seg[len(prefix):]  # аргументы именно этой части            done = subprocess.run(shlex.split(rule["require"]) + args,                                  cwd=ROOT, capture_output=True, timeout=100)            if done.returncode:                return f"{rule['reason']}: {shlex.join(seg)}"def main():    try:        reason = check(json.load(sys.stdin))    except Exception as e:  # сбой, таймаут, кривой вход        reason = f"hook error: {e}"    if reason:        print(reason, file=sys.stderr)        sys.exit(2)if __name__ == "__main__":    main()
Шесть шагов вызова через хук: от решения агента до exit 0 или exit 2 с причиной в stderr

Упавший хук для агента выглядит так же, как нарушенное правило.

Команда ./deploy.sh хорошая.md && ./deploy.sh плохая.md даёт две части, и проверка идёт дважды, каждая со своим файлом; поиск по подстроке пропустил бы её целиком. Слова git push в сообщении коммита правило не тронут. Непойманное исключение в Python даёт код 1, а любой код, кроме 2, Claude Code считает неблокирующим, поэтому хук ловит всё, включая таймаут проверки, и выходит с 2. В моём движке отказ идёт JSON-ответом deny, код 2 — для сбоев; в примере для краткости всё идёт через код 2.

Почему хук не замечает ключ API, если агент добавляет файл и коммитит одной командой

Для безопасности ИИ-агентов главный рубеж — коммит. Первая версия поиска ключей перед git commit и git push сканировала файлы на диске. Ревью кода нашло в ней два пропуска: ключ можно добавить в индекс, стереть с диска и закоммитить, а неотправленные коммиты перед push она не смотрела вовсе.

Есть и третья ловушка: хук срабатывает до команды. Агенты часто коммитят одной командой git add -A && git commit или git commit -a, и в момент проверки индекс ещё пуст. Проверка перед коммитом должна смотреть индекс, правки против HEAD и новые файлы, перед push — все коммиты, которых нет на удалённом репозитории. Я пришёл к такой версии:

#!/usr/bin/env bash# scan_secrets.sh commit|push; код 2 — ключ или сбой gitset -uo pipefailadded() { "$@" | sed -n '/^+++ /d; s/^+//p'; }  # только добавленные строкиchanges() {  if [ "$1" = commit ]; then    added git diff --cached --no-color --no-ext-diff || return 1    if git rev-parse -q --verify HEAD >/dev/null; then  # в пустом репозитории HEAD нет      added git diff HEAD --no-color --no-ext-diff || return 1    fi    git ls-files -o --exclude-standard -z | while IFS= read -r -d '' f; do cat -- "$f"; done  else    added git log -p --no-color -m --branches --not --remotes  fi}out=$(mktemp) || exit 2changes "$1" > "$out" || { rm -f "$out"; echo "git не ответил" >&2; exit 2; }# шаблон упрощён, в работе возьмите gitleaks или trufflehoggrep -qaE '(^|[^A-Za-z0-9_])(sk-|ghp_|AKIA)[A-Za-z0-9_-]{16,}' "$out"; rc=$?; rm -f "$out"[ "$rc" = 1 ] || { echo "похоже на ключ" >&2; exit 2; }
Где искать ключ перед коммитом: в индексе, в правках против HEAD и новых файлах, в неотправленных коммитах; упавшая проверка блокирует вызов

Ключ, стёртый с диска после git add, всё равно уезжает в коммит.

Тот же отчёт GitGuardian нашёл больше двадцати тысяч секретов в публичных конфигах MCP. Конфиги MCP с ключами держите в .gitignore и отдавайте сканеру отдельно.

Какие действия агента проверять хуком, а какие оставить правилом в CLAUDE.md

Хуки у меня стоят на всех ИИ-агентах для программирования, и правило очень простое: если откат ошибки дороже пяти минут разговора с агентом, правило проверяет код. Стиль, тон и порядок работы остаются правилами в CLAUDE.md → такую ошибку агент исправит одним ответом.

  1. Начните с двух правил: ключи перед commit и push (в паре с родными git-хуками, о них ниже), проверка перед выкладкой.

  2. Режьте составную команду на части и передавайте в проверку аргументы своей части.

  3. Сбой и таймаут хука блокируют: таймаут проверки меньше таймаута хука, любое исключение — код 2.

  4. На каждое правило держите сценарии регресса: команды на пропуск и на блок. У меня их несколько десятков, новый обычно появляется после поломки.

  5. Файл с правилами хуков агент не должен править сам, нужен хук на Edit|Write по пути файла.

Пятый пункт я проверил на себе: регулярка в правилах блокировала лишнее, и Claude Code не дал агенту без моего подтверждения поправить файл с правилами хуков. Правила разрешений ведут себя не так, как ждёшь (issue #27333). На составных командах они не срабатывают, поэтому на dev.to проверку тоже переносят в хук PreToolUse (dev.to).

Что ещё очень важно — хук видит команду, а не намерение, и git -C другая/папка push или вызов через свой скрипт не поймает. Ключи надёжнее ловить родными git-хуками pre-commit и pre-push, они срабатывают уже после git add, а хуком агента запретить --no-verify.

ссылка на оригинал статьи https://habr.com/ru/articles/1087300/