Прежде чем дать AI-агенту доступ к сайту: права, повторы и потерянные правки

В нашей небольшой веб-студии решили попробовать передать AI-агентам часть рутинной работы с сайтами. Работаем мы в основном с MODX 2 и 3, поэтому мне поручили подобрать подходящий MCP-компонент. Задача выглядела несложной: посмотреть существующие решения, сравнить возможности и выбрать, что лучше подойдет.
Сначала я так и собирался сделать. Но потом полез в исходники и наткнулся на одну интересную вещь. В одном из компонентов был совершенно обычный PHP-цикл, который обновлял дополнительные поля страницы. Я решил проверить, что произойдет, если при сохранении возникнет ошибка. Первое поле записалось, второе нет, а обработчик завершил работу как ни в чем не бывало.
Вернулся к коду. Оказалось, результат setTVValue() просто никто не проверяет. Если агент получит сообщение об успешном выполнении команды, он будет считать, что все готово. Хотя на сайте могла сохраниться только половина изменений.
После этого стало интересно, что еще может пойти не так. Например, что произойдет, если сотрудник изменит страницу, пока агент готовит свою правку? Что будет, если команда выполнится, но ответ потеряется? И вообще, с какими правами агент получит доступ к сайту?
Так обычный выбор компонента постепенно превратился в небольшое исследование. Я отобрал пять проектов, разобрал исходники и провел серию экспериментов. Причем интересовали меня уже не столько возможности компонентов, сколько то, как они ведут себя, когда что-нибудь идет не по плану.
MODX здесь просто пример системы, с которой я хорошо знаком. Сами проблемы встречаются и в других CMS, да и вообще в любых приложениях, где AI-агентам разрешили менять рабочие данные.
Что сравнивал
Для начала отобрал пять проектов. В таблице указаны последние опубликованные версии по состоянию на 30 октября 2026 года.
Проект | Для чего предназначен | Последняя версия |
|---|---|---|
MCP-сервер для MODX 3 | ||
Подключение внешних агентов к MODX 2 | ||
Работа агентов с MODX 2 и 3 | ||
AI-инструменты внутри панели управления | ||
Управление задачами и их приемкой | Оформленных выпусков на GitHub нет |
Первые три дают внешним агентам инструменты для работы с MODX. modAI работает внутри админки, а mxBoard занимается задачами и их приемкой. Это не пять одинаковых MCP-серверов, поэтому сравнивать их по количеству команд было бы странно. Зато можно посмотреть, как разработчики решают вопросы доступа и контроля результата.
Исходники пяти проектов я зафиксировал 26 октября 2026 года. Затем провел 25 проверок на PHP 8.3.6. Использовал настоящие классы компонентов, но вместо установленного MODX и базы данных подставлял тестовые объекты. Для проверки блокировок и повторных запросов запускал отдельные PHP-процессы.
Все 25 проверок завершились с PASS. Правда, некоторые специально воспроизводили ошибки, поэтому успешный тест здесь не всегда означает правильную работу компонента. Настоящий MODX с MySQL и подключенной языковой моделью я пока не запускал. На этом этапе мне было интересно поведение конкретных обработчиков.
Результаты всех проверок оставил под спойлером в конце статьи.
Проблема №1. От чьего имени агент выполняет команды
Допустим, контент-менеджер может редактировать только раздел с новостями. Цены, настройки сайта и чужие разделы ему недоступны. Подключаем ему AI-помощника, который должен выполнять часть работы. А какие права получит сам помощник?
По исходникам обнаружились две разные схемы. В одной агент действует с учетом прав конкретного пользователя. В другой ему дают набор разрешенных команд, но сами они выполняются с административными правами.
У ResourceGetTool проекта karamble есть две проверки. Сначала вызывается проверка разрешения на операцию, затем проверяется доступ к самому ресурсу:
$this->requirePermission($modx, 'view a resource', ['view']);
$resource = $this->locate($modx, $arguments);
$this->requirePolicy(
$modx,
$resource,
'view',
'resource ' . $resource->get('id')
);Сам токен тоже ограничивает список доступных инструментов. Получается, что разрешение вызвать команду еще не означает автоматического доступа ко всем данным, которые она умеет обрабатывать.
Здесь уже была реальная история с ошибкой. В issue №11 пользователь заметил, что его ограниченная учетная запись не видит некоторые данные в админке, но получает их через MCP. Исправление вошло в версию 1.2.0. После него участник обсуждения прогнал 57 проверок без ошибок и повторил тест на установленном пакете.
Это проверял не я, но случай показательный. Если агенту разрешили вызвать инструмент, еще не факт, что пользователь имеет право видеть все данные, которые этот инструмент способен вернуть.
У dampilov94 и rumata-estor другая схема. Можно ограничить группы команд, доступных агенту, но сами операции идут через служебного пользователя с административными правами MODX.
Например, в коде rumata-estor, если служебная учетная запись не указана, выбирается первый активный пользователь с флагом sudo:
$query = $this->modx->newQuery(\MODX\Revolution\modUser::class);
$query->where(['active' => 1, 'sudo' => 1]);
$query->sortby('id', 'ASC');
$query->limit(1);Здесь sudo означает административные права внутри MODX, к суперпользователю Linux они отношения не имеют. Такая схема вполне может подойти, если агент выполняет задания администратора сайта. Но когда у каждого сотрудника должен быть личный помощник с его собственными ограничениями, одного списка разрешенных команд уже недостаточно.
У modAI проверки находятся непосредственно в инструментах. Например, EditChunk вызывает hasPermission('save_chunk'). Я проверил эту ветку: без нужного права выполнение останавливается еще до чтения чанка.
mxBoard отвечает за другую часть работы. Там проверяется, кто может выполнить задачу, изменить ее состояние или принять результат. К правам на редактирование конкретной страницы эти проверки напрямую не относятся.
После изучения этих вариантов вопрос о количестве доступных команд у меня отошел на второй план. Сначала надо решить, кому именно мы доверяем их выполнение: конкретному сотруднику или отдельной служебной учетной записи.
Проблема №2. Агент прочитал данные, а они уже изменились
Представим обычную ситуацию. На странице товара стоит цена 1000 рублей. Агент ее прочитал и начал готовить обновление. В это время сотрудник поменял цену на 1100. Агент заканчивает работу и записывает 1200, все еще опираясь на старые данные.
Команда выполнилась без ошибок, только изменение сотрудника куда-то исчезло. Для опытов я взял TV, дополнительные поля ресурсов MODX. Там часто хранят цены, характеристики и другие значения, но такая ситуация возможна и при работе с обычными записями в базе данных.
Проверять старое значение или просто сохранять новое
В обработчике обновления TV проекта dampilov94 нашел такой код:
foreach ($data['tvs'] as $tvName => $tvValue) {
$resource->setTVValue($tvName, $tvValue);
}Старое значение здесь не сравнивается с текущим. Я смоделировал ситуацию: после чтения поля человек записал human-edit, а агент попытался установить agent-edit. В результате осталось значение агента, а изменение человека пропало.
В ResourceTvUpdateTool проекта rumata-estor есть сравнение с ожидаемым значением:
if (!is_string($beforeValue)
|| (string)$resource->getTVValue($name) !== $beforeValue
) {
throw new \ModxMCPClientException(
'STALE_STATE {"status":"STALE_STATE","reason":"tv_values_changed"}'
);
}Смысл простой: агент сообщает, что видел в поле раньше, а обработчик перед записью еще раз смотрит на текущее значение. Если они не совпадают, команда останавливается с STALE_STATE.
В моем тесте исходным значением было 111. После чтения я заменил его на human-edit. Агент попытался записать agent-edit, указав старое значение. Обработчик заметил расхождение и сохранил человеческую правку.
Потом я повторил тот же запрос без ожидаемого значения. На этот раз agent-edit записался без возражений. Получается, проверка есть, но пользоваться ею необязательно. Если клиент не передаст предусловие, этот механизм не поможет.
Есть и другая тонкость. Проверка значения и сама запись выполняются отдельно. Если сотрудник успеет изменить поле между этими действиями, конфликт можно пропустить. То есть защита от устаревших данных есть, но полноценной атомарной операции сравнения и записи на уровне базы данных здесь нет.
А если команда выполнилась, но ответ потерялся?
Теперь другая история. Агент попросил создать страницу. Сервер ее создал, но соединение оборвалось до отправки ответа. Агент не знает, что произошло, и может попробовать еще раз. Если ничто не мешает повторному выполнению, на сайте появятся две страницы вместо одной.
У dampilov94 для таких ситуаций есть RequestStore. Он запоминает состояние операции по идентификатору запроса. Когда операция уже завершилась, обработчик возвращает сохраненный ответ:
if ($record['state'] === 'completed') {
return $this->response($key, $record);
}Я проверил несколько вариантов. Если отправить тот же запрос, пока первый еще выполняется, получаем ошибку 409. Если первый уже завершился, возвращается сохраненный ответ без повторного действия. А если взять прежний идентификатор, но поменять аргументы, запрос тоже отклоняется.
Самый интересный случай возник, когда действие уже произошло, а процесс завершился до сохранения ответа. RequestStore не начинает все заново, а сообщает, что результат неизвестен. Это не очень удобный ответ, зато он не пытается повторно создать страницу наугад.
Правда, если отправить ту же команду с новым идентификатором, она выполнится снова. Поэтому одного механизма на стороне сервера недостаточно, многое зависит и от поведения клиента.
Что дают блокировки
У rumata-estor есть общая файловая блокировка для операций записи, которые ее используют. Я запустил два PHP-процесса. Первый удерживал блокировку 350 мс, а второй дождался ее освобождения и только после этого продолжил работу.
Потом добавил третий процесс, который о блокировке ничего не знал. Он спокойно изменил файл, пока первый продолжал работать. Ничего неожиданного здесь нет: блокировка действует только на тех, кто согласился ее соблюдать. Контент-менеджер в обычной админке MODX может сохранить страницу, не обращая на нее внимания.
В итоге получилось три разных механизма, каждый со своей задачей:
Механизм | Что позволяет контролировать |
|---|---|
Идентификатор запроса | Повторное выполнение той же команды |
Блокировка | Одновременную работу операций, использующих общую блокировку |
Проверка ожидаемого значения | Изменение данных после того, как агент их прочитал |
Они не заменяют друг друга. Например, блокировка не спасет от повторного создания страницы после потери ответа, а идентификатор запроса не защитит от изменения данных человеком.
Проблема №3. Агент сообщил об успехе, но все ли сохранилось?
Вернусь к циклу, с которого все началось. Агент должен изменить два дополнительных поля. Первое записалось, а на втором setTVValue() вернул false. У dampilov94 результат вызова не проверяется, поэтому обработчик продолжает работу.
Я подставил такую ошибку в тесте. Первое поле изменилось, второе осталось прежним, а обработчик закончил работу без ошибки. Не лучший результат, особенно если агент после этого сообщит пользователю, что все готово.
В rumata-estor тот же случай обрабатывается иначе:
$modx->beginTransaction();
try {
foreach ($data['tvs'] as $name => $value) {
if ($resource->setTVValue($name, $value) === false) {
throw new \ModxMCPClientException(
'Failed to write TV: ' . $name
);
}
}
$modx->commit();
} catch (\Throwable $e) {
$modx->rollback();
throw $e;
}Источник: ResourceTvUpdateTool
Здесь проверяется результат каждой записи. Если одна не удалась, возникает исключение и вызывается откат. В тесте эта ветка сработала: обработчик вызвал rollback(), а тестовый объект вернулся к исходным значениям. На настоящей базе MySQL я это еще не проверял.
Похожий вопрос возник с modAI, только не с записью полей, а с обновлением кеша. В EditChunk проверяется сохранение чанка, после чего вызывается cacheManager->refresh(). Результат обновления кеша уже не проверяется.
Когда тестовый кеш вернул false, инструмент все равно сообщил об успехе. Это не значит, что кеш на реальном сайте обязательно сломается. Просто отказ на этом шаге обработчик не замечает, а агент получает положительный ответ.
У karamble после изменения ресурса выполняется повторное чтение. Можно получить состояние объекта после сохранения, хотя само по себе это еще не подтверждает выполнение всей задачи. А mxBoard предлагает смотреть на результат с другой стороны: отделить выполнение задания от его приемки.
В Transitions.php есть проверка того, кто вправе закрыть задачу:
if (!$isAuthor && !$isManager) {
return [
'allowed' => false,
'reason' => 'mxboard_err_close_author_only'
];
}В пяти тестах я менял роли и настройки. Обычный исполнитель не смог закрыть чужую задачу, автор смог, а приемка собственной задачи автором-исполнителем по умолчанию была ограничена.
Проблему с частичным сохранением данных это, конечно, не решает. Зато появляется отдельный этап проверки: кто-то должен посмотреть на результат работы, прежде чем задача будет считаться закрытой.
К чему в итоге пришел
Компоненты оказались довольно разными. У каждого нашлись интересные решения, но и недостатков хватает. Для агента с правами конкретного сотрудника я бы сначала смотрел karamble. У dampilov94 понравилась обработка повторных запросов. modAI интересен для работы прямо из админки, а mxBoard пригодится, когда нужно распределять задания и отдельно принимать результаты.
В итоге с компонентом для нашей задачи я определился. Теперь хочу проверить его на настоящем MODX с MySQL, уже без заглушек и тестовых объектов. Особенно интересуют права доступа, потерянные ответы, чужие правки и ошибки при сохранении нескольких полей.
Посмотрим, насколько результаты совпадут с тем, что получилось в изолированных тестах. Если обнаружится что-нибудь интересное, будет повод написать продолжение. А пока главное для себя выяснил: прежде чем давать агенту доступ к рабочему сайту, неплохо бы разобраться, что именно он сможет там натворить.
Материалы и результаты тестирования
Здесь оставлю результаты 25 проверок, которые использовал при подготовке статьи. Все они выполнялись 26 октября 2026 года на PHP 8.3.6. Для работы с компонентами использовались их настоящие классы, но вместо установленного MODX и базы данных подставлялись тестовые объекты. В проверках повторных запросов и блокировок участвовали отдельные PHP-процессы.
Все 25 проверок, результаты и ревизии исходников
1. Права доступа
Проверка | Результат |
|---|---|
| Неинициализированная сессия обычного пользователя отклонена |
| Инициализированная сессия прошла проверку |
| Для пользователя с |
| Статические проверки завершились: 25 passed, 0 failed |
| Без разрешения |
Все пять проверок завершились с PASS. При этом karamble_static_permission_guard запускал собственный набор из 25 статических проверок исходников. Это не дополнительные 25 интеграционных тестов.
2. Изменение TV и защита от потери правок
Проверка | Результат |
|---|---|
| Последняя запись заменила предыдущее изменение |
| Проверка ожидаемого значения обнаружила конфликт и сохранила чужую правку |
| Без предусловия чужая правка была перезаписана |
| Возврат |
| При отказе записи вызван |
Все пять проверок завершились с PASS. Для проверки конфликта изменений использовался настоящий ResourceTvUpdateTool и тестовые объекты MODX.
Фрагмент проверочного кода:
$old = $m->resource->values;
$m->resource->values['primary'] = 'human-edit';
$before = $m->resource->values;
$guard = [
'expected_tvs' => [
'primary' => $old['primary']
]
];
mustFail(function () use ($t, $ctx, $guard) {
$t->execute($ctx, [
'resource_id' => 1,
'tvs' => ['primary' => 'agent-edit'],
'_runtime_preconditions' => $guard
]);
}, 'STALE_STATE');
demand(
$m->resource->values === $before,
'Human edit lost with guard'
);Результат с проверкой ожидаемого значения:
{
"case": "tv_stale_guard",
"result": "PASS",
"before": {
"primary": "human-edit",
"secondary": "222"
},
"after": {
"primary": "human-edit",
"secondary": "222"
}
}Результат без предусловия:
{
"case": "tv_without_guard",
"result": "PASS",
"observation": "Human edit overwritten",
"after": {
"primary": "agent-edit",
"secondary": "222"
}
}Во втором случае PASS означает, что тест успешно воспроизвел потерю изменения.
3. Повторные запросы
Для этих проверок использовались настоящая реализация RequestStore, файловая система и отдельные PHP-процессы.
Проверка | Результат |
|---|---|
| Повтор во время выполнения отклонен с кодом 409 |
| Повтор завершенного запроса вернул сохраненный ответ, действие выполнено один раз |
| Прежний идентификатор с другими аргументами отклонен с кодом 409 |
| Новый идентификатор позволил выполнить действие повторно |
| После прерывания процесса возвращена ошибка 409 с неизвестным результатом, повторного действия не произошло |
Все пять проверок завершились с PASS. Особенно интересен результат crash_after_effect, когда процесс был прерван после выполнения действия, но до сохранения ответа.
{
"case": "crash_after_effect",
"result": "PASS",
"effects": 1,
"reply": {
"status": 409,
"error": "Original request outcome is unknown. Check the site and request status; this ID will not be executed again."
}
}Первое действие произошло, но повтор с прежним идентификатором не выполнялся. Для сравнения, при использовании нового идентификатора результат был таким:
{
"case": "different_id_control",
"result": "PASS",
"effects": 2
}В этом случае одинаковое действие выполнилось дважды.
4. Блокировки
Проверка | Результат |
|---|---|
| Два процесса, использующие общую блокировку, выполнились последовательно |
| Сторонний процесс смог изменить файл, не дожидаясь освобождения блокировки |
Обе проверки завершились с PASS. Результат работы общей блокировки:
{
"case": "shared_mutation_lock",
"result": "PASS",
"order": [
"enter-A",
"leave-A",
"enter-B",
"leave-B"
],
"second_elapsed_ms": 328.7
}Второй процесс вошел в защищенный участок после выхода первого. При этом процесс, который не использовал блокировку, смог изменить файл независимо от остальных.
5. Ошибки сохранения и откат
В обоих проектах моделировалась одинаковая ситуация. Первое TV-поле сохранялось, а при записи второго setTVValue() возвращал false.
dampilov94
{
"case": "dampilov_tv_false_write_return_ignored",
"result": "PASS",
"after": {
"primary": "partial",
"secondary": "before"
},
"observation": "Actual trait returns normally after false from second setTVValue; no rollback in this method"
}Метод завершился без ошибки, хотя изменилось только первое поле.
rumata-estor
{
"case": "tv_rollback",
"result": "PASS",
"before": {
"primary": "agent-edit",
"secondary": "222"
},
"after": {
"primary": "agent-edit",
"secondary": "222"
},
"rollback_calls": 1
}Здесь обработчик вызвал откат, а тестовый объект восстановил прежнее состояние. Настоящая база MySQL в этих экспериментах не использовалась.
6. Сохранение чанков и обновление кеша modAI
Проверка | Результат |
|---|---|
| После сохранения был вызван метод обновления кеша |
| Возврат |
| При отказе сохранения обработчик не сообщил об успешном завершении |
Все три проверки завершились с PASS. При имитации отказа обновления кеша получен следующий результат:
{
"case": "modai_false_refresh_return_not_checked",
"result": "PASS",
"observation": "Stub refresh returns false; tool still returns success"
}7. Управление задачами и приемка результатов mxBoard
Проверка | Результат |
|---|---|
| Исполнитель не смог закрыть чужую задачу |
| Автор смог закрыть задачу |
| Самостоятельная приемка автором-исполнителем по умолчанию запрещена |
| При включении соответствующей настройки самостоятельная приемка разрешена |
| Пользователь с административными правами смог закрыть задачу |
Все пять проверок завершились с PASS. При попытке закрыть задачу обычным исполнителем получен следующий результат:
{
"case": "mxboard_executor",
"result": "PASS",
"verdict": {
"allowed": false,
"reason": "mxboard_err_close_author_only"
}
}Для автора задачи результат оказался другим:
{
"case": "mxboard_author",
"result": "PASS",
"verdict": {
"allowed": true,
"reason": ""
}
}8. Точные ревизии исходников
Для каждой проверки использовались исходники конкретного Git-коммита. Это позволяет соотнести результаты с состоянием проектов на момент проведения экспериментов.
Проект | Git commit |
|---|---|
|
|
|
|
|
|
|
|
|
|
9. Условия тестирования
Проверки проводились на PHP 8.3.6. Использовались настоящие классы компонентов, тестовые объекты MODX, отдельные PHP-процессы и файловая система для проверки повторных запросов и блокировок.
Всего зарегистрировано 25 проверок, все получили статус PASS. Результаты показывают поведение конкретных обработчиков в заданных условиях. Полноценные испытания на установленном MODX с настоящей базой данных остаются следующим этапом работы.
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.