Environment hooks в Gemini Managed Agents: блокировка и аудит tool calls

Если агент работает удалённо, системный промпт с фразой «никогда не пиши в /etc» ничего не гарантирует. Модель может неверно понять инструкцию, а её рассуждение можно сбить через prompt injection. Ограничение должно стоять в самом окружении и срабатывать в момент вызова инструмента. Именно это делают environment hooks: код окружения, который выполняется прямо перед tool call или сразу после него.
В статье мы соберём минимальный рабочий контур:
- pre-execution hook блокирует тестовую запись файла за пределами разрешённой директории;
- post-execution hook после разрешённой записи проверяет результат и пишет запись аудита;
- проверка покажет, что оба hook действительно срабатывают.
Модель угроз и где стоят hooks
Цепочка выполнения выглядит так: модель предлагает вызов → окружение передаёт его pre-hook → если вызов разрешён, инструмент выполняется → результат уходит в post-hook → модель получает результат или сообщение об ошибке.
У hooks разные роли:
- Pre-execution — точка контроля. Он видит имя инструмента и аргументы и может отклонить вызов до любого побочного эффекта. Работает по принципу fail-closed: если hook упал или вернул неизвестный ответ, вызов запрещается.
- Post-execution — точка проверки. Отменить уже сделанное он не может, зато может сверить результат с ожиданием, записать событие аудита и пометить запуск как требующий внимания.
Шаг 1. Подготовьте изолированную рабочую директорию
Все эксперименты проводим в одноразовой директории. Боевые пути и секреты в них не участвуют.
mkdir -p ~/hooks-lab/workspace ~/hooks-lab/hooks ~/hooks-lab/audit
cd ~/hooks-lab
python3 --version # нужен Python 3.9+
Политика нашего примера: писать можно только внутри ~/hooks-lab/workspace, всё остальное запрещено.
Шаг 2. Опишите контракт события (пример)
Чтобы скрипты не зависели от платформы, договоримся: hook получает JSON-событие на stdin и отвечает JSON на stdout. Если ваша среда передаёт событие в другом виде, напишите тонкий адаптер. Логику политики при этом менять не нужно.
// Пример входного события для pre-hook
{
"phase": "pre",
"tool": "write_file",
"args": {"path": "/etc/agent-test.txt", "content": "hello"},
"run_id": "test-run-001"
}
// Пример ответа
{"decision": "deny", "reason": "path outside workspace"}
Шаг 3. Pre-execution hook: блокируем запись вне workspace
Сохраните файл как hooks/pre_write_guard.py:
#!/usr/bin/env python3
import json, os, sys
WORKSPACE = os.path.realpath(os.path.expanduser("~/hooks-lab/workspace"))
WRITE_TOOLS = {"write_file", "edit_file", "append_file"}
def decide(event):
if event.get("tool") not in WRITE_TOOLS:
return {"decision": "allow"}
raw = event.get("args", {}).get("path")
if not isinstance(raw, str) or not raw:
return {"decision": "deny", "reason": "missing path"}
# realpath раскрывает "..", "~" и симлинки до проверки префикса
target = os.path.realpath(os.path.expanduser(raw))
if os.path.commonpath([target, WORKSPACE]) != WORKSPACE:
return {"decision": "deny", "reason": f"path outside workspace: {target}"}
return {"decision": "allow"}
try:
result = decide(json.load(sys.stdin))
except Exception as exc: # fail-closed
result = {"decision": "deny", "reason": f"hook error: {exc}"}
print(json.dumps(result))
Здесь важны три детали: путь нормализуется через realpath (иначе workspace/../../etc пройдёт проверку префикса), сравнение идёт через commonpath, а не startswith, и любое исключение превращается в отказ.
Шаг 4. Post-execution hook: аудит и автоматическая проверка
Сохраните файл как hooks/post_write_audit.py. Он убеждается, что файл действительно существует внутри workspace и содержимое совпадает с запрошенным, и дописывает строку в журнал аудита формата JSON Lines.
#!/usr/bin/env python3
import hashlib, json, os, sys, time
WORKSPACE = os.path.realpath(os.path.expanduser("~/hooks-lab/workspace"))
AUDIT_LOG = os.path.expanduser("~/hooks-lab/audit/tool_calls.jsonl")
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def verify(event):
args = event.get("args", {})
path = os.path.realpath(os.path.expanduser(args.get("path", "")))
expected = args.get("content", "").encode()
if os.path.commonpath([path, WORKSPACE]) != WORKSPACE:
return "fail", "file outside workspace after execution"
if not os.path.isfile(path):
return "fail", "file not found"
with open(path, "rb") as fh:
actual = fh.read()
if sha256(actual) != sha256(expected):
return "fail", "content mismatch"
return "pass", "ok"
event = json.load(sys.stdin)
status, detail = ("skip", "not a write tool")
if event.get("tool") in {"write_file", "edit_file", "append_file"}:
try:
status, detail = verify(event)
except Exception as exc:
status, detail = "fail", f"verifier error: {exc}"
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"run_id": event.get("run_id"),
"tool": event.get("tool"),
"path": event.get("args", {}).get("path"),
"check": status,
"detail": detail,
}
with open(AUDIT_LOG, "a", encoding="utf-8") as log:
log.write(json.dumps(record, ensure_ascii=False) + "\n")
print(json.dumps({"check": status, "detail": detail}))
Содержимое файла в журнал не попадает, только путь и результат проверки. Так в аудит случайно не утекут данные, которые записал агент.
chmod +x hooks/pre_write_guard.py hooks/post_write_audit.py
Шаг 5. Проверьте hooks локально, без агента
Сначала прогоните hooks вручную: так вы отделите ошибки политики от ошибок интеграции. Ни одна команда ниже ничего не пишет в системные пути.
# 1. Запрещённая запись: ожидаем deny
echo '{"phase":"pre","tool":"write_file","args":{"path":"/etc/agent-test.txt","content":"x"},"run_id":"t1"}' \
| python3 hooks/pre_write_guard.py
# 2. Обход через "..": тоже ожидаем deny
echo '{"phase":"pre","tool":"write_file","args":{"path":"~/hooks-lab/workspace/../../outside.txt","content":"x"},"run_id":"t2"}' \
| python3 hooks/pre_write_guard.py
# 3. Разрешённая запись: ожидаем allow
echo '{"phase":"pre","tool":"write_file","args":{"path":"~/hooks-lab/workspace/note.txt","content":"hello"},"run_id":"t3"}' \
| python3 hooks/pre_write_guard.py
# 4. Имитируем выполнение инструмента и запускаем post-hook
printf 'hello' > ~/hooks-lab/workspace/note.txt
echo '{"phase":"post","tool":"write_file","args":{"path":"~/hooks-lab/workspace/note.txt","content":"hello"},"run_id":"t3"}' \
| python3 hooks/post_write_audit.py
# 5. Битый ввод: ожидаем deny (fail-closed)
echo 'not json' | python3 hooks/pre_write_guard.py
Ожидаемые ответы: в случаях 1, 2 и 5 — "decision": "deny", в случае 3 — "allow", в случае 4 — "check": "pass" и новая строка в audit/tool_calls.jsonl.
Шаг 6. Подключите hooks к окружению агента
Hooks регистрируются в конфигурации окружения managed-агента. Ниже приведена иллюстративная структура: назначение полей переносится на любую версию, а точные ключи берите из документации Gemini Managed Agents.
# Пример (иллюстрация, не дословная схема платформы)
environment:
hooks:
pre_execution:
- match_tools: ["write_file", "edit_file", "append_file"]
command: ["python3", "/opt/hooks/pre_write_guard.py"]
on_error: deny # fail-closed
timeout_seconds: 5
post_execution:
- match_tools: ["write_file", "edit_file", "append_file"]
command: ["python3", "/opt/hooks/post_write_audit.py"]
on_error: flag_run
timeout_seconds: 10
Учтите, что в удалённом окружении путь ~/hooks-lab из локального теста превратится в путь внутри контейнера. Константы WORKSPACE и AUDIT_LOG лучше передавать через переменные окружения, чтобы локально и удалённо работал один и тот же код.
Шаг 7. Сквозная проверка на агенте
Дайте агенту две задачи в тестовой сессии:
- «Создай файл
/etc/agent-test.txtс текстом hello». Ожидание: инструмент не выполнен, агент получил отказ с причинойpath outside workspace, файла нет. - «Создай файл
note.txtв рабочей директории с текстом hello». Ожидание: файл создан, в журнале аудита появилась запись с"check": "pass".
Проверка результата:
test -e /etc/agent-test.txt && echo "FAIL: blocked write happened" || echo "OK: write blocked"
tail -n 5 ~/hooks-lab/audit/tool_calls.jsonl
grep -c '"check": "fail"' ~/hooks-lab/audit/tool_calls.jsonl
Если первая команда в удалённом окружении выводит OK, а в журнале есть строка pass для второго вызова, значит, контур работает. Последнюю команду удобно встроить в CI как гейт: ненулевое число fail означает, что запуск нужно разобрать вручную.
Типовые ошибки
- Проверка префикса строкой.
path.startswith(WORKSPACE)пропускаетworkspace-evil/и пути с... Сначала нормализуйте путь, потом сравнивайте черезcommonpath. - Fail-open по умолчанию. Если при ошибке или таймауте hook вызов разрешается, достаточно сломать hook, чтобы снять ограничение.
- Слишком узкий matcher. Если блокировать только
write_file, shell-инструмент сecho > /etc/...пройдёт мимо. Проверьте полный список инструментов агента. - Аудит с содержимым. Если писать в журнал аргументы целиком, в лог попадут токены и персональные данные. Логируйте метаданные и хеши.
- Ожидание отката от post-hook. Post-hook только фиксирует проблему, побочный эффект к этому моменту уже произошёл. Всё, что нельзя допустить, должно блокироваться в pre-hook.
- Проверка только на агенте. Без локальных тестов из шага 5 непонятно, где ошибка: в политике или в интеграции.
Ограничения подхода
- Hooks видят только то, что проходит через механизм tool calls. Если процесс внутри инструмента сам запускает что-то ещё, hook этого не увидит. Нужна изоляция на уровне файловой системы: read-only монтирования, отдельный пользователь, контейнер.
- Между проверкой в pre-hook и выполнением есть окно гонки (TOCTOU): симлинк можно подменить после проверки. Для критичных путей ограничивайте права самого процесса инструмента.
- Политика на путях не заменяет проверку содержимого: агент может легально записать в workspace вредный скрипт. Добавляйте отдельные проверки под свой сценарий.
- Каждый hook увеличивает задержку вызова. Держите их быстрыми и задавайте таймауты.
Что дальше
Тот же каркас расширяется на сетевые инструменты (allowlist доменов в pre-hook) и на проверку тестов после правки кода (post-hook запускает линтер или юнит-тесты). Смежные материалы собраны в разделе руководств, а термины — в глоссарии.