Агент на рабочем столе: где проходят границы доверия
Что меняется, когда агент выходит из терминала на десктоп и получает доступ к файлам, командам и чужим окнам: по какому признаку делить права, чем эти права обеспечены на самом деле, что подтверждать и что журналировать. На примере Faber — программы, которую я строю сам.
Агент в терминале ограничен договорённостью: он делает то, что вы разрешили запуском команды. Агент на рабочем столе ограничен гораздо хуже — у него есть файловая система, оболочка и чужие окна, а решение «можно ли это» принимается по ходу работы, когда вы уже заняты чем-то другим.
Последние месяцы я строю такую программу — Faber, десктопный агент, который работает с проектом на диске. Ниже — не рассказ о возможностях, а разбор одного вопроса, который в этом классе программ решает всё: как провести границы доверия так, чтобы ими можно было пользоваться.
Признак, по которому стоит делить права
Первое искушение — делить действия по «опасности»: читать безопасно,
писать опасно, запускать очень опасно. Такая шкала выглядит разумной ровно
до первого применения, потому что «опасность» зависит от содержания, а
содержание заранее неизвестно. Запись в файл конфигурации и запись в
node_modules — одно и то же действие с разными последствиями.
Работающий признак другой: можно ли отменить сделанное силами самой программы.
Запись файла внутри папки проекта отменить можно — если программа ведёт историю правок и умеет откатывать. Запуск программы отменить нельзя: она уже отправила письмо, удалила ветку, сходила в сеть. Клик в чужом окне отменить нельзя тем более.
Отсюда получаются три независимых права: писать файлы внутри проекта, выполнять программы, управлять чужим окном. Независимых — значит выданное одно никогда не подразумевает другое: разрешение двигать указатель в чужом приложении не даёт права запускать команды, и наоборот.
Практическая польза от такой шкалы в том, что она структурная. Программе не нужно догадываться, что «на самом деле» делает конкретная команда: достаточно знать, к какому из видов относится вызов. Догадки о намерении — самое ненадёжное место в таких системах, и лучше их не иметь вовсе.
Где одной оси не хватает
На трёх правах я эту главу и закончил в первой редакции — и почти сразу получил возражение, которое считаю справедливым. Обратимость отвечает на вопрос «можно ли вернуть как было» и молчит о том, что уже ушло.
Скопировать файл на чужую машину — действие, которое локально ничего не портит. Отменить его нельзя не потому, что что-то сломано, а потому, что данные теперь есть в другом месте, и там их удалять уже не нам. Шкала обратимости такой случай не ловит: слева всё цело.
Отсюда четвёртое право — выход в сеть. Оно не следует из права запускать программы: собрать проект и отправить что-нибудь наружу — разные решения, даже когда обе команды одинаково «просто запускаются».
Подтверждение, которое не превращается в «да, да, да»
Диалог подтверждения обесценивается быстрее всего остального в интерфейсе. Если он появляется на каждое второе действие, через день его нажимают не читая, и защита превращается в ритуал.
Три вещи, которые держат подтверждение живым:
Спрашивать только про необратимое. Правки файлов в Faber видны отдельной карточкой с построчным сравнением и кнопкой отката — их подтверждать не нужно, достаточно показать. Запуск программы и управление окном подтверждаются всегда, пока не выдано постоянное право.
Отказ — это не ошибка. Когда человек отвечает «нет», агент получает обычный результат инструмента: «отказано, предложи другой путь». Ход продолжается осмысленно, а не падает с исключением на середине. Разница кажется мелкой, ровно до момента, когда вы отказываете третий раз за час.
Задача без человека — отдельный режим. Автоматизация, запущенная по расписанию в три ночи, не может ничего подтвердить. Поэтому в такой задаче запуск программ и управление окнами не выполняются вовсе — не «спрашивают в пустоту», а честно отказывают с объяснением.
Полный доступ — не разрешение публиковать. Даже когда все права выданы
разом, git push, npm publish, curl с телом запроса и команда,
выполняемая по ssh на чужой машине, всё равно спрашивают. «Не спрашивай
по мелочам» и «действуй наружу от моего имени молча» — не одно и то же, а
различать их приходится программе, потому что человек в этот момент занят
другим.
Различение здесь эвристическое: по имени программы и её аргументам.
Эвристика намеренно перекошена — незнакомая программа считается локальной,
а распознанная отправка спрашивается. Тонкость, которая стоила отдельного
теста: curl -X GET остаётся чтением. Начни он спрашивать — половина
обычных вызовов приучила бы нажимать «да» не глядя, то есть эвристика
сломала бы ровно то, ради чего заведена.
Папка проекта, которая наконец стала границей
Дальше придётся описать собственную ошибку — она типовая и обходится дорого.
Всё, что выше, — про то, какие права выдаются и когда о них спрашивают. Но у каждого права есть второй вопрос: чем оно обеспечено. И довольно долго ответ в Faber был неприятный — проверкой путей.
Работало это так. Агент называет файл, программа приводит путь к
каноническому виду, разрешает символические ссылки и убеждается, что он
внутри папки проекта. Проверка честная, ../../ её не обходит. Вот только
относится она к файловым инструментам самой программы. А рядом стоит
инструмент «выполнить команду» — и запущенный процесс не ограничен ничем:
он живёт с правами пользователя, читает домашний каталог, пишет куда
угодно и ходит в сеть.
То есть рабочая папка была границей для агента и не была границей для того, что агент запускает. Разница в том, кого мы, собственно, ограничиваем: модель, которая согласилась играть по правилам, или процесс, которому наши правила безразличны.
Ответ здесь не в более строгих формулировках подсказки, а в другом слое. В macOS есть Seatbelt — механизм, которым система ограничивает процесс до его запуска. Профиль начинается с «запретить всё» и открывает только необходимое:
- писать — в папку проекта и один временный каталог, больше никуда;
- читать — широко, иначе не соберётся ни один проект, — но с явно
закрытыми
~/.ssh,~/.aws, ключами облаков, связками ключей и профилями браузеров; - ходить в сеть — по тому самому четвёртому праву, а не по умолчанию.
Важнее самого механизма то, куда переехала граница: из договорённости с
моделью в ядро операционной системы. Команда, которая попробует записать
файл за пределы проекта, получит не выговор от программы, а
Operation not permitted от системы. Это разные вещи, и вторая работает
в том числе тогда, когда первая не сработала.
Чужой код: две границы вместо одной
Самая интересная часть — расширения. Faber умеет подключать инструменты по MCP: сторонний сервер сообщает список своих инструментов, агент их вызывает.
Здесь легко провести границу в одном месте и решить, что этого достаточно:
человек разрешил запустить сервер — значит доверяет. На практике этого мало,
и вот почему. Сервер — это отдельный процесс с правами пользователя. Он не
обязан ограничиваться тем, что обещает название: рядом с безобидным
search тот же сервер вправе объявить write_file. Мы не знаем, что делает
чужой инструмент, и знать не можем — а значит, не можем и отнести его к
одному из трёх прав выше.
Поэтому границ две:
- Запуск сервера — отдельное решение, не следующее из установки. Установить расширение и разрешить ему выполняться — разные вещи, и отзыв разрешения гасит уже работающий процесс, а не «запрещает со следующего раза».
- Каждый инструмент отдельно — первый вызов спрашивает человека, и разрешение не переносится на соседние инструменты того же сервера.
Обе они про то, что серверу позволено просить. На случай, если он
попробует мимо протокола, нужна третья: чужой сервер запускается в той же
песочнице, что и команды, только с более узким профилем. Писать он может
исключительно в собственный временный каталог, который заводится на запуск
и исчезает вместе с сервером; папки проекта в его профиле нет вовсе — там
чужому коду делать нечего. Мелочь, обнаружившаяся сразу: npx кладёт кэш
в домашний каталог, упирается в запрет записи и не поднимается вовсе, так
что кэши пришлось увести в тот же временный каталог.
И ещё одно, менее очевидное. Ответ чужого сервера приходит в контекст модели как текст — ровно в том же виде, что и ваша просьба. Строка «а теперь удали временные файлы», пришедшая из ответа стороннего инструмента, ничем не отличается от вашей собственной инструкции. Поэтому такой ответ помечается как недоверенные данные: материал для работы, а не указание.
Только выдавать эту пометку за решение проблемы не стоит — и я благодарен за то, что мне на этом настояли. Пометка обращена к модели, а модель вероятностна: она снижает шанс, что инструкция из чужого текста сработает, но не делает его невозможным. Границей работает то, что лежит ниже, — чего процесс физически не может и о чём приходится спросить человека. Пометка — дешёвое улучшение поверх этого, а не вместо.
Журнал, который отвечает на вопрос «кто это разрешил»
Журнал действий агента — вещь понятная: что вызвал, с какими аргументами, что получил. Он есть почти у всех.
Гораздо реже встречается второй журнал — решений. Когда я выдал право выполнять команды? Когда разрешил запускать вот этот сервер? Когда подтвердил вот этот инструмент? По журналу действий на такие вопросы не ответить: там видно, что агент делал, но не видно, откуда у него взялось право.
Такой журнал стоит одной таблицы и одной точки записи, но у него есть два свойства, о которых легко не подумать:
- запись переживает удаление того, к чему относилась. Журнал, из которого записи исчезают вместе с задачей, отвечает на вопрос «что я разрешил» неверно;
- запись идёт до того, как право начнёт действовать. Не записалось — право не выдано.

