# Осиротевшие дочерние процессы агента могут сохранять доступ к учетным данным

Тайм-аут AI-агента не обязательно означает его остановку. Если его оболочка, компилятор, HTTP-помощник или SSH-команда успели создать дочерний процесс до срабатывания тайм-аута, этот процесс может продолжить работу после исчезновения родительского PID. Если он унаследовал действующие учетные данные или уже аутентифицированное соединение, тайм-аут не отменил полномочия, которые, как вам казалось, были отозваны.

Я видел отчеты об инцидентах, которые начинались словами «агента завершили в 14:03», а заканчивались API-вызовом в 14:11. Обычно никакого хитрого эксплойта никто не находил. Раннер завершил один процесс, а полезная работа к тому моменту уже переместилась в другой. Очистка процессов кажется технической мелочью, пока автономный агент не получает рабочие учетные данные. После этого она становится частью границы безопасности.

## Завершение родителя не останавливает работу

Дочерний процесс может пережить родительский, потому что ядро отслеживает процессы независимо, а не как одноразовые расширения команды оболочки. Когда родитель завершается, операционная система передает его дочерние процессы системному обработчику или супервизору. У дочернего процесса сохраняются собственные PID, память, файловые дескрипторы, сокеты, текущий каталог и часто окружение.

Такое поведение нормально. На нем работают системы сборки и мультиплексоры терминала. На него рассчитывают менеджеры служб. Ошибка возникает, когда PID родителя принимают за границу выполнения запуска агента.

Рассмотрим знакомую цепочку:

```text
agent-runner (PID 4102)
  shell tool wrapper (PID 4131)
    deployment script (PID 4140)
      ssh helper (PID 4144)
```

Раннер достигает установленного срока и отправляет сигнал PID 4102. Если оболочка или скрипт развертывания не завершились вместе с ним, оставшаяся цепочка может продолжить работу. Еще сложнее становится, когда скрипт запускает фоновую задачу с помощью `&`, использует `nohup`, вызывает менеджер служб или просит удаленный хост начать работу. Родитель может корректно завершиться, пока дочерний процесс уже не стал независимым.

Поэтому осиротевший дочерний процесс агента опаснее обычного потребителя CPU. Это неконтролируемое продолжение решения, которое человек или планировщик считал завершенным. Ущерб зависит от доступных процессу ресурсов, но ошибка возникает раньше: раннер выбрал не ту единицу для остановки.

POSIX описывает механизмы, лежащие в основе такого поведения, в интерфейсах процессов и управления заданиями. Процесс-группа состоит из связанных процессов с одним идентификатором группы. Сигналы можно отправлять всей группе, а не одному участнику. Сессия объединяет одну или несколько процесс-групп и обычно связана с управляющим терминалом. Это разные понятия ядра, и смешивание этих терминов приводит к ненадежному коду очистки.

## Тайм-аут должен завершать изолированную единицу

Тайм-аут безопасен только тогда, когда он нацелен на изолированную единицу, созданную до запуска агента. Для локального дерева команд такой единицей обычно служит выделенная процесс-группа. Раннер сразу записывает идентификатор группы, а после истечения срока отправляет сигналы этой группе.

Порядок действий важен. Не ждите очистки, чтобы обнаружить дочерние процессы. К этому моменту исходный родитель может уже исчезнуть, связь PPID может вводить в заблуждение, а другой запуск уже мог начаться.

Практический путь обработки тайм-аута состоит из пяти действий:

1. До запуска агента создайте выделенную группу или задачу под управлением супервизора.
2. В одной записи сохраните ID запуска, PID, PGID, время начала, команду и срок завершения.
3. При тайм-ауте сначала пометьте запуск как истекший, а уже потом отправляйте сигнал, чтобы новые действия нельзя было принять за одобренную работу.
4. Отправьте `TERM` записанной изолированной единице, подождите короткий заданный период, а затем отправьте `KILL` только оставшимся процессам.
5. Сохраните снимок результата, время отправки сигналов, состояние завершения и оставшиеся PID.

