Узнайте, как настроить защитные ограждения для Claude Code, чтобы предотвратить опасные действия ИИ-агента. Практические примеры и готовые правила.
Три месяца я использую Claude Code как основного помощника для разработки. Он пишет Ansible-плейбуки, на которые у меня ушла бы неделя. Он читает кодовую базу быстрее меня. Он действительно хорош. Но однажды он захотел сделать force-push в main — и у него была очень веская причина.
Остановитесь на секунду. Это ключевой момент. Ребейз завис, и force-push решил бы проблему. Каждое звено в цепочке рассуждений агента было логичным. Он не был небрежен, не галлюцинировал, не «дрейфовал». Он принял локально правильное решение с глобальными последствиями — именно ту ошибку, которую человек-ревьюер замечает хуже всего, потому что диф выглядит нормально.
Агент видел команду, но не видел кратер, который она оставит.
Сначала я пытался проверять всё: каждый диф, каждую команду, держа руку на Ctrl-C, как отец у лестницы с малышом. Это не масштабируется. Причина проста: объём проверки растёт вместе с объёмом того, что пишет агент. А этот объём растёт только в одну сторону — вверх.
Поэтому я изменил подход: вместо проверки того, что агент делает, я записал, что ему нельзя делать никогда. И хорошая новость: этот список оказался коротким. Не «коротким для политики безопасности», а коротким, как записка на салфетке.
Вот мой список:
git push --force origin main.rm -rf "$BUILD_DIR/" выполняется на машине, где BUILD_DIR никогда не была задана.package-lock.json, потому что это файл, где видно номер версии..skip, и CI зеленеет.cat .env, чтобы «просто посмотреть, какие переменные есть».Последний пункт — мой любимый, я к нему вернусь. Ни один из этих случаев не является глупостью агента. Каждый — разумное действие существа, которое не видит дальше собственного носа.
Многие не знают, что в Claude Code есть хук PreToolUse. Он срабатывает до любого вызова инструмента. Ваш скрипт получает весь контекст через stdin:
{
"session_id": "abc123",
"cwd": "/home/rabih/app",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "git push --force origin main"
}
}
И вы можете отказать:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Это force-push в main, общую ветку."
}
}
Самое удивительное: строка permissionDecisionReason — агент её читает и реагирует. Если сказать «заблокировано», он пожмёт плечами и попробует чуть другой синтаксис, как кот, проверяющий закрытую дверь. Если сказать «измени манифест и запусти pnpm add» — он сделает именно это, с первой попытки, без споров.
Это переворачивает представление. Отказ — не просто забор, это момент обучения с максимальным соотношением сигнала к шуму, потому что он происходит ровно в ту секунду, когда агент собирался ошибиться. Никто не читает документацию в этот момент. Все читают ошибку.
Поэтому каждое моё правило должно отвечать на два вопроса: что не так и что делать вместо этого.
Все они живут в репозитории claude-guardrails.
| Правило | Блокирует |
|---|---|
| secrets-never-land-in-source | Литералы, похожие на секреты, в исходном коде |
| secret-files-stay-out-of-context | Чтение .env, *.pem, ~/.aws/credentials в сессию |
| secrets-are-not-staged | git add -A в репозитории, где .env не в .gitignore |
| shared-branches-are-not-rewritten | git push --force в main, develop, release/* |
| uncommitted-work-is-not-discarded | git reset --hard, git clean -fd, git stash drop |
| verification-hooks-are-not-bypassed | --no-verify, HUSKY=0, --no-gpg-sign |
| unexpanded-variables-in-destructive-paths | rm -rf "$DIR/" где $DIR может быть пустым |
| remote-code-is-not-piped-to-a-shell | curl ... | sh |
| committed-migrations-are-immutable | Редактирование уже закоммиченной миграции |
| destructive-sql-needs-a-where | Безграничные DELETE/UPDATE, ad-hoc TRUNCATE |
| cluster-targets-are-explicit | Деструктивный kubectl без --context |
| tests-are-not-silenced | Добавление .skip, @Disabled, continue-on-error: true |
| lockfiles-are-generated-not-edited | Ручное редактирование package-lock.json и подобных |
Ноль зависимостей. Нечего настраивать. Node читает JSON и иногда говорит «нет».
Моим первым порывом было защитить запись — не дать ключу попасть в файл. Но потом я подумал и понял, что всё наоборот. Путь записи проходит через код-ревью: кто-то рано или поздно посмотрит диф. А путь чтения не имеет ничего. Когда агент запускает cat .env, чтобы проверить, какие переменные есть, он получает разумный ответ на разумный вопрос — и все значения из файла оказываются в транскрипте. Транскрипты хранятся, синхронизируются, иногда вставляются в баг-репорт добрым человеком.
На диске ничего не меняется, git diff пуст, а ваши учётные данные уже покинули здание. Поэтому правило блокирует чтение и предлагает вместо этого: grep -o "^[A-Z_]*=" .env — тот же вопрос, тот же ответ, но без части, которая портит вам неделю.
Я хотел правило, которое запрещает редактировать миграцию, уже выполненную базой данных. Проблема: хук не знает, что выполнила ваша продакшн-база. Это Node-скрипт с JSON-объектом, он не может позвонить в Postgres. Но он может задать git один вопрос:
execFileSync('git', ['ls-files', '--error-unmatch', '--', pathspec], { cwd, stdio: 'ignore' });
Отслеживается ли этот файл? Вот и вся эвристика — и она хорошая, потому что раз миграция закоммичена, значит, кто-то где-то её почти наверняка выполнил. Приятный побочный эффект: миграция, которую вы ещё пишете, не отслеживается, поэтому правило невидимо, пока вы работаете, и непоколебимо, как только вы закончили. Индекс git проводит эту линию бесплатно, и мне не пришлось изобретать ни одной настройки.
git add .env — это нормально, честно. Он видимый, он прямо в истории, вы его заметите. А вот git add -A в репозитории, где забыли добавить .env в .gitignore — он тихо добавит файл в индекс вместе с сорока другими, коммит будет называться «add feature», никто не посмотрит, и всё окажется на GitHub.
Поэтому это правило вообще не сопоставляет команду по шаблону. Оно спрашивает git, что реально попадёт в индекс при массовом добавлении:
execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8' })
Мне тихо приятно, что файлы из .gitignore никогда не появляются в этом выводе. Значит, в правильно настроенном репозитории правило полностью и навсегда молчит. Оно говорит только с теми репозиториями, у которых есть проблема. Правило, которого никто не замечает, — правило, которое никто не удалит. Это свойство ценнее самой проверки.
kubectl delete pod api-7f9d. Какой это кластер? Я не знаю. Вы не знаете. Агент не знает. Хук точно не знает, потому что ответ живёт в конфигурационном файле, которого нет в полезной нагрузке.
Каждое другое правило в этом репозитории считывает намерение из вызова инструмента. Это не может. Поэтому оно делает единственное честное: отказывает, пока вы не укажете --context и не заставите команду явно сказать, что она собирается менять. Оно не блокирует ошибку, оно блокирует двусмысленность — команду, чей транскрипт не зафиксирует, что она сделала. Думаю, это самое полезное правило в наборе, и оно единственное, которое работает, признавая, что не видит.
Если этот репозиторий вообще работает, большинство правил в нём рано или поздно напишут незнакомцы. Это меняет задачу полностью.
Кто-то допустит баг. Если его баг сломает мой git push, вся идея умрёт. Поэтому каждое правило обёрнуто в собственный try/catch, и исключение трактуется как «нет мнения» с ворчанием в stderr. Да, это значит, что упавшее правило открывает доступ — для инструмента безопасности это звучит непростительно, пока вы не представите альтернативу: одна неудачная правка — и никто в мире не сможет коммитить, пока её не откатят. Плагин удалят, а удалённый плагин не защищает ничего. Открытый отказ позволяет остаться установленным. Установленность — это вся игра.
Диспетчер выдаёт JSON только для отказа. permissionDecision с радостью примет «allow», что затоптало бы ваши собственные настройки разрешений — а этот плагин не имеет права так делать. У него один голос, и этот голос — «нет».
Один ложный срабатывание на правильной работе — и плагина нет к обеду. Поэтому каждое правило сопровождается исполняемыми примерами:
examples: {
blocked: [
{ tool_name: 'Bash', tool_input: { command: 'git push --force origin main' } }
],
allowed: [
{ tool_name: 'Bash', tool_input: { command: 'git push --force-with-lease origin main' } },
{ tool_name: 'Bash', tool_input: { command: 'git push --force origin feature/x' } }
]
}
--force-with-lease против --force. .env.example против .env. docs/package-lock.md против package-lock.json. Вот где живут ложные срабатывания, поэтому именно это нужно записывать.
И эти примеры и есть тест-сьют. npm test проходит по каждому правилу и проверяет оба списка. Это дизайн-решение, которым я доволен больше всего, и оно потребовало больше всего времени, чтобы его увидеть. Очевидная версия репозитория имеет папку guards/ и папку test/, и контрибьюторы пишут оба. Но они не пишут. Никто не пишет второй файл. Никогда. Встраивание тестов в определение правила означает, что контрибуция — это один файл, и этот файл недействителен, пока вы не укажете в коде, что он намеренно пропускает.
Это Node, а не shell. Shell-версии были бы втрое короче и зависели бы от jq. Я писал это на Windows. Значительная часть людей, которым это нужно, не сидят в Unix-шелл, а ограждение, которое защищает только разработчиков с уже хорошими инструментами, — довольно бесполезное ограждение. Node поставляется с Claude Code, зависимость уже оплачена. Кстати, весь плагин имеет ноль зависимостей, так что у него нет lockfile — что забавно для проекта, который поставляет правило для lockfile.
Единица контрибуции — один файл. Скопируйте guards/_template.js, измените пять вещей, откройте PR. Десять минут, максимум.
module.exports = {
id: 'your-guard-id',
title: 'Краткая формулировка правила',
prevents: 'Конкретная проблема, которая случается, когда никто не смотрит.',
tools: ['Bash'],
check(input) {
// верните { reason } для отказа или null, чтобы не мешать
},
examples: {
blocked: [ /* полезные нагрузки, которые должны быть отклонены */ ],
allowed: [ /* полезные нагрузки, которые должны проходить */ ]
}
};
Положите его в guards/ — и он заработает. Никакого реестра обновлять не нужно, диспетчер просто читает директорию.
Одно поле решает, примут ли ваш PR, — это prevents. «Это плохая практика» — не prevents. Если вы не можете закончить предложение «когда это случилось в последний раз, что сломалось…», у вас стилистическое предпочтение, и таким предпочтениям место в вашем собственном CLAUDE.md.
Кстати, вот почему я остановился на тринадцати. Я вижу очертания ещё четырёх правил, которые напрашиваются. Но это уже другая история.
Прямо сейчас, не откладывая, сделайте три вещи. Во-первых, возьмите мой список из тринадцати запретов и адаптируйте под свой проект — вы удивитесь, сколько из них применимы к вашей кодовой базе. Во-вторых, добавьте хотя бы одно правило, которое блокирует чтение .env — это самое дешёвое и эффективное улучшение безопасности. В-третьих, настройте правило для git push --force с подсказкой использовать --force-with-lease. Эти три шага займут не больше часа, а сэкономят вам неделю боли и, возможно, спасут ваш репозиторий от катастрофы.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →