English · Русский
Модель безопасности для интеграторов
См. также: index.md (индекс) · developer-guide.md (быстрый старт, выпуск ключа, примеры запросов) · errors.md (обработка 401/403/404/409/429/501/503) · versioning-and-deprecation.md (политика semver) · openapi/index.html (полный справочник OpenAPI в Redoc).
Семь вещей, которые должен понимать каждый интегратор до того, как считать этот API границей доверия.
1. Только localhost
Оба хоста — ProjectMaker (:5299) и ZennoPoster Core (:5300) — принимают только вызовы с loopback.
Запрос, чей адрес пира не является loopback (127.0.0.0/8, ::1 или их IPv4-mapped формы), отклоняется с 403 без тела; запрос без резолвируемого адреса пира отклоняется тоже. Проверка выполняется до маршрутизации и до аутентификации, поэтому покрывает и анонимные T0-маршруты (/ping, /capabilities, /auth/whoami). MCP-серверы применяют то же правило на своём ingress.
В этом релизе внешнего сетевого доступа нет, как нет и удалённого режима или режима TLS. Публикация контракта OpenAPI и этой документации — это именно публикация контракта, а не открытие API для сети. Если вам нужен удалённый доступ, ответственность за собственный туннель или прокси и его безопасность лежит на вас.
2. Ключ — не всемогущий
Выпущенный ApiKey намеренно не эквивалентен работе от имени владельца машины:
- Скоупирован — каждая операция требует конкретный скоуп (
task:read,project:edit,code:author, …); ключ несёт только те скоупы, с которыми он был выпущен. - С уровнем риска — каждая операция несёт свой тир (
T0чтение →T3уровень ОС / класс RCE, например компиляция и запуск OwnCode); ключ работает только до своегоmaxTier. - Отзывается мгновенно — отзыв удаляет запись ключа; следующая же попытка аутентификации с ним завершается
401. Задержки распространения, о которой надо было бы думать, нет. - С сроком действия — выпускается с явным абсолютным
expiresAt; после истечения аутентификация не проходит так же, как у отозванного ключа. - Никогда не хранится и не восстанавливается в открытом виде — необработанный ключ показывается ровно один раз при выпуске (в стиле GitHub PAT). Хранится только солёный хэш PBKDF2/HMAC-SHA256 (210 000 итераций, случайная соль на ключ), причём сам реестр ключей зашифрован на диске. Если вы потеряли необработанный ключ, пути восстановления нет: отозвать и выпустить новый.
От чего это НЕ защищает: ключ не защищает от того, кто контролирует машину, на которой работает API, — такой человек в любом случае может прочитать память процесса, интерфейс и файловую систему.
Не защищает он и от кода, который продукт исполняет по вашей воле. Купленный проект, который вам разрешено только выполнять, и сторонний плагин исполняются внутри процесса самого продукта: такой код читает любой ключ этого процесса прямо из его памяти и обращается к API как доверенный локальный вызывающий. Флаг «нельзя открыть в редакторе» стоит читать как защиту работы автора, а не как свойство безопасности для того, кто проект запускает.
Что ключ действительно даёт: он закрывает дыру открытого localhost для других локальных процессов без валидного ключа, ограничивает объём того, что может сделать любая отдельная интеграция (включая AI-агента) — скоуп плюс тир, и привязывает каждый вызов к именованному ключу, который можно отозвать отдельно.
3. Аудит
Каждый вызов с тиром T2 и выше записывается в журнал только для добавления (JSONL — один JSON-объект на строку), доступный через GET /api/v1/audit (только скоуп admin). Это то самое правило, которое заявлено в таблице тиров, поэтому в журнал попадают запуск проекта, выполнение действия, старт и остановка задачи, создание инстанса — и по-прежнему попадают операции скоупа admin, которые формально T0, но журналируются с самого начала. Отклонённые попытки записываются тоже, вместе с полученным 401/403/429: часто именно попытка и представляет интерес.
У каждого хоста свой журнал, поэтому GET /api/v1/audit на ProjectMaker (:5299) отвечает за ProjectMaker, а тот же вызов на ZennoPoster Core (:5300) — за ZennoPoster. Чтобы увидеть картину целиком, спрашивайте оба. Это разные процессы; отдельный файл у каждого избавляет их от борьбы за общий и делает журнал самоподписанным ещё до открытия.
Каждая запись несёт: временную метку, id операции, хост, id и метку ключа, метод, путь, код статуса, тир, требуемый скоуп, длительность, адрес пира и X-Zenno-Uow-Id, если вызывающий его прислал. Два замечания о чтении журнала:
- Адрес пира всегда loopback, потому что других этот API не принимает. Он записывается для полноты, а не потому, что различает вызывающих.
- Id единицы работы — это то, что вызывающий положил в заголовок. Он полезен, чтобы сопоставить вызов с выполняющейся задачей, но доказательством авторства не является.
Вызовы T0 и T1 в журнал не попадают. Чтения и обратимые правки утопили бы в себе то, что действительно важно: один только ИИ-агент, обходящий структуру проекта, дал бы тысячи записей. Если нужны и они, смотрите собственные логи продукта.
Журнал хранит только метаданные вызова — никогда необработанный ключ или его хэш — поэтому, в отличие от реестра ключей, он не зашифрован на диске; относитесь к нему как к операционным логам, а не как к секрету. Кроме того, он растёт без ограничения размера и не ротируется: примерно 250 байт на запись, так что на нагруженном раннере накопятся десятки мегабайт. Архивировать его — ваша задача.
4. Подтверждение человеком
Владелец машины может потребовать, чтобы вызов пропускал человек. В свежей установке проверка выключена, потому что раннер, за которым никто не сидит, должен продолжать работать; включается она в Настройках → Api-ключи → Подтверждения, там же владелец выбирает уровень риска, с которого она начинается (по умолчанию T3 — операции уровня ОС, класса RCE), сколько вызов ждёт ответа, отклонять или пропускать вызов, на который никто не ответил, и какие операции не спрашивать.
Что при включённой проверке видит интеграция:
- Вызов блокируется, пока кто-то решает. Ничего не возвращается: ни id задания, ни хэндла для опроса. Рассчитывайте клиентский таймаут больше, чем ожидание, заданное владельцем, иначе первым сработает ваш собственный.
- Отказ приходит как
403с кодомconfirmation_rejected(человек сказал нет) илиconfirmation_timeout(никто не ответил), и в теле ошибки естьconfirmationId. Ни то, ни другое не означает, что ключа не хватило, так что ключ с большими правами ничего не изменит. - Ничего не повторяется за вас. Отвеченное подтверждение исчезает, а новый вызов создаёт новое.
Очередь принадлежит тому процессу, который задержал вызов, поэтому вызов к ProjectMaker пропускают через ProjectMaker. GET /api/v1/confirmations показывает, что держит этот хост, а POST /api/v1/confirmations/{id}/approve или .../reject отвечает на одно из них. Всем трём нужен скоуп admin, и это сделано намеренно: тот, кто наткнулся на проверку, не должен уметь пропустить себя сам.
Обратите внимание, что эта мера даёт и чего не даёт. Это единственная защита здесь, которая не опирается на секрет, поэтому она одинаково работает и против украденного ключа, и против кода, который продукт исполняет по вашей воле: до API дотянутся оба, а нажать кнопку не может ни один. Чего она не умеет — рассказать потом, что произошло: обычные доменные вызовы в журнал по-прежнему не попадают, подтверждённые в том числе (см. раздел 3).
5. Повторные неудачные аутентификации замедляются
Неудачные аутентификации считаются отдельно по предъявленным учётным данным и по адресу пира. После превышения порога вместо очередного 401 приходит 429 с заголовком Retry-After, причём приходит он до проверки ключа — так что подбором токенов не получится заставить сервер тратить процессор на хэширование.
Это не блокировка: ничего не отключается, никому не нужно ничего разблокировать, а счётчики сами истекают в пределах минуты. Успешная аутентификация сразу обнуляет счётчик своего ключа. Так сделано намеренно: на поверхности только для loopback адрес у всех вызывающих один, и настоящая блокировка позволила бы любому локальному процессу вывести вашу интеграцию из строя, проваливая аутентификацию за неё.
Что это значит для клиента: считайте 429 сигналом «подожди и повтори», соблюдайте Retry-After и исправьте учётные данные, а не повторяйте их в плотном цикле.
6. Плагинам доверяют по содержимому, а не по автору
Плагин (.zpg) это упакованный проект, который продукт исполняет рекурсивно, в том же потоке и процессе, что и вызывающий его проект. В манифесте плагина есть поле автора, но его пишет тот, кто делает файл, поэтому список «доверенных авторов» обходился бы ровно тем, от кого он должен защищать. Такого списка в продукте нет.
Вместо него есть пин самого файла. Ключ со скоупом admin записывает SHA-256 установленного файла плагина через POST /api/v1/plugins/trust (тело {"path": "Local\\my-plugin.zpg"}, путь относительно папки плагинов или абсолютный), получает список пинов через GET /api/v1/plugins/trust и снимает пин через DELETE /api/v1/plugins/trust/{sha256}. То же самое владелец делает в диалоге «Доверенные плагины» в настройках Api-Keys. Файл хэширует сам сервер, так что пин всегда описывает байты, которые есть на этой машине. Любое изменение файла, включая обновление, даёт ему новый хэш, и доверие не переносится: владелец смотрит на новую версию и пинит её заново, либо нет.
Гейт исполнения сверяется с пинами. Проект, вызывающий плагин, который никто не запинил, считается непрозрачным содержимым и требует code:author на T3, ровно как проект со своим C# (раздел 2). Запиненный плагин исполняется под project:run вызывающего, как и остальной проект; внутрь гейт не заглядывает, пин это слово владельца о том, что плагин делает. Когда запуск отклонён из-за незапиненного плагина, тело 403 называет плагин и его хэш, чтобы владелец знал, что пинить, а GET /projects/current/structure отдаёт тот же хэш у каждого кубика плагина в поле contentHash. Плагин, путь к которому известен только во время выполнения (макрос), заранее захэшировать нельзя, и он остаётся непрозрачным.
Стоит развести отдельно: .zpg ещё и поставляется execute-only, то есть вызывающий его проект не может открыть его как исходники. Это защищает интеллектуальную собственность автора плагина — вызывающий может исполнить плагин, но не прочитать, что он делает. Это не мера безопасности для того, кто запускает вызывающий проект, и не претендует на эту роль; безопасность для него обеспечивает пин по хешу выше плюс граница процесса из раздела 2. Ничто здесь не доверяет плагину больше из-за того, что он execute-only, и ничему здесь не требуется, чтобы он им был, для работы пина.
Два ограничения, сказанные прямо. Пины проверяет только гейт API: человека, запускающего проект из окна продукта, никто не спрашивает, и раннер незапиненный плагин не отклоняет. И пин подтверждает байты, а не их безопасность: запиненный плагин может всё, что может продукт, и это та граница, которую описывает раздел 2.
7. Код, который исполняет продукт, отрезан от его внутреннего ИИ-стека
Продукт исполняет не ваш код: купленный проект, плагин, C#-сниппет. Такой код работает внутри процесса самого продукта (раздел 2). Этот раздел о том, до чего он оттуда дотягивается и что продукт с этим делает.
- Внутренние ИИ-серверы (MCP-серверы за встроенным чатом и ИИ-кубиком, оркестратор, который ими управляет) и API самого окна чата отвечают только тем, кто предъявляет секрет машины, который продукт выдаёт своим процессам. Вызов без него получает
401. Эти порты внутренние и не для интеграций: используйте свою копию публичных MCP-серверов (install.md). Если сам секрет не удалось загрузить, API чата остаётся открытым для любого локального вызывающего вместо отказа всем — та же картина, что была до появления этой защиты. - Оркестратор подключается только к MCP-серверам, которые запустил его хост, какие бы адреса ни назвал запрос, поэтому его нельзя направить на чужой слушатель, чтобы собрать секрет.
- Локальные управляющие каналы продукта (named pipes) принимают только процессы самого продукта: исполняемый файл из папки продукта или подписанный тем же издателем.
- Обращения ИИ-кубика к браузеру несут id попытки задания, от имени которой он действует, и домен Instance по нему не пускает одну попытку к браузеру другой. Этому id верят только из собственного ИИ-канала продукта. С любым другим ключом
X-Zenno-Uow-Idигнорируется, как будто его нет, то есть интеграция получает ровно то, что получала, никогда его не присылая. Это не пускает одну легитимную ИИ-сессию к браузеру чужой попытки, но не является границей против противника, которого описывает заключительный абзац ниже: код, уже обладающий правами продукта, может просто не прислать id и попасть в тот же неограниченный случай, что и любой другой вызывающий. Аудит по-прежнему записывает то, что прислал вызывающий (раздел 3).
Что это даёт и чего не даёт. Песочницей это не является. Для любой локальной проверки код внутри процесса продукта и есть продукт: у него id процесса продукта, поэтому проверка пайпов его пропускает, и он делит с продуктом память, поэтому упорный автор достанет оттуда секрет и предъявит его так же, как продукт. Меняется цена. Три строки документированного HTTP, скопированные с форума, больше не работают; чтобы сделать то же самое, теперь нужно разбирать внутренности продукта. Гарантию даёт только исполнение кода проекта в отдельном процессе с урезанными правами, которое появится в одном из следующих выпусков. До тех пор считайте, что любой проект и плагин, который вы запускаете, имеет права продукта (раздел 2).
8. Что отдают операции трафика
GET /api/v1/instances/{id}/traffic и GET /api/v1/instances/{id}/traffic/{requestId} (скоуп instance:read, тир T0) возвращают сетевой журнал активной вкладки инстанса: URL запросов вместе со строкой запроса, заголовки запроса и ответа, тела запроса и ответа. Если держатель ключа — ИИ-ассистент на облачной модели, эти данные покидают машину: это цена того, что агент может найти эндпоинт за страницей или прочитать ответ XHR вместо разбора DOM.
Что маскируется всегда, без параметра, который это выключает: значения Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-Api-Key и X-Auth-Token. Структура сохраняется — схема авторизации, имена кук, атрибуты Set-Cookie, — чтобы сессию можно было отлаживать, не воспроизводя её. Больше ничего не маскируется: токен внутри JSON-тела или строки запроса возвращается как есть. Если нужны сырые куки, читайте их в коде проекта через Tab.GetTraffic() — он не покидает процесс.
Отслеживается только текущая страница активной вкладки, и тело читается только пока эта страница загружена: после навигации заголовки остаются, а тела пропадают (bodyAvailable: false).