← Все статьи

Прежде чем модель получит право голоса: как устроены защитные механизмы shell в Agent!

Как ShellSafetyService, повторная проверка на стороне демона и второе мнение Jev не дают ИИ-агенту стереть ваш Mac — разбор на реальном коде Swift.

На этой неделе TechRadar сообщил, что ИИ-агент для программирования за короткое время удалил около 48,000 файлов, а затем извинился. Извинения ничего не изменили. Файлы пропали.

Agent! может выполнять shell-команды от вашего имени через Launch Agent и от имени root через Launch Daemon. Именно это делает его полезным: он может записать образ на SD-карту, исправить права доступа или очистить папку сборки. Но это же означает, что «модель, скорее всего, будет осторожна» — не модель безопасности. Поэтому Agent! не просит модель быть осторожной. Он проверяет каждую команду в коде, до того как что-либо запустится, на трёх уровнях.

Уровень 1: жёстко зашитый защитный механизм, который модель не сможет уговорить

ShellSafetyService — это обычный Swift-enum в Agent/Services/ShellSafetyService.swift. Документирующий комментарий в начале файла сразу задаёт тон: сервис работает перед каждой точкой выполнения и отклоняет катастрофические команды, не отправляя их на выполнение. Системные промпты названы тем, чем они являются: страховкой, а не уровнем принуждения.

Точка входа принимает команду, контекст, в котором она будет выполняться, и папку проекта вкладки:

static func check(_ command: String,
                  context: Context = .userAgent,
                  projectFolder: String = "") -> Verdict

Verdict — это allowed, понятная человеку причина reason и короткий идентификатор правила rule для журнала аудита. Причина написана для модели: она возвращается как результат инструмента, чтобы LLM понимала, почему ей отказали, и не пыталась просто повторить команду.

Он читает составные команды так же, как shell

Наивный фильтр проверяет начало строки. Злоумышленники и запутавшиеся модели так не играют. check разбивает команду по ;, &&, ||, | и переводам строк, а затем проверяет каждый сегмент. Поэтому ls; rm -rf / блокируется, хотя первая половина безобидна.

Из этого порядка есть одно намеренное исключение. Классическая fork-бомба :(){ :|:& };: зависит от ; и | — именно тех символов, по которым разделитель режет строку. Поэтому проверка на fork-бомбу выполняется для всей команды целиком, до разбиения.

Он снимает маскировку

Перед сопоставлением rm вспомогательная функция stripPrefixWrappers снимает обёртки, которые не меняют сути команды: sudo, exec, command, builtin, eval и doas. Она также убирает ведущие присваивания переменных окружения вроде FOO=bar. Это значит, что sudo rm -rf ~ и FOO=1 exec rm -rf ~ попадают под то же правило, что и голая команда.

Флаги тоже разбираются парсером, а не сопоставляются по шаблону. -rf, -fr, -Rf, -r -f, --recursive --force — всё это учитывается, потому что парсер ищет r и f в любой группе коротких флагов, а также их длинные формы.

Что он отклоняет

Вот идентификаторы правил, которые встречаются в исходном коде:

