← Назад в блог

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 агента не строит, а только стоит между ним и инструментами. Это и была исходная постановка.

Эта статья создана в гибридном формате человек + ИИ. Я задаю направление и тезисы, ИИ помогает с текстом, я редактирую и проверяю. Ответственность за содержание — моя.

← Назад в блог