Сначала нужна запись, потому что очистка может совпасть с быстрым завершением процесса. Если в журнале написано только «агент завершен», из него нельзя понять, какой процесс был завершен, какие дочерние процессы входили в его группу и успел ли какой-то дочерний процесс выйти из нее до отправки сигнала.

На macOS проверяйте идентификаторы, а не доверяйте отображению дерева процессов. Эта команда показывает поля, которые объясняют большинство сбоев при тайм-ауте:

```sh
ps -axo pid,ppid,pgid,sid,lstart,etime,command
```

Перед очисткой вывод для корректно завершенного запуска может выглядеть так:

```text
  PID  PPID  PGID   SID  STARTED                  ELAPSED COMMAND
 4102  3988  4102  4102  Thu Jul 24 14:00:02 2026   00:31 agent-runner ...
 4131  4102  4102  4102  Thu Jul 24 14:00:02 2026   00:31 /bin/sh -c ...
 4144  4131  4102  4102  Thu Jul 24 14:00:04 2026   00:29 ssh ...
```

Значения приведены для примера, их нельзя жестко зашивать в код. Важно, что запуск имеет известный PGID, равный 4102. При очистке сигнал отправляется этой группе, а не только PID 4102. Отрицательная цель указывает `kill` отправить сигнал процесс-группе:

```sh
kill -TERM -4102
```

Проверьте синтаксис для используемых языка выполнения и операционной системы. Некоторые оболочки плохо обрабатывают отрицательные числа или принимают дополнительный аргумент за параметр команды. Не стройте границу безопасности вокруг командной строки, которую вы не проверили во время настоящего тайм-аута.

У сигнала группе тоже есть ограничения. Дочерний процесс может вызвать `setsid`, создать новую процесс-группу, передать работу другой службе или запустить процесс на удаленной машине. Контроль группы закрывает обычный локальный путь выхода, но не любой возможный путь. Раннер должен считать такие переходы явной передачей ответственности с собственной отменой и аудитом.

## Процесс-группы, сессии и учетные данные решают разные задачи

Процесс-группа дает локальную цель для сигнала. Сессия устанавливает более широкую границу и может изолировать управление заданиями терминала. Ни одна из них не отзывает учетные данные, которые процесс скопировал в память или записал на диск. Это отдельные средства контроля, и для каждого нужны свои подтверждения.

Различие часто теряется, потому что сбой выглядит как одно событие: у агента истекает тайм-аут, а позднее успешно выполняется вызов. Слой очистки спрашивает: «Какие локальные процессы нужно остановить?» Обработка учетных данных спрашивает: «Какой процесс все еще может авторизовать внешнее действие?» Аудит спрашивает: «Можем ли мы доказать, какое действие произошло после завершения одобрения?» Процесс-группа отвечает только на первый вопрос.

Переменные окружения, это классический пример плохой передачи. Если раннер экспортирует `API_TOKEN`, каждый потомок получает его, пока следующий процесс не очистит окружение. Дочерний процесс может скопировать токен, передать его другой программе или сохранить аутентифицированное соединение. Замена переменной окружения после запуска родителя не отзывает байты, которые уже были унаследованы.

Файлы и сокеты не менее важны. Процесс может унаследовать открытый файловый дескриптор, указывающий на файл с учетными данными, подключенный HTTPS-сокет с состоянием сессии или сокет SSH-агента. Фоновый процесс может продолжать использовать дескриптор после завершения родителя. Флаг close-on-exec помогает предотвратить случайное наследование при новом `exec`, но не помогает, если дескриптор уже есть у дочернего процесса или программа намеренно передает его дальше.

