Tool call от модели — недоверенный ввод, даже если модель получила системный prompt «не делай ничего опасного». Если агент умеет запускать Python, читать workspace и ходить в сеть, prompt injection превращается в путь к credentials, metadata endpoints и внутренним API. Проверять строку команды blacklist-ом бесполезно: тот же эффект достигается через другой бинарник, библиотеку или /proc.
Модель угроз на один запуск
Агент анализирует присланный архив и запускает тесты. В README спрятано указание прочитать environment и отправить его на внешний URL. Без изоляции процесс наследует токены backend-а, видит домашний каталог worker-а, Docker socket и сеть production namespace.
Защита строится не вокруг «намерения» модели, а вокруг capabilities процесса:
untrusted request
-> policy decision (identity, tool, arguments, approval)
-> disposable sandbox (filesystem/process/network/resource limits)
-> narrow broker APIs for allowed side effects
-> immutable audit event + artifactsSandbox ограничивает произвольный код. Policy engine решает, разрешён ли конкретный tool call. Broker хранит credentials и выполняет узкие операции. Audit log фиксирует решение и эффект. Ни один слой не заменяет остальные.
Docker как базовая, но не абсолютная граница
Контейнер использует общее kernel host-а. Для враждебного multi-tenant кода рассмотрите более сильную границу — отдельную VM/microVM или sandboxed runtime — после threat model. Но и обычный Docker можно настроить существенно безопаснее дефолта.
services:
runner:
image: registry.example/agent-runner@sha256:REPLACE_WITH_DIGEST
user: "65532:65532"
read_only: true
network_mode: none
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 128
mem_limit: 512m
cpus: 1.0
tmpfs:
- /tmp:rw,nosuid,nodev,noexec,size=64m
volumes:
- type: bind
source: ./job-input
target: /work/input
read_only: true
- type: volume
source: job-output
target: /work/outputЧисла — параметры конкретного примера; лимиты выводятся из профиля workload и проверяются нагрузкой. Существенны свойства:
- image закреплён digest-ом, внутри нет package manager, shell и сетевых клиентов без необходимости;
- non-root UID; rootless Docker или user namespace дополнительно уменьшают последствия container escape;
- root filesystem read-only, input read-only, output — отдельный одноразовый volume;
- все capabilities сброшены,
no-new-privilegesзапрещает повышение привилегий через setuid/file capabilities; - PID, memory и CPU ограничены; иначе fork bomb или allocation storm бьют host;
- Docker socket, host PID/IPC/network namespace, devices и произвольные host mounts не передаются;
- seccomp остаётся включённым и по возможности сужается под фактические syscalls; AppArmor/SELinux ограничивает доступ на уровне LSM.
--privileged, bind mount /, /var/run/docker.sock или membership в docker group фактически отдают host. Это не «исключение для удобства».
cgroups v2: лимит должен наблюдаться в kernel
Compose — интерфейс, enforcement делает cgroup. На cgroup v2 проверяйте реальные файлы sandbox cgroup:
memory.max жёсткий предел памяти
memory.events oom, oom_kill и достижение max
cpu.max quota и period CPU
pids.max предел процессов
pids.events число отказов из-за max
cgroup.kill завершение всего поддереваПосле запуска тест намеренно превышает каждый ресурс, а harness проверяет соответствующий counter. Наличие YAML-поля не доказывает, что runtime применил ограничение. Учитывайте, что OOM может убить процесс внутри sandbox; host и соседние jobs должны остаться живы.
Timeout обязан убивать дерево процессов
asyncio.wait_for(proc.wait()) прекращает ожидание, но не гарантирует завершение дочерних процессов. Запускайте job в новой session/process group, при deadline посылайте сигнал группе, затем эскалируйте. В контейнере отдельный init (--init) корректно reap-ит orphan/zombie processes.
import asyncio
import os
import signal
async def run_job(argv: list[str], timeout_s: float):
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, # PGID == PID
env={"PATH": "/usr/local/bin:/usr/bin", "LANG": "C.UTF-8"},
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout_s)
except TimeoutError:
os.killpg(proc.pid, signal.SIGTERM)
try:
await asyncio.wait_for(proc.wait(), 2.0)
except TimeoutError:
os.killpg(proc.pid, signal.SIGKILL)
await proc.wait()
raise JobTimeout
return proc.returncode, bounded(stdout), bounded(stderr)Grace period — конфигурация workload, не универсальная норма. Есть race между выходом process group и killpg; production harness обрабатывает ProcessLookupError. Надёжнее дополнительный внешний watchdog, который при потере worker-а останавливает контейнер/cgroup целиком. Ограничивайте stdout/stderr во время чтения: communicate() иначе способен исчерпать память harness-а.
Deny-by-default egress
network_mode: none — правильный режим для задач без сети. Если доступ нужен, не открывайте весь интернет. Поместите sandbox в отдельную сеть без прямого маршрута наружу и разрешите только egress proxy/broker. Proxy проверяет destination по allowlist и аутентифицирует job identity.
sandbox netns -> egress proxy -> allowlisted HTTPS origins
X RFC1918/internal ranges
X link-local/metadata endpoints
X arbitrary DNS/UDPОдной проверки hostname недостаточно: DNS rebinding, redirects и IPv4/IPv6 представления обходят наивный фильтр. Proxy резолвит имя сам, проверяет каждый полученный IP и каждый redirect, запрещает private, loopback, link-local и metadata ranges, ограничивает методы и объём ответа. DNS также идёт через контролируемый resolver. TLS interception не обязателен для destination policy; если нужен контроль path/body, предпочтительнее прикладной broker с фиксированным API.
Allowlist api.example.com не означает выдачу агенту API key. Секрет остаётся в broker-е; sandbox получает короткоживой job credential с audience/scope, либо вообще не получает credential и вызывает операцию через authenticated local channel.
Permissions: capability на одну операцию
Модель не должна выбирать произвольный URL, SQL или shell flags. Tool contract сужает пространство действий, policy проверяет уже parsed arguments:
{
"subject": {"tenant_id": "t1", "user_id": "u7"},
"agent": {"name": "support-refund", "version": "git:…"},
"tool": "refund.create",
"resource": {"order_id": "o42", "tenant_id": "t1"},
"arguments_digest": "sha256:…",
"requested_effect": {"currency": "EUR", "amount_minor": 1299},
"approval_id": "ap_…",
"operation_id": "refund:o42"
}Policy сопоставляет tenant ownership, роль пользователя, разрешённый tool/version, поля resource, лимиты из бизнес-системы, свежесть approval и совпадение digest. Проверка должна выполняться непосредственно перед эффектом; решение на этапе planning может устареть. Credentials разделяйте по tool и environment. Read capability не должна автоматически давать write, list — read, а staging — production.
Blast radius задаётся архитектурой
Ограничьте ущерб независимо по осям:
- данные: отдельный tenant scope, read-only snapshot, row-level/business authorization в broker-е;
- время: deadline и cancellation всего process tree;
- ресурсы: cgroup CPU/memory/PID, quota на disk/output, bounded logs;
- сеть: no network либо allowlisted proxy без internal routes;
- действия: узкие tools, idempotency key, approval для необратимых эффектов;
- credentials: short-lived, scoped, не наследуются из environment оркестратора;
- параллелизм и деньги: quotas на tenant/run/tool в scheduler-е.
Kill switch должен останавливать новые выдачи capability и отзывать broker credentials; завершение только одного worker-а не блокирует уже запущенные sandboxes.
Audit log — не application log
Audit event фиксирует субъект, policy decision, запрошенный и фактический эффект, но не секреты и полный документ:
{
"event_id": "01J…",
"occurred_at": "…",
"trace_id": "…",
"run_id": "…",
"step_id": "refund",
"actor": {"type": "agent", "id": "support-refund", "version": "git:…"},
"on_behalf_of": {"user_id": "u7", "tenant_id": "t1"},
"tool": {"name": "refund.create", "version": "3"},
"request_digest": "sha256:…",
"policy": {"bundle_version": "git:…", "decision": "allow", "reasons": ["owner", "approved"]},
"approval_id": "ap_…",
"operation_id": "refund:o42",
"outcome": "succeeded",
"external_ref": "re_…",
"previous_event_hash": "sha256:…",
"event_hash": "sha256:…"
}Пишите событие deny до вызова, а для разрешённой операции — decision и outcome. Если audit sink недоступен, поведение зависит от риска: read-only диагностика может продолжиться с durable local spool; денежный side effect обычно должен fail closed, если невозможно гарантировать запись. Это явно задаётся policy.
Append-only таблица сама по себе не tamper-proof: DBA может изменить строки. Hash chaining обнаруживает разрыв, но не защищает от переписывания всей цепочки. Нужны ограниченные write credentials, запрет update/delete, регулярные подписанные anchors во внешнем WORM/object-lock хранилище и независимый контроль доступа. Payload и audit metadata имеют retention/minimization policy.
Негативные тесты
Позитивный pytest на print(2 + 2) ничего не доказывает. В CI и на staging запускайте вредоносный corpus и проверяйте enforcement:
read /etc/shadow -> permission denied
read orchestrator environment/secret -> secret отсутствует
write /work/input or /usr -> read-only filesystem
connect public IP/domain -> blocked без allowlist
connect 127.0.0.1, RFC1918, link-local -> blocked
DNS rebinding / redirect to private IP -> blocked proxy
fork bomb -> pids.max event, host жив
allocate over memory.max -> cgroup OOM, соседи живы
spawn child that ignores SIGTERM -> вся group/cgroup уничтожена
fill stdout and output volume -> bounded/truncated, host disk жив
invoke unapproved tool/other tenant -> policy deny + audit event
reuse expired approval/token -> deny
mount namespace, ptrace, raw socket -> denied capability/seccomp/LSMТесты сверяют не только exit code, но и отсутствие side effect, cgroup counters, proxy decision и audit event. После timeout не должно оставаться процессов, сокетов, mount-ов и volume с секретами.
Failure modes
- Blacklist shell-команд. Обход через Python,
/proc, symlink, иной бинарник. Ограничивайте syscalls, filesystem, credentials и сеть. - Контейнер запущен root с Docker socket. Sandbox отсутствует фактически.
- Таймаут убил parent. Дочерний miner продолжает работать. Убивайте process group/cgroup и проверяйте cleanup.
- Egress proxy доверяет первому DNS answer. Redirect/rebinding уводит во внутреннюю сеть. Проверяется каждый hop и IP.
- Один production credential на все tools. Компрометация чтения даёт запись. Credentials дробятся по capability.
- Audit содержит prompt и токены. Сам журнал становится утечкой. Храните digests/references, чувствительные артефакты — отдельно.
- Resource limit написан, но не применён. Проверяйте cgroup filesystem и негативные тесты на runtime.
- Sandbox используется повторно. Артефакты и процессы переходят между tenants. Один job — одно окружение с гарантированным teardown.
Практический сценарий: анализ недоверенного репозитория
Оркестратор скачивает commit по разрешённому source ID, проверяет digest и монтирует snapshot read-only. Одноразовый runner без сети запускает тесты под non-root UID. Результаты пишутся только в bounded output volume. Запрос к package registry невозможен напрямую; если зависимости нужны, отдельный fetcher заранее собирает проверенный cache. По deadline watchdog убивает контейнер/cgroup. В audit остаются image digest, source digest, policy version, resource profile, exit reason и digests артефактов — без содержимого репозитория.
Правило главы
Недоверенный код получает не «доступ к машине с ограничениями», а минимальный набор capabilities на один job. Filesystem, process tree, cgroup, network, credentials, policy broker и audit проверяются независимо; любой разрешённый side effect должен оставаться ограниченным и восстанавливаемым при компрометации модели.
Чеклист главы
- Runner одноразовый, non-root, read-only, без capabilities и без host/Docker socket mounts.
- CPU, memory, PID, disk/output и log volume ограничены и подтверждены cgroup-негативными тестами.
- Timeout уничтожает process group/cgroup; orphan processes после job отсутствуют.
- Egress deny-by-default; proxy проверяет DNS, IP и redirects, internal/metadata ranges закрыты.
- Секреты не наследуются; broker выдаёт минимальную операцию и повторно проверяет policy перед эффектом.
- Tenant/resource ownership, approval digest, tool version и idempotency key входят в решение.
- Audit append-only, связан с trace/run и внешне anchored; недоступность sink имеет fail-open/fail-closed policy.
- Негативный corpus выполняется в CI/staging и проверяет отсутствие эффекта, а не только код возврата.
Практикум
Задача 36.1 — стенд для запуска недоверенного Python. ⭑⭑⭑
Соберите runner image, Docker/Compose profile, Python harness с process-group timeout, отдельный egress proxy и append-only audit sink. Запускайте пользовательский архив read-only, возвращайте bounded stdout и output artifacts.
Критерии приёмки:
- Все негативные тесты из главы автоматизированы; host и параллельный benign job остаются работоспособны.
-
memory.events/pids.eventsподтверждают срабатывание лимитов. - После timeout отсутствуют процессы job; SIGTERM-игнорирующий descendant уничтожается.
- Public и private egress закрыт по умолчанию; разрешён только тестовый origin через proxy.
- Sandbox не видит credentials оркестратора и Docker socket.
- Deny и allow/outcome создают связанные audit events; модификация старой записи обнаруживается anchor verification.
- Повторный job не видит файлов, процессов и сетевых соединений предыдущего.
Первоисточники
- Docker Engine: Security
- Docker: Seccomp security profiles
- Docker: Rootless mode
- Docker: Resource constraints
- Linux kernel: Control Group v2
- Linux man-pages:
credentials(7) - Linux man-pages:
kill(2)