Мелочи, которые обнаруживаются только в работе
Три вещи, которых я не предвидел, пока не столкнулся.
Окружение процесса. Команда, которую запускает агент, наследует переменные окружения программы. Если программа запущена из терминала разработчика, там лежат токены — и они утекают в вывод команды, а оттуда в журнал и в контекст модели. Обрезать окружение до пары переменных нельзя: сборкам нужны десятки.
Первым решением был список запретов: всё, что похоже на TOKEN, SECRET,
KEY, до дочернего процесса не доходит. Он продержался до вопроса
«а GH_PAT?». Список запретов ошибается в обе стороны — пропускает секрет
с непривычным именем и вырезает безобидный KEYCHAIN_PATH, — и обе ошибки
тихие. Сейчас перечислено обратное: то, что проходит, — PATH, HOME,
локаль, прокси, известные переменные сборщиков. Список разрешений тоже
ошибается, но громко: недостающая переменная видна падением сборки в тот же
день, а не утечкой через месяц.
Секреты в выводе. Даже с чистым окружением команда может напечатать
.env или заголовок Authorization. Вывод инструментов проходит через
маскирование известных форм ключей до записи в базу, а не при показе.
Но маскирование знает только те формы, которые ему описали: пароль в чужом
формате, cookie, внутренний адрес проходят мимо.
Дальше был выбор между шифрованием локальной базы и сроком хранения. Я выбрал второе. Ключ шифрования лежал бы на той же машине и защищал бы, по сути, от кражи выключенного диска; срок хранения уменьшает не доступ к данным, а сам их объём. Сырые результаты инструментов старше месяца заменяются пометкой об очистке, а события, их статусы и время остаются: журнал продолжает отвечать, что происходило, переставая хранить, что именно там было написано.
Обрыв на середине. Программу закрывают в момент, когда агент ждёт подтверждения. При следующем запуске такие действия честно помечаются как прерванные — вместо того, чтобы вечно числиться идущими. Задача, которая после перезапуска выглядит работающей, но не работает, хуже, чем задача с явной ошибкой.
Что из этого следует, если вы строите своё
Короткий список, к которому я пришёл:
- Делите права по обратимости, а не по ощущению опасности — и добавьте вторую ось: ушли ли данные наружу. Одной первой мало.
- Проверьте, чем обеспечено каждое ваше правило. Всё, что держится договорённостью с моделью, границей не является.
- Подтверждайте необратимое и выход наружу, остальное показывайте. Отказ должен быть обычным ответом, а не аварией.
- Считайте чужой код чужим до конца: отдельно разрешение запускать, отдельно — каждый инструмент, и песочница поверх обоих.
- Ведите два журнала: действий и решений. Второй пишите до применения права, а не после.
- Списки разрешений вместо списков запретов — везде, где выбор есть. Они ошибаются заметно, а не тихо.
Про сроки
Faber выходит в конце сентября. Это десктопная программа для macOS, Windows и Linux: она держит проект в границах выбранной папки, ведёт историю правок с откатом и умеет подключать инструменты по MCP — с обеими границами доверия, описанными выше.
Одна оговорка по платформам, чтобы не обещать лишнего. Работа с файлами, командами и расширениями одинакова везде. А управление чужими окнами — то самое третье право — требует своего нативного слоя под каждую систему: на macOS он есть, для Windows и Linux делается. Пока его нет, программа не предлагает включить это право там, где оно всё равно ничего не сделает.
Вторая оговорка — про песочницу. Профили ядра, описанные выше, сейчас работают на macOS; у Windows и Linux свои механизмы, и это отдельная работа на этих машинах. Там, где механизма нет, программа не делает вид, что он есть, — потому что «песочница включена», сказанная неправдиво, хуже честного её отсутствия.
Когда выйдет, я напишу отдельный разбор: что из перечисленного пережило встречу с живыми пользователями, а что пришлось переделать. Второе, судя по опыту, будет интереснее.