С SSH нужна особая осторожность. Закрытый ключ, загруженный в локальный помощник, сокет SSH-агента, мультиплексированное управляющее соединение и удаленная команда имеют четыре разных срока жизни. Завершение локального родителя может оставить существующее соединение или удаленную команду работающими. Если агент может выполнять произвольные SSH-команды, раннеру нужно понимать, что именно было одобрено: вызов локального помощника, аутентифицированный канал или удаленная задача. Когда все три варианта называют «SSH-командой», граница, которой нужно управлять, исчезает.

Более безопасный подход передает запрос на действие, а не исходный секрет. Процесс просит компонент полномочий выполнить конкретный HTTP-запрос или SSH-операцию. Этот компонент хранит секрет, решает, может ли текущий запуск его использовать, выполняет действие и возвращает результат. Сбежавший дочерний процесс все еще может повторять запросы, пока его запуск остается разрешенным, поэтому компонент полномочий должен учитывать истечение срока и отзыв.

Sallyport придерживается такого разделения для поддерживаемых HTTP- и SSH-каналов: агент обращается через свой MCP-адаптер, а приложение хранит API- или SSH-материалы и само выполняет действие, вместо того чтобы передавать секрет процессу агента. Это не отменяет очистку процессов, но устраняет самый простой способ унаследовать учетные данные.

## Запуск в фоне, это путь выхода, а не безобидная деталь оболочки

Самая частая ошибка очистки начинается с удобной возможности оболочки. Кто-то пишет `command &`, запускает конвейер, использует `nohup` или запускает среду выполнения, которая создает рабочие процессы. Родительская команда выглядит завершенной или получает тайм-аут, а рабочий процесс продолжает работу.

Конвейеру оболочки нужно уделить особое внимание. Раннер может выполнить `/bin/sh -c 'generator | uploader'` и записать PID оболочки. У оболочки будут дочерние процессы для обеих сторон конвейера. В зависимости от того, как раннер создает группу, завершение только оболочки может оставить работающими генератор или загрузчик. Если загрузчик владеет учетными данными и сетевым соединением, именно его и нужно было остановить.

`nohup` опаснее, чем многие признают. Он не дает процессу получить сигнал разрыва, но не делает его надежным и не сообщает о себе раннеру. В автоматизации это обычно означает, что кто-то хочет сохранить работу после выхода из терминала или завершения родителя. Для управляемой службы это может быть допустимо, но тогда задача должна перейти под контроль супервизора, который владеет ею и записывает ее жизненный цикл. Она не должна случайно оставаться внутри вызова инструмента агента.

Отсоединенные дочерние процессы создают отдельную проблему. В Node.js параметр `detached: true` при вызове `spawn` намеренно дает дочернему процессу новую процесс-группу и сессию в Unix-подобных системах. Такое поведение может пригодиться настольному приложению, которое должно пережить запускатель. В исполнителе агента это плохой вариант по умолчанию, потому что он ломает обычное завершение группы раннером.

```js
import { spawn } from "node:child_process";

const child = spawn("/bin/sh", ["-c", "sleep 600"], {
  detached: true,
  stdio: "ignore"
});
child.unref();
```

Этот фрагмент нужен для проверки очистки, а не как шаблон для копирования в раннер агента. Он создает дочерний процесс, который больше не входит в обычную процесс-группу родителя. Если среда инструментов позволяет выполнить такой код, одной очистки процесс-группы недостаточно для полной гарантии. Ограничьте отсоединенный запуск, перехватывайте его на границе инструмента или запускайте инструмент в более строгой изоляции операционной системы.

Популярная рекомендация «просто убить дерево процессов» тоже не работает, если под ней понимается рекурсивный обход PPID. PPID описывает состояние в конкретный момент, а не постоянную границу членства. Связь родителя может измениться. Дочерний процесс может создать новый процесс между обходом и отправкой сигнала. Потомок может выйти из дерева. При запуске запишите идентичность группы или задачи, а любой выход за ее пределы сделайте намеренным и подлежащим аудиту.

## Не очищайте процессы по имени команды

