бот: push ─► git archive <sha>:services/<name> ─► ssh tgit-deploy@host deploy <name> <sha> (архив — в stdin)
│ forced command: ключ запускает только tgit-runner
host: tgit-runner ─► проверка аргументов и архива ─► docker build ─► docker run …-next (песочница)
─► healthy? да — заменить старый · нет — удалить новый, старый работает, exit 3
Код до хоста довозит бот, поэтому у хоста нет доступа к репозиторию загрузок.
Что сделано
infra/runner/tgit-runner (bash): deploy, restart, remove, list. Аргументы из SSH_ORIGINAL_COMMAND не исполняются и не глобятся. Имя сервиса — ^[a-z][a-z0-9-]{1,30}$, sha — hex. В архиве допускаются только файлы и папки, без симлинков и .., не больше 20 МБ, обязателен main.py. health_url — только http://localhost:…, в HEALTHCHECK подставляется в exec-форме, без shell.
Песочница (ARCHITECTURE §4): uid 10001, read-only FS, /tmp — tmpfs 16 МБ noexec, --cap-drop ALL, no-new-privileges, 128 МБ без swap, 0.25 CPU, 64 PID, --network none. Сеть появляется, только если её просит tgit.yamlи админ внёс сервис в egress-allow на хосте. Не больше 10 сервисов на хост, хранятся 2 последних образа.
Без простоя и без отката «вслепую»: новая версия стартует как tgit-svc-<name>-next и заменяет старую, только когда станет healthy.
Бот: после push разворачивает изменённые services/<name>/. Автор получает «🚀 работает» или «⛔ не развёрнут: …» с хвостом лога, команда — алерт ⛔. Если папка сервиса удалена — remove. В «Статусе» у админа появилась кнопка «♻️ Перезапустить», только для сервисов раннера — не для бота и БД.
install.sh, authorized_keys.example, infra/runner/README.md. CD бота сам обновляет tgit-runner на хосте.
Как проверено
Живой Docker:hello — контейнер со всеми флагами песочницы (docker inspect); три деплоя подряд; сломанная версия → FAIL … previous version kept, старая работает.
examples/escape_attempts: запись в код и /etc, интернет, DNS, docker.sock, setuid(0), raw-сокет, 200 процессов, запуск из /tmp — все 9 BLOCKED.
Сервер: раннер установлен в /srv/tgit/runner, ключ в authorized_keys с command=…,restrict. Из сети бота list → OK list, а id → FAIL unknown command: forced command держит. RUNNER_* в /srv/tgit/dev/.env уже прописаны, заработает после деплоя этого PR.
334 теста (38 новых): валидация и инъекции, плохие архивы, флаги песочницы, откат, лимиты, сквозной путь «клиент → фейковый ssh → настоящий tgit-runner → фейковый docker», push → деплой в боте, перезапуск. Покрытие 92%.
Ограничения
Сервис разворачивается сразу после push. Бот перед этим проверяет только синтаксис; ruff, bandit и тесты из CI репозитория загрузок не дожидаются — это #11, вместе с возвратом к SourceCraft.
HTTP-проба «Статуса» для сервисов без сети не работает (--network none); их health — это Docker HEALTHCHECK изнутри контейнера.
Closes #13 · S-5
## Как это работает
```
бот: push ─► git archive <sha>:services/<name> ─► ssh tgit-deploy@host deploy <name> <sha> (архив — в stdin)
│ forced command: ключ запускает только tgit-runner
host: tgit-runner ─► проверка аргументов и архива ─► docker build ─► docker run …-next (песочница)
─► healthy? да — заменить старый · нет — удалить новый, старый работает, exit 3
```
Код до хоста довозит бот, поэтому у хоста нет доступа к репозиторию загрузок.
## Что сделано
- **`infra/runner/tgit-runner`** (bash): `deploy`, `restart`, `remove`, `list`. Аргументы из `SSH_ORIGINAL_COMMAND` не исполняются и не глобятся. Имя сервиса — `^[a-z][a-z0-9-]{1,30}$`, sha — hex. В архиве допускаются только файлы и папки, без симлинков и `..`, не больше 20 МБ, обязателен `main.py`. `health_url` — только `http://localhost:…`, в `HEALTHCHECK` подставляется в exec-форме, без shell.
- **Песочница** (ARCHITECTURE §4): uid 10001, read-only FS, `/tmp` — tmpfs 16 МБ `noexec`, `--cap-drop ALL`, `no-new-privileges`, 128 МБ без swap, 0.25 CPU, 64 PID, `--network none`. Сеть появляется, только если её просит `tgit.yaml` **и** админ внёс сервис в `egress-allow` на хосте. Не больше 10 сервисов на хост, хранятся 2 последних образа.
- **Без простоя и без отката «вслепую»:** новая версия стартует как `tgit-svc-<name>-next` и заменяет старую, только когда станет healthy.
- **Бот:** после push разворачивает изменённые `services/<name>/`. Автор получает «🚀 работает» или «⛔ не развёрнут: …» с хвостом лога, команда — алерт ⛔. Если папка сервиса удалена — `remove`. В «Статусе» у админа появилась кнопка «♻️ Перезапустить», только для сервисов раннера — не для бота и БД.
- `install.sh`, `authorized_keys.example`, `infra/runner/README.md`. CD бота сам обновляет `tgit-runner` на хосте.
## Как проверено
- **Живой Docker:** `hello` — контейнер со всеми флагами песочницы (`docker inspect`); три деплоя подряд; сломанная версия → `FAIL … previous version kept`, старая работает.
- **[`examples/escape_attempts`](examples/escape_attempts/):** запись в код и `/etc`, интернет, DNS, docker.sock, `setuid(0)`, raw-сокет, 200 процессов, запуск из `/tmp` — **все 9 BLOCKED**.
- **Сервер:** раннер установлен в `/srv/tgit/runner`, ключ в `authorized_keys` с `command=…,restrict`. Из сети бота `list` → `OK list`, а `id` → `FAIL unknown command`: forced command держит. `RUNNER_*` в `/srv/tgit/dev/.env` уже прописаны, заработает после деплоя этого PR.
- 334 теста (38 новых): валидация и инъекции, плохие архивы, флаги песочницы, откат, лимиты, сквозной путь «клиент → фейковый ssh → настоящий tgit-runner → фейковый docker», push → деплой в боте, перезапуск. Покрытие 92%.
## Ограничения
- Сервис разворачивается сразу после push. Бот перед этим проверяет только синтаксис; ruff, bandit и тесты из CI репозитория загрузок не дожидаются — это #11, вместе с возвратом к SourceCraft.
- HTTP-проба «Статуса» для сервисов без сети не работает (`--network none`); их health — это Docker `HEALTHCHECK` изнутри контейнера.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- infra/runner/tgit-runner: deploy/restart/remove/list по SSH forced command;
аргументы не исполняются и не глобятся; архив — только файлы и папки,
без «..», ≤ 20 МБ, есть main.py; health_url — только localhost
- песочница: uid 10001, read-only, tmpfs noexec, cap-drop ALL,
no-new-privileges, 128 МБ без swap, 0.25 CPU, 64 PID, --network none
(сеть — только по egress-allow админа), лимит 10 сервисов, 2 образа
- новая версия стартует рядом (-next) и заменяет старую, только став
healthy; иначе удаляется, старая работает, exit 3
- бот: после push git archive изменённых services/<name>/ → runner;
автору — «🚀 работает» или «⛔ не развёрнут» с логом, команде — алерт ⛔;
удалённая папка → remove; «♻️ Перезапустить» в «Статусе» для админа (S-5)
- examples/escape_attempts: все попытки выбраться — BLOCKED на живом Docker
- install.sh, authorized_keys.example; CD бота обновляет runner на хосте
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #13 · S-5
Как это работает
Код до хоста довозит бот, поэтому у хоста нет доступа к репозиторию загрузок.
Что сделано
infra/runner/tgit-runner(bash):deploy,restart,remove,list. Аргументы изSSH_ORIGINAL_COMMANDне исполняются и не глобятся. Имя сервиса —^[a-z][a-z0-9-]{1,30}$, sha — hex. В архиве допускаются только файлы и папки, без симлинков и.., не больше 20 МБ, обязателенmain.py.health_url— толькоhttp://localhost:…, вHEALTHCHECKподставляется в exec-форме, без shell./tmp— tmpfs 16 МБnoexec,--cap-drop ALL,no-new-privileges, 128 МБ без swap, 0.25 CPU, 64 PID,--network none. Сеть появляется, только если её проситtgit.yamlи админ внёс сервис вegress-allowна хосте. Не больше 10 сервисов на хост, хранятся 2 последних образа.tgit-svc-<name>-nextи заменяет старую, только когда станет healthy.services/<name>/. Автор получает «🚀 работает» или «⛔ не развёрнут: …» с хвостом лога, команда — алерт ⛔. Если папка сервиса удалена —remove. В «Статусе» у админа появилась кнопка «♻️ Перезапустить», только для сервисов раннера — не для бота и БД.install.sh,authorized_keys.example,infra/runner/README.md. CD бота сам обновляетtgit-runnerна хосте.Как проверено
hello— контейнер со всеми флагами песочницы (docker inspect); три деплоя подряд; сломанная версия →FAIL … previous version kept, старая работает.examples/escape_attempts: запись в код и/etc, интернет, DNS, docker.sock,setuid(0), raw-сокет, 200 процессов, запуск из/tmp— все 9 BLOCKED./srv/tgit/runner, ключ вauthorized_keysсcommand=…,restrict. Из сети ботаlist→OK list, аid→FAIL unknown command: forced command держит.RUNNER_*в/srv/tgit/dev/.envуже прописаны, заработает после деплоя этого PR.Ограничения
--network none); их health — это DockerHEALTHCHECKизнутри контейнера.🤖 Generated with Claude Code