Runtime, в котором агент не может вызвать инструмент в обход контроля
Фраза «агент предлагает, Cradle решает, что может быть исполнено» стоит на главной странице продукта. Пока в коде оставался хоть один путь, которым инструмент вызывался напрямую, эта фраза была рекомендацией, а не правилом. Ниже — как runtime устроен и как я закрыл обходные пути механически, а не договорённостью.
Три разделения
Cradle отделяет вероятностное рассуждение от детерминированного контроля исполнения. Вся конструкция держится на трёх утверждениях:
Proposal — не решение. Вывод инструмента — не проверенная истина. Успешное исполнение — не проверка результата.
Система оценки рисков, о которой я писал раньше, управляет ответами людям. Runtime обобщает её до управления действиями над системами.
Четыре сущности вместо одного «действия агента»
В типичном агентном цикле «вызов инструмента» — это одна запись в транскрипте. В runtime это четыре отдельные таблицы: proposals, decisions, observations, verifications, плюс append-only audit_events.
| Сущность | Что фиксирует | Кто пишет |
|---|---|---|
| Proposal | что агент хочет сделать: тип действия, цель, аргументы, класс побочного эффекта, пред- и постусловия | агент |
| Decision | вердикт allow / deny / repair / require_approval и авторизованная идентичность | policy |
| Observation | что вернул инструмент на самом деле | runtime после вызова |
| Verification | держатся ли постусловия по независимому источнику | верификатор |
Разделение нужно не ради красоты схемы. Оно позволяет enforce'ить инварианты дешёвыми проверками над записями, без графа и без ризонера:
- отклонённый proposal не может породить observation (
EXECUTION_NOT_AUTHORIZED); - исполнитель обязан совпасть с авторизованной идентичностью (
IDENTITY_MISMATCH); - один idempotency key — один proposal, один proposal — одно решение;
- статус верификации выводится из проверок, а не задаётся вызывающим: пустой список проверок даёт
inconclusive, потому что «мы не смотрели» — это не «всё в порядке».
Последний пункт и есть весь тезис в одной строке кода:
const verification = recordVerification(db, {
observationId: observation.id,
checks: [{ name: 'refund.recorded', status: 'fail', source: 'ledger-api' }]
})
verification.status // 'failed' — even though the tool returned 200
Провал верификации возвращается как отказ, даже когда инструмент отчитался об успехе. Поле source в каждой проверке фиксирует, что читали независимый источник, а не тот же вызов, который делал запись.
Аудит как побочный продукт
Журнал не надстроен поверх runtime. Каждая запись proposal, decision, observation и verification порождает событие аудита с payload_hash, prev_hash и hash. Функция verifyAuditChain() ловит изменённый payload, изменённые метаданные и удалённую строку (разрыв в seq).
Это то, что я называю assurance by construction: нельзя исполнить действие и не оставить след, потому что след — не отдельный вызов логгера, а часть того же пути записи. Одна граблина по дороге: canonicalJson() сортирует ключи на всех уровнях. Без этого цепочка ломалась на round-trip через SQLite: JSON.stringify сохраняет порядок вставки, и свежесобранный объект сериализовался иначе, чем прочитанный из базы.
ToolGateway — единственная дверь
Поверх существующего MCP-клиента стоит ToolGateway.execute(): propose → decide → invoke → observe. Отказ политики — это результат, а не исключение: вызывающий получает решение с полным списком причин, а транспорт не трогается.
import { ToolGateway, registerTool, mcpInvoker } from './core/runtime'
registerTool(db, {
serverId: 'mcp_billing',
toolName: 'refund.issue',
sideEffectClass: 'compensatable',
requiredScopes: ['billing:write'],
allowedEnvironments: ['staging', 'production'],
dryRunToolName: 'refund.validate'
})
const result = await new ToolGateway(mcpInvoker).execute(db, {
taskId, agentId,
identity: { id: 'user:operator_7', scopes: ['billing:write'] },
serverId: 'mcp_billing',
action: {
actionType: 'refund.issue',
target: { kind: 'order', id: 'A-119', env: 'production' },
arguments: { amount: 4200 },
sideEffectClass: 'compensatable',
preconditions: ['order.exists', 'order.paid'],
postconditions: ['refund.recorded']
},
idempotencyKey: 'refund:A-119'
})
result.decision.verdict // allow | deny | repair | require_approval
result.observation // null unless allow — the tool was never invoked
result.replayed // true when the idempotency key was already used
Два решения внутри шлюза, которые стоит назвать отдельно.
Discovery — не authorization. MCP-сервер сообщает имя инструмента и в лучшем случае destructiveHint. Он не говорит, что инструмент может тронуть и кому его можно звать. Поэтому незарегистрированный инструмент не вызывается вовсе, а при автоматической регистрации новый инструмент получает класс irreversible, если сервер явно не сказал обратного. Оптимизм удалённого сервера насчёт собственной безопасности — не основание понижать планку локально.
Подтверждение человека — объект, а не строка. Раньше шлюз принимал approvalId строкой и верил ей; любой вызывающий удовлетворял правило, придумав значение. Теперь это запись со своим жизненным циклом: она указывает на конкретный proposal или scope, имеет срок, тратится один раз, и агент не может подтвердить сам себя.
Сторож в CI
Шлюз сам по себе ничего не гарантирует, пока рядом существует прямой getMcpManager().callTool(). Автор файла gateway.ts написал это в докстринге прямо: без единой точки прохода инварианты advisory.
Проверка по коду нашла три обхода. Один оказался не MCP вовсе, а одиннадцатью внутрипроцессными инструментами против своей базы, четыре из них разрушительные. Второй, который я по строке 80 классифицировал как read-only, тем же хелпером писал: обновлял заметки, ставил отметку «выполнено». Поверхностная классификация — отдельный урок.
Закрывал двери в три шага. Типы: getMcpManager() возвращает пул соединений, у которого нет callTool. Код: обходы либо зарегистрированы в реестре с классом эффекта и scopes, либо явно выведены из-под шлюза с причиной в коде. И механический сторож — скрипт scripts/check-gateway.mjs, он же pnpm check:gateway.
Почему скрипт, а не grep. Правило — о том, где используется символ, а не о том, встречается ли строка: callTool легально живёт внутри runtime и внутри MCP-клиента. Сторож проверяет три вещи: вызов вне src/core/runtime/, импорт символов, способных дойти до инструмента, и состав экспортов src/core/mcp/ — он должен оставаться ровно двумя именованными исключениями. Единственная поблажка (чтение скриншота заметки для UI оператора) обязана держать имя инструмента строковым литералом; расширь её сигнатуру до произвольного имени, и сторож падает.
Сторож проверен на заведомом нарушении — иначе это не сторож, а пожелание:
✗ src/core/__probe/violation.ts:2 — calls .callTool() outside src/core/runtime/
→ ПРОВАЛ: 1 нарушений в 234 файлах вне src/core/runtime/
На чистом коде — ноль нарушений в 233 файлах, тесты runtime зелёные.
Честный статус
Чего нет, и описывать это как работающее нельзя:
- Готовых верификаторов. Движок есть, реестр пуст. Пока ничего не зарегистрировано, любое действие с постусловиями заканчивается
inconclusive→aborted. Это правильное поведение по умолчанию, но пользы от него пока ноль. - Отката в смысле восстановления снапшота. Есть только компенсация через инструмент, который объявил сам сервер.
- Доменных пакетов как поставки. Манифест есть, формат распространения в on-premise установки — нет.
- Tool-use у канального агента. Триаж отвечает текстом; инструменты живут в песочнице и доменных пакетах. Нужен ли агенту в канале вызов инструментов через шлюз — продуктовый вопрос, он открыт.
Запасной путь, если свой агентный цикл не нужен вовсе: внешний агент вроде Claude Code, запущенный в закрытом контуре командой cradle launch claude через Anthropic-совместимый /v1/messages на cradle-server. Тогда Cradle агента не строит, а только стоит между ним и инструментами. Это и была исходная постановка.