`pkill` и широкое сопоставление имен кажутся удобными, потому что требуют одной короткой команды. Для параллельных запусков агентов это плохой выбор. `pkill ssh` может завершить несвязанное интерактивное соединение. `pkill python` может остановить локальный инструмент разработчика. Сопоставление строки аргументов может пропустить процесс, который изменил аргументы, или ошибочно совпасть с процессом другого запуска.

Используйте имя команды только как подсказку для расследования. Для принудительной остановки используйте границу владения.

Если раннер знает процесс-группу, он может сначала проверить ее участников. На macOS отфильтруйте таблицу процессов по записанному номеру, а не по предположительному имени исполняемого файла:

```sh
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
```

В такой раскладке столбцов первый столбец, это PID, второй, PPID, а третий, PGID. В рабочем коде не разбирайте текст, ориентированный на человека, если язык позволяет обратиться к нативным API процессов. Для терминала оператора этот вывод дает быструю и понятную проверку перед выбором сигнала.

Затем отправьте сигнал группе и снова проверьте ее:

```sh
kill -TERM -4102
sleep 2
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
```

Если участники остались, выясните причину, прежде чем автоматически отправлять `KILL`. Процесс, заблокированный в непрерываемой работе ядра, требует другого расследования, чем процесс, проигнорировавший `TERM`. Процесс с другим PGID пережил сигнал не случайно: он пересек границу. Это свидетельствует о решении в дизайне, поведении фреймворка или вредоносном инструменте.

После заданного периода ожидания `KILL` уместен для локальных процессов, которые остались в записанной группе и не должны завершать работу. Не обещайте мягкую очистку, когда агент уже вышел за окно своих полномочий. Отведенное время нужно для закрытия файлов и записи статуса, а не для бесконечного продолжения внешней работы.

Контейнеры и менеджеры служб могут дать более надежное владение, чем обычная процесс-группа, если вы контролируете среду выполнения. Рабочие процессы агентов в macOS автоматически не получают Linux cgroups, поэтому не переносите советы о cgroups без изменений. На Mac базовым уровнем часто служат выделенная дочерняя процесс-группа, узкие разрешения инструментов и явный договор об удаленных задачах. Если вам нужны ограничения ресурсов или полная изоляция всех потомков, используйте среду выполнения, которая действительно это обеспечивает.

## Аудит должен пережить родительский процесс

Нельзя восстановить сведения о сбежавшем действии по финальному тексту агента. Родитель мог завершиться до сброса журналов, а продолживший работу дочерний процесс не обязан отчитываться. Стройте аудит вокруг неизменяемых событий, которые наблюдаются за пределами процесса агента.

Для каждого запуска до разрешения внешней работы сохраните как минимум такие поля:

```json
{
  "run_id": "run-7f3c",
  "started_at": "2026-07-24T14:00:02Z",
  "deadline_at": "2026-07-24T14:00:32Z",
  "parent_pid": 4102,
  "process_group": 4102,
  "approval_identity": "signed agent process identity",
  "state": "active"
}
```

В записи действия должны быть ID запуска, порядковый номер действия, время принятия шлюзом, канал, идентичность цели и результат. По умолчанию не записывайте исходные bearer-значения, закрытые ключи, заголовки авторизации или полные тела запросов. Запись, раскрывающая учетные данные при документировании их использования, создает второй инцидент.

При срабатывании тайм-аута добавьте переход состояния до отправки любого сигнала:

```json
{
  "run_id": "run-7f3c",
  "event": "deadline_expired",
  "observed_at": "2026-07-24T14:00:32Z",
  "process_group": 4102,
  "signal": "TERM"
}
```

Теперь у расследования есть четкий вопрос и четкий ответ: началось ли внешнее действие после `deadline_expired`? Если шлюз принимает действие после этого события, значит, он не проверяет состояние запуска либо вызывающая сторона не передала идентичность, которую ожидал шлюз. Если действие началось до истечения срока, но завершилось после него, укажите это прямо. Время начала и время завершения различаются, а их смешение заставляет обычную работу, уже находившуюся в процессе, выглядеть как выход за границу.