ПравилоЧто оно блокирует
rm.catastrophicrm -rf для /, голого glob-шаблона вроде * или ./* или вашей домашней папки в любом написании (~, ~/*, $HOME, ${HOME}/* или буквальный путь к домашней папке)
rm.no-preserve-root--no-preserve-root — явный обход защиты /
rm.project-folderРекурсивное удаление папки проекта, в которой работает агент, или всего её содержимого через glob. Удаление именованной подпапки по-прежнему разрешено.
rm.dangerous-targetДругие опасные цели rm на пользовательском уровне
fork-bombСамовоспроизводящиеся бомбы процессов
mv.to-devnull«Удаление» файлов путём перемещения в /dev/null
find.delete-broad-rootfind … -delete от слишком широкого корня
perms.recursive-on-rootРекурсивное изменение прав на путях корневого уровня

Правило для папки проекта заслуживает отдельного внимания. Именно оно напрямую соответствует инциденту, описанному выше: агент никогда не должен стирать тот самый проект, над которым его попросили работать, как бы ни был сформулирован запрос.

С root обращаются иначе — и это сделано намеренно

Можно было бы ожидать, что root-демон получит самые строгие правила. На деле он получает самые узкие. Комментарий объясняет почему: демон существует для системной работы — клонирования дисков или mkfs — и «не должен нам мешать». Поэтому в контексте .rootDaemon функция check сразу переходит к checkCatastrophicRm, которая блокирует только три невосстановимых шаблона (/, голые glob-шаблоны и домашнюю папку), а также --no-preserve-root и стирание папки проекта. Всё остальное — на усмотрение оператора.

Это проектное решение стоит перенять. Защиту, которая блокирует легитимную административную работу, просто отключают. Защита, которая блокирует только невосстановимые ошибки, остаётся включённой.

Уровень 2: демон проверяет ещё раз

Приложение вызывает ShellSafetyService.check до того, как что-либо отправить. Вспомогательные процессы не принимают это на веру. В Shared/DaemonCore.swift, сразу после записи в журнал аудита, демон выполняет ту же проверку на своей стороне:

// Defense-in-depth: the app already runs this same check before
// dispatching, but any same-team-signed client can reach the mach
// service directly.
let verdict = ShellSafetyService.check(
    script,
    context: auditCategory == .launchDaemon ? .rootDaemon : .userAgent,
    projectFolder: workingDirectory
)

Заблокированная команда записывается в журнал как отклонённая вместе с идентификатором правила и получает код завершения 126. До /bin/zsh она так и не доходит. Это важно, потому что XPC-слушатели принимают любого клиента, подписанного той же командой разработчиков. Если что-то другое с той же подписью подключится к mach-сервису напрямую, оно всё равно упрётся в защиту.

Уровень 3: Jev — второе мнение там, где шаблоны бессильны

Правила-шаблоны точны, но знают только те шаблоны, которые вы записали. Множество разрушительных команд совсем не похожи на rm -rf /: truncate не того файла, SQL-запрос DROP, переданный через пайп в CLI, dd не в ту сторону.

Этим занимается Jev — необязательный консультативный уровень в JevAdvisor.swift. Каждая команда, которая уже прошла ShellSafetyService, отправляется в Jev, и он оценивает, насколько вероятно, что она безвозвратно уничтожит данные. Если оценка выше заданного порога, Agent! отказывает с понятным сообщением:

Refused: Jev rated this command 85% likely to irreversibly destroy data.
Command: …
Narrow the target or run it yourself if this is intentional.

Несколько деталей показывают, насколько тщательно продуманы границы этого механизма:

  • Он дополняет, но никогда не заменяет. Комментарий в коде однозначен: ShellSafetyService — это уровень принуждения, а Jev лишь ловит то, что пропустили правила-шаблоны. Поэтому порог намеренно высокий. По умолчанию он равен 70%, и в Настройках его можно менять от 0 до 100% с шагом 10%.
  • При сбое он пропускает, но громко. Отсутствие ключа, выключенный переключатель или сбой сети означают «мнения нет», так что нестабильный сервис никогда не застопорит вашу задачу. При этом сбой всё равно записывается в журнал (⚠️ Jev check failed, command allowed), чтобы истёкший ключ никогда не принимали за «Jev считает, что безопасно».
  • Отмена — значит отмена. Если вы остановите задачу, пока Jev думает, команда не запустится.
  • Каждый вердикт виден. Для каждой проверки в журнал записываются процент риска, решение (разрешено или отклонено), ответившая модель и количество токенов.

Проверено тестами, а не предположениями

В AgentTests/ShellSafetyServiceTests.swift содержится 30 тестов для защитного механизма. А поскольку каждая команда вспомогательного процесса записывается в журнал аудита до выполнения, всегда можно восстановить, что агент пытался сделать, — в том числе то, что ему сделать не позволили.

Главный вывод для всех, кто создаёт агентов

  1. Обеспечивайте соблюдение правил в коде, а не в промпте. Промпты — это пожелания. Swift-enum — нет.
  2. Разбирайте команды так, как это делает shell. Разбивайте составные команды, снимайте обёртки и нормализуйте флаги.
  3. Проверяйте ещё раз на привилегированной границе. Не рассчитывайте, что ваш собственный клиент — единственный, кто обращается к сервису.
  4. Блокируйте невосстановимое, а не необычное. Узкие правила выживают, шумные — отключают.
  5. Добавляйте суждение поверх и делайте его сбои заметными. Второе мнение ценно только тогда, когда видно, что оно не ответило.

Всё это открыто на GitHub. Читайте, ищите слабые места и создавайте issue, если найдёте команду, которую следовало остановить.