Почему CLAUDE.md не спасает от утечки секретов
Правило в CLAUDE.md не гарантирует, что агент не закоммитит ключ. Это инструкция человека, которую Claude Code подгружает в каждую сессию, но лежит она в том же контекстном окне, что и вся переписка: чем длиннее диалог, тем хуже модель видит правило. Гарантию даёт код, который проверяет опасное действие до его выполнения, и в Claude Code для этого есть событие PreToolUse.
Масштаб измерим: в публичных коммитах, сделанных через Claude Code, секреты встречаются в 3,2% случаев против 1,5% в среднем по GitHub (данные GitGuardian). Оговорка тут важнее самой цифры: по этим данным нельзя определить, кто вписал ключ, агент или человек. Часть утечек могла прийти из копипаста или из старого кода, так что 3,2% - это повод настроить защиту, а не приговор агентам.
Гарантии исполнения в CLAUDE.md нет по устройству. Правило лежит в середине разговора, а факт из середины длинного контекста языковая модель находит хуже, чем факт в начале или конце (Liu et al.). Инструкция, написанная в первый день работы над проектом, к сотому сообщению сессии оказывается ровно в этой мёртвой зоне.
Два случая показывают цену ставки на текст. В разборе описана ситуация, когда агент нарушил прямой запрет из CLAUDE.md и ради одной цифры для статьи запустил платную серию запросов к API. Второй случай жёстче: агент Replit летом 2025 года нарушил запрет заказчика на изменения и удалил рабочую базу с данными больше тысячи компаний (AIID). В обоих случаях правило было записано текстом.
Отсюда рабочая эвристика: если откат ошибки дороже пяти минут разговора с агентом, правило должен проверять код, а не CLAUDE.md. Уточнить формулировку в диалоге стоит пять минут. Отозвать ключ, вычистить историю git и объясняться с провайдером - заметно дороже. Сокращать и чистить CLAUDE.md полезно (про это есть отдельный разбор про экономию подписки и чистку CLAUDE.md), но короткая инструкция остаётся инструкцией.
Как работает PreToolUse в Claude Code
Claude Code hooks - это скрипты, которые Claude Code запускает на событиях сессии. Перед вызовом инструмента срабатывает PreToolUse: хук получает на stdin JSON-объект с полями tool_name и tool_input. Для Bash в tool_input приходит команда, которую агент собирается выполнить.
{
"tool_name": "Bash",
"tool_input": {
"command": "git add . && git commit -m \"fix\""
}
}
Формат входных данных и способы блокировки
Блокировка работает через код выхода. Завершились с 2 - вызов не выполняется, а текст из stderr агент получает как причину отказа и видит его в диалоге. Второй способ: выйти с 0 и вернуть JSON с permissionDecision: deny, он удобнее, когда решение нужно передать структурированно. Практическая разница в другом: блокирует только код 2. Скрипт, упавший с кодом 1, не остановит ничего.
Подключение хука в .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/secret_guard.py",
"timeout": 120
}
]
}
]
}
}
matcher ограничивает событие командами Bash: хук не дёргается на чтение файлов или поиск по коду. timeout: 120 - потолок в секундах на один запуск. Внутри хука разумно держать свой таймаут меньше этого значения, иначе Claude Code убьёт процесс раньше, чем скрипт успеет отчитаться.
Простой хук ключ пропустит. Если он срабатывает на "git commit" и сразу смотрит изменения, он видит пустой индекс: git add ещё не выполнен, файлы не проиндексированы. Добавьте сюда падение скрипта с кодом 1, и получится защита, которая никого не защищает. Поэтому проверять надо не факт команды, а состояние репозитория, о котором команда говорит.
Рабочий пример хука на Python: разбор составных команд и проверка индекса
Задача хука: поймать опасные команды (git commit, git push) и перед их выполнением прогнать сканер по тому, что реально попадёт в коммит. Правила удобно держать в JSON-файле .claude/rules.json, чтобы правки не требовали трогать код.
{
"rules": [
{
"match": "git (commit|push)",
"require": "bash .claude/hooks/secret_scan.sh",
"message": "В изменениях найдены секреты. Убери ключ из файлов и истории, потом повтори."
}
]
}
Разбор составных команд через shlex
Агент редко вызывает git одной командой. Типичная строка выглядит как "git add . && git commit -m \"fix\"" или "git status | head". Если искать подстроку "git commit" жадно по всей строке, часть вызовов потеряется, а часть сработает ложно. Команду разбивают на сегменты по &&, ||, ;, | и переводу строки, при этом кавычки учитываются, иначе "git commit -m \"a && b\"" распадётся не туда.
import shlex
SPLITTERS = set("&;|\n")
def segments(command):
# && || ; | и перевод строки, кавычки учтены
lex = shlex.shlex(command, posix=True, punctuation_chars="&;|\n")
lex.whitespace_split = True
lex.commenters = ""
parts, current = [], []
for token in lex:
if token and all(ch in SPLITTERS for ch in token):
if current:
parts.append(current)
current = []
else:
current.append(token)
if current:
parts.append(current)
return parts
print(segments('git add . && git commit -m "a && b"'))
Функция вернёт два сегмента: ["git", "add", "."] и ["git", "commit", "-m", "a && b"]. Дальше каждый сегмент проверяется по правилам из rules.json.
Проверка индекса, правок против HEAD и новых файлов
Хук срабатывает до git add, поэтому смотреть только git diff --cached нельзя: индекс пуст. Проверять нужно три источника сразу:
- git diff --cached - то, что уже проиндексировано;
- git diff HEAD - рабочее дерево против последнего коммита, включая правки без add;
- git ls-files --others --exclude-standard - новые файлы, которых git ещё не отслеживает.
Перед push рамка шире: проверять надо все коммиты, которых нет на удалённой ветке. Иначе ключ уедет внутри коммита, сделанного раньше, и вы отправите его одним push. Для этого годится git log @{u}..HEAD --name-only: он вернёт список файлов из коммитов, которых ещё нет на upstream.
Bash-скрипт сканирования на секреты
Сам поиск удобнее держать в отдельном bash-скрипте: его же потом подключают к git-хукам. Ниже пример с набором паттернов, список стоит расширить под ключи своих провайдеров.
#!/usr/bin/env bash
# .claude/hooks/secret_scan.sh
set -uo pipefail
PATTERN='(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'
STATUS=0
FILES=$(mktemp)
trap 'rm -f "$FILES"' EXIT
{
git diff --cached --name-only
git diff HEAD --name-only
git ls-files --others --exclude-standard
if [ "${1:-}" = "--push" ]; then
git log --name-only --pretty=format: '@{u}..HEAD'
fi
} 2>/dev/null | sort -u > "$FILES"
while IFS= read -r file; do
[ -f "$file" ] || continue
hits=$(grep -InE "$PATTERN" -- "$file" 2>/dev/null)
if [ -n "$hits" ]; then
printf 'Секрет в %s:\n%s\n' "$file" "$hits" >&2
STATUS=2
fi
done < "$FILES"
exit $STATUS
Скрипт печатает находки в stderr и завершается кодом 2, если что-то нашёл. Флаг -I в grep не даёт бинарным файлам ломать вывод, а сортировка снимает дубли, когда один файл попал в список из индекса и из правок против HEAD одновременно.
Вызов из Python-хука идёт через subprocess.run с таймаутом 100 секунд, то есть на 20 секунд меньше timeout хука в настройках: сканер должен успеть отчитаться до принудительного завершения.
import json, os, re, shlex, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # .claude/hooks -> корень проекта
def check(command):
rules = json.load(open(os.path.join(ROOT, ".claude/rules.json")))
for seg in segments(command):
line = " ".join(seg)
for rule in rules["rules"]:
if re.search(rule["match"], line):
args = ["--push"] if "push" in line else []
done = subprocess.run(
shlex.split(rule["require"]) + args,
cwd=ROOT, capture_output=True, timeout=100)
if done.returncode == 2:
sys.stderr.write(done.stderr.decode("utf-8", "replace"))
sys.stderr.write(rule["message"] + "\n")
return 2
return 0
if __name__ == "__main__":
payload = json.load(sys.stdin)
if payload.get("tool_name") != "Bash":
sys.exit(0)
command = payload.get("tool_input", {}).get("command", "")
sys.exit(check(command))
Функция segments берётся из примера выше. Обратите внимание на шаблон правила: "git (commit|push)" совпадает с обычной командой, но не с вызовом, где между git и подкомандой стоят флаги. Это ограничение разбирается в следующем разделе.
Ограничения PreToolUse: что хук не поймает
Хук видит команду, а не намерение. Он получает строку и сопоставляет её с шаблоном; всё, что не похоже на шаблон, проходит мимо.
Почему git -C и собственные скрипты обходят хук
Шаблон "git (commit|push)" не сработает на "git -C ../другой-проект push": между git и push стоит -C с путём, регулярное выражение не совпадёт, вызов уйдёт без проверки. Расширить шаблон можно, но проверка всё равно выполнялась бы не в том репозитории, куда реально уйдёт коммит.
Собственный скрипт обходит защиту ещё проще. Хук увидит "./deploy.sh", а внутри скрипта будут и git add, и git commit, и git push. Распознать опасное действие по имени файла нельзя.
Дополнительная защита: git-хуки pre-commit и pre-push
Родные git-хуки срабатывают внутри git и не зависят от того, как вызвана команда: через -C, через обёртку, из другого каталога. pre-commit выполняется перед созданием коммита, pre-push - перед отправкой. Подключить к ним тот же secret_scan.sh дешевле, чем поддерживать второй сканер. Бонус: git-хук знает свой репозиторий, поэтому вопрос "а в какой папке мы вообще проверяли" просто не возникает.
Схема связки такая: PreToolUse ловит то, что агент делает явно, и объясняет ему причину отказа человеческим текстом; git-хуки закрывают обходные пути, потому что работают на уровне самой операции с репозиторием. Про такой подход к контролю агента есть разбор трёх принудительных хуков для Claude Code, там же описаны блокировка коммитов без прогона тестов и баг секундной гранулярности bash, на который стоит посмотреть перед тем, как писать свои проверки.
Альтернативные подходы к изоляции секретов от агента
Проверка коммитов - не единственный слой. Есть подходы, которые убирают секрет из поля зрения агента или ограничивают его действия.
Claude Managed Agents держит учётные данные, нужные агенту, в отдельном хранилище, чтобы агент их не видел. Это снимает главный риск: то, чего агент не видел, он не вставит в файл. Ограничение в другом - подход работает внутри этой платформы и не покрывает локальный запуск и произвольные CLI-инструменты на вашей машине.
NVIDIA OpenShell - открытое ПО, которое контролирует, что агент может выполнять и к чему получать доступ. Логика по умолчанию запрещающая: без разрешающего правила блокируется всё, проверяется каждый инструмент, который агент пытается использовать, а правила применяются к файлам, сетевым соединениям и данным. Такой слой отвечает на вопрос "куда агенту можно", а не "что попало в коммит". Политика доступа не помешает модели аккуратно вписать ключ, который вы сами положили в файл.
Третий пример показывает типичную ловушку блок-листов. MCAIA - плагин для Paper-сервера Minecraft, который подключает чат Minecraft к Google Gemini и умеет выполнять команды от имени консоли сервера. Настраиваемый блок-лист команд авторы называют мерой предосторожности, а не полной границей безопасности. К хукам Claude Code это отношения не имеет, но логика та же: список запретов работает только против того, что в него попало.
Итог: когда правило в CLAUDE.md, а когда - код
Критерий простой. Если откат ошибки дороже пяти минут разговора с агентом, правило должен проверять код, а не CLAUDE.md. Дорогой откат - это утёкший ключ, удалённые данные, счёт за API, публикация, которую нельзя отменить. Для таких действий пишут хук.
Для утечки секретов одного PreToolUse мало: он видит команду, а не намерение, и обходные вызовы проходят мимо. Рабочая связка - PreToolUse плюс родные git-хуки pre-commit и pre-push на одном сканере.
Чек-лист:
- Добавьте в .claude/settings.json секцию hooks с событием PreToolUse, matcher "Bash" и timeout 120.
- Опишите правила в .claude/rules.json, покрыв и push, и commit.
- Проверьте, что сканер смотрит индекс (git diff --cached), правки против HEAD (git diff HEAD) и новые файлы (git ls-files --others --exclude-standard).
- Для push добавьте проверку коммитов, которых нет на удалённой ветке.
- Убедитесь, что скрипт возвращает именно код 2 при находке: при падении с кодом 1 блокировки не будет.
- Поставьте те же проверки в git-хуки pre-commit и pre-push, чтобы закрыть git -C и вызовы через свои скрипты.
Начать стоит с одной команды, которая дороже всего в откате, и одного теста: положите в пустой файл строку вида "sk-..." и попробуйте закоммитить. Если хук не остановил агента, читать дальше нечего, чините проверку.