Для людей храните календарное время, а для упорядочивания событий на одной машине, монотонное прошедшее время. Системная дата может измениться из-за синхронизации или ручной корректировки. Во время инцидента не нужна лекция о хронометраже, нужны данные, которые не позволят заявить, будто действие произошло после завершения родителя, если часы расходятся.

В Sallyport журналы Sessions и Activity разделяют представление о запуске и отдельные вызовы, а зашифрованный журнал с цепочкой хешей можно проверить офлайн с помощью `sp audit verify`. Это полезно после исчезновения родителя, потому что журнал аудита не зависит от того, решит ли агент сам сообщить историю.

Журнал аудита, это свидетельство, а не изоляция. Он показывает, что вызов состоялся, и помогает при последующей проверке. Он не завершает локальный осиротевший процесс, не отзывает уже переданные ему данные и не отменяет удаленную команду без механизма отмены. Считайте это разными обязанностями и отражайте каждую в записи запуска.

## Восстанавливайте картину сбоя в правильном порядке

Обнаружив возможного осиротевшего процесса, сначала сохраните факты, а потом устраняйте проблему. Срочный `kill -9` может быть оправдан для остановки опасной работы, но он способен уничтожить связи между процессами, необходимые для исправления раннера. Если ситуация позволяет, сначала сохраните снимок процессов.

Начните с записи завершенного запуска. Зафиксируйте его родительский PID, PGID или идентификатор задачи, время начала, срок завершения и каждый отправленный сигнал. Затем снимите текущее состояние процессов. На macOS сравните PID, PPID, PGID, SID, время работы и команду. У осиротевшего процесса PPID часто равен 1 или указывает на супервизор, но не делайте это единственным критерием. Процесс может оставаться источником риска, даже если его PPID по-прежнему указывает на другой процесс.

Затем определите, что именно вышло за границу:

- Процесс в исходной PGID пережил ожидаемый сигнал.
- У процесса новая PGID или SID, поэтому локально он отсоединился.
- Локальный процесс запросил удаленный запуск, и удаленная задача продолжилась.
- Учетные данные или аутентифицированный канал остались пригодными после завершения запуска.

Для каждого класса нужен свой способ исправления. Первый указывает на обработку сигналов или время очистки. Второй, на неразрешенный механизм отсоединения. Третий требует идентификатора удаленной задачи и протокола отмены. Четвертый требует, чтобы полномочия действий связывали запросы с текущим запуском, а не полагались на корректное поведение родительского процесса.

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

Наконец решите, требуется ли смена или отзыв учетных данных. Если исходный секрет не попал к агенту, а шлюз действий отклонял вызовы после истечения срока, оставшийся риск может ограничиваться уже завершенными изменениями или переданными данными. Если дочерний процесс унаследовал bearer-токен, закрытый ключ, доступ к SSH-агенту или подключенный административный канал, считайте, что он может продолжать действовать, пока вы не отключите это полномочие. Здесь команды теряют время, споря, «сделал ли процесс, скорее всего, что-нибудь». Если вы не можете доказать, что он потерял доступ, удалите доступ.

## Тест сбоя должен сразу показать осиротевший процесс

В тесте тайм-аута должна быть проверка, которая явно сообщает о выжившем дочернем процессе. Проверка только того, что родитель вернул тайм-аут, почти ничего не доказывает.

Создайте фикстуру, которая запускает родителя и дочерний процесс в одной записанной группе. Дочерний процесс должен ждать достаточно долго, чтобы тест успел завершить родителя по тайм-ауту. До блокировки родитель должен сообщить PID дочернего процесса и группы. После тайм-аута проверьте, что ни одного процесса не осталось и что в журнале тестовых действий нет принятого вызова после записи об истечении срока.

Эта оболочечная фикстура показывает форму проблемы:

