Привилегии и доверие
Ядро не строит песочницу: владелец даёт агенту власть над машиной. Здесь про то, где эта власть кончается и чем её ограничивать снаружи.
Модель доверия
Агент запускает команды, читает и правит файлы, ходит в сеть — от имени человека, который его поставил. Модель прав одна на всех: подключился — значит управляешь. Гранулярных разрешений внутри харнесса нет намеренно: флаг «этому клиенту можно только читать» ничего не стоит, пока у того же клиента есть run. Отсюда:
- единственная защита API — bearer-токен; его утечка равна полному контролю;
- ограничивать надо машиной или контейнером, а не настройками агента: настоящий барьер — отдельный контейнер, пользователь или ВМ.
Привилегии: брокер snens-priv
Повышать права агенту разрешено только через один исполняемый файл с явным whitelist — не через россыпь строк в sudoers, где матчинг аргументов ошибается незаметно. Установка (делает человек, не агент): положить scripts/snens-priv в /usr/local/bin/snens-priv (root:root 0755), скопировать deploy/snens-priv.conf.example в /etc/snens-priv.conf (root:root 0644) и разрешить ровно один вызов в /etc/sudoers.d/snens-priv:
echo "$(whoami) ALL=(root) NOPASSWD: /usr/local/bin/snens-priv" \
| sudo tee /etc/sudoers.d/snens-priv && sudo chmod 0440 /etc/sudoers.d/snens-priv
Три ключа конфига; всё, что не описано, запрещено:
units = "caddy my-app" # юниты systemd (--user), которые можно дёргать
roots = "/var/www /srv/data" # корни, где разрешены mkdir -p и chown
owner = "deploy:deploy" # единственный владелец для chown
Пустой конфиг — брокер отказывает во всём: намеренный дефолт, установка никому ничего не выдаёт.
| действие | что проверяется |
|---|---|
systemctl <глагол> <юнит> | глагол из restart/reload/start/stop/status, юнит из units (.service можно не писать), ровно 2 аргумента |
mkdir -p <путь>… | только -p; каждый путь внутри roots, .. запрещён всегда |
chown [-R] <владелец> <путь> | владелец ровно из owner, путь внутри roots |
Правки /etc, apt-get install, tee в системные файлы в whitelist не входят намеренно — их делает человек. Валидация без выполнения: SNENS_PRIV_DRYRUN=1 snens-priv mkdir -p /var/www/x печатает ok: … либо отказывает кодом 2.
Очередь одобрений
Headless-сессии (веб, десктоп, Telegram) негде ввести пароль, поэтому шим sudo в такой сессии не зовёт askpass, а спрашивает согласие у владельца кнопкой:
- шим подключается к сокету
<каталог-данных>/approval.sock(0600) и шлёт заявку{session, cwd, argv}— точное действие, которое человек видит в карточке; - ядро эмитит
approval.requestedв сессию владельца и ждёт; - решение приходит
POST /v1/sessions/{id}/approvals/{aid};allow— вызов уходит какsudo -nчерез брокер, всё прочее (включая молчание) — отказ.
approval_timeout_seconds (дефолт 300) — сколько ждать; 0 выключает очередь, и шим сразу отдаёт sudo без пароля (тот падает). Заявка одноразовая и привязана к сессии, действию и каталогу. Кнопка не подменяет пароль: одобренный вызов идёт sudo -n и сработает только под NOPASSWD-правилом на брокере; нет правила — «password required», и это ожидаемый отказ, а не обход. В живом TUI (snens tui-local) вместо кнопки askpass.
Токен и пейринг
Файл api.token (0600) ядро создаёт при первом старте; публичный файл или токен короче 32 символов с пробелами — отказ на старте. Передаётся заголовком Authorization: Bearer …; для двух SSE-маршрутов допускается ?token=, потому что браузерный EventSource не умеет заголовков (цена — query в логах прокси), для остального API такого варианта нет. Токен = полный контроль над API и машиной.
Короткий код вместо передачи постоянного токена руками: snens pair печатает 6 цифр. Клиент шлёт POST /v1/pair/claim {code, name} и получает свой постоянный токен; в реестре clients/ лежит только SHA-256: по нему нельзя восстановить токен, и утечка реестра не отдаёт живых секретов. Устройство отзывается поштучно (snens clients revoke <id>, DELETE /v1/clients/{id}).
Защита кода: одноразовый, 5 минут, после 5 неудачных попыток сгорает целиком. Код открывает тот, кто уже внутри (нужен токен), — POST /v1/pair/start.
Транспорт
Ядро слушает loopback. Не-loopback адрес требует явного --allow-remote, публичный — ещё и --insecure-public:
snens serve --listen 0.0.0.0:<порт> # отказ: loopback only
snens serve --listen <приватный-адрес>:<порт> --allow-remote
Без --insecure-public проходят только приватные диапазоны: RFC1918, CGNAT (100.64/10 — tailnet), link-local, unique-local IPv6, loopback. Проверка ловит опечатку (0.0.0.0 вместо адреса tailnet), а не заменяет голову. Шифрование — забота транспорта: tailnet, WireGuard, SSH-туннель либо TLS-прокси перед ядром (--tls-cert/--tls-key принимаются, но HTTPS ядро само не отдаёт). CORS открыт для любого origin — это нужно парному клиенту, и единственная реальная защита — токен.
Чего не делать
- не выставлять порт API в открытую сеть без TLS и приватного транспорта: токен пойдёт открытым текстом;
- не держать
api.tokenи.envтам, где их читает кто-то ещё, и не коммитить их:config.tomlкопируемо, ключи — нет; - не давать агенту
NOPASSWD: ALL; расширять whitelist брокера точечно; - не запускать два ядра на одном каталоге данных: поверхность берёт эксклюзивный lock.