```sh
#!/bin/sh
(
  trap 'exit 0' TERM INT
  sleep 600 &
  child=$!
  printf 'parent=%s child=%s pgid=' "$$" "$child"
  ps -o pgid= -p "$$" | tr -d ' '
  wait "$child"
)
```

Запускайте ее тем же кодом, который использует исполнитель агента, а не из интерактивного терминала, который может незаметно задавать другое поведение управления заданиями. Сохраните выведенные идентификаторы, принудительно завершите срок и проверьте таблицу процессов. Если дочерний процесс остался, вы узнали нечто конкретное: запуск не создал ожидаемую группу или сигнал тайм-аута не был отправлен этой группе.

Повторите тест для инструментов, которые реально вызывают ваши агенты. Оболочечные обертки, API дочерних процессов Node, вызовы subprocess в Python, менеджеры пакетов, компиляторы и SSH-помощники по-разному работают с группами, дескрипторами и передачей сигналов. Сотни тестов не нужны. Нужен один разрушающий тест для каждого шаблона запуска, способного создавать потомков.

Добавьте вторую фикстуру, которая пытается отсоединиться. В тесте можно использовать вызов, создающий новую сессию, или параметр среды выполнения для создания отсоединенного дочернего процесса. Ожидаемым результатом должно быть отклонение, передача под контроль супервизора или явная запись о регистрации. Тихий успех означает дефект, потому что он сообщает агенту, что работу можно вынести за границу тайм-аута.

Не ограничивайтесь локальными PID. Если агент может запускать удаленную работу, удаленная сторона должна возвращать идентификатор задачи, а тест должен проверять отмену после локального тайм-аута. Нужное состояние звучит не как «SSH-клиент завершился». Нужно, чтобы «удаленная задача, связанная с этим запуском, остановилась или получила документированного владельца оставшейся работы».

## Одобрение должно истекать раньше процесса

Одобрение человеком запуска агента мало значит, если дочерний процесс может использовать его после объявления запуска завершенным. Свяжите полномочия внешнего действия с идентичностью и состоянием запуска, которые компонент полномочий проверяет для каждого запроса. Когда раннер помечает запуск как истекший, новые действия этого запуска должны завершаться отказом, даже если локальный осиротевший процесс все еще использует CPU.

Это не означает, что нужно просить человека подтверждать каждую команду оболочки. Компонент, который может расходовать полномочия, должен знать, когда запуск завершился. Он может выдать запуску краткоживущий маркер, отклонять его после отзыва и записывать отказ. Завершение процесс-группы ограничивает локальный ущерб, а граница действий ограничивает последствия гонки при очистке.

Сохраняйте одобрение каждого действия для операций, которым оно нужно, но не путайте одобрение с надзором. Пользователь может в начале сессии одобрить подписанный процесс агента. Если потомок продолжает работать после завершения агента, он вышел за пределы одобренного срока выполнения, даже если унаследовал тот же локальный контекст. Система должна уметь принудительно обеспечить это условие, а не надеяться на вежливость дочернего процесса.

Раннер также должен различать отмену запуска и отмену внешнего эффекта. Отмененный запрос мог уже попасть в API. Завершенный SSH-клиент мог успеть запустить удаленную команду. Записывайте запрос на отмену, наблюдаемое локальное завершение и любое подтверждение внешней системы как отдельные события. Формулировка менее успокаивающая, чем «отменено», зато показывает операторам, что именно им известно.

Первое исправление, которое я внес бы в исполнитель агента, небольшое и неприметное: создавать и записывать выделенную процесс-группу при запуске, затем завершать эту группу в пути обработки тайм-аута и проверять результат в тесте сбоя. Добавьте границу действий, которая отклоняет запросы запуска после истечения срока. Когда эти два условия выполнены, осиротевший процесс становится инцидентом, который можно локализовать и объяснить, а не смутным страхом, что где-то все еще идет работа.
