Агент-инженер по репозиторию · Модуль 2 · Урок 2.4
Чекпойнт v1: запуск тестов и линта по allowlist (без произвольного shell)
Зачем (какую проблему чиним)
Главная способность инженера — проверять свою работу. Агенту нужно уметь запускать тесты и линтер. Но «дай агенту запускать команды» — самый прямой путь к катастрофе (вспомните Replit: агент выполнил деструктивные команды). Поэтому вводим allowlist команд как фундаментальное правило версии v1 и всего курса: агент запускает только заранее разрешённые команды, никакого произвольного shell.
Решение и альтернативы
Решение: инструмент run_check принимает не строку команды, а ключ из allowlist (tests, lint, build). Каждый ключ маппится на фиксированный бинарь и аргументы (go test ./..., golangci-lint run, go build ./...), запускается через exec.CommandContext с таймаутом, в корне репозитория, с обрезкой вывода до бюджета. Модель не может передать произвольную команду — только выбрать из списка.
Альтернативы: run_shell(cmd string) — удобно и смертельно опасно; единственная инъекция или галлюцинация = rm -rf. Контейнер/песочница на каждую команду — правильно для прода (и мы укажем на это в версии v5), но на версии v1 достаточно строгого allowlist + таймаутов. Осознанный компромисс: безопасность через сужение возможностей, а не через попытку отфильтровать опасные строки.
DIFF
Добавляем allowlist и инструмент run_check; собираем рабочий агент версии v1 и тегируем v1.
⚠ Безопасность
Это ключевой инвариант исполнения команд всего курса: агент не исполняет произвольный shell — только команды из allowlist. Allowlist — это список *ключ → (бинарь, аргументы)*, заданный человеком в коде/конфиге, а не моделью. Плюс: таймаут (зависание), обрезка вывода (взрыв контекста), запуск в корне репозитория (а с версии v3 — в изолированном worktree). Принцип наименьших привилегий в чистом виде.
Проверка
«Прогони тесты» → агент вызывает run_check{check: "tests"} → видит вывод go test. Попытка run_check{check: "rm -rf /"} или run_check{check: "curl ..."} отклоняется: ключа нет в allowlist. Затем git tag v1.
Глубже
Безопасность агентов, least privilege, OWASP LLM Top 10 — курс «Продакшн-разработка», Модуль 5 (гл. 11); песочницы исполнения — там же (гл. 8). Реальные последствия произвольного исполнения — кейс Replit (см. вводный раздел курса).
+package main
+
+import (
+ "context"
+ "os/exec"
+)
+
+// command — фиксированная команда: бинарь + аргументы. Никаких строк shell.
+type command struct {
+ bin string
+ args []string
+}
+
+// allowlist — ЕДИНСТВЕННЫЙ источник исполняемых команд. Задаётся человеком,
+// не моделью. Модель может лишь выбрать ключ из этого списка.
+var allowlist = map[string]command{
+ "tests": {bin: "go", args: []string{"test", "./..."}},
+ "lint": {bin: "golangci-lint", args: []string{"run"}},
+ "build": {bin: "go", args: []string{"build", "./..."}},
+}
+
+// runCheck исполняет команду по ключу из allowlist (с таймаутом и обрезкой).
+func runCheck(ctx context.Context, root, key string) (string, error) {
+ c, ok := allowlist[key]
+ if !ok {
+ return "", fmt.Errorf("команда не разрешена: %q (доступно: tests, lint, build)", key)
+ }
+ cmd := exec.CommandContext(ctx, c.bin, c.args...)
+ cmd.Dir = root
+ out, _ := cmd.CombinedOutput() // ненулевой код выхода — это валидный результат (тесты упали)
+ return truncate(string(out), 8000), nil // бюджет контекста
+} cfg := loadConfig()
cfg.Root, _ = os.Getwd()
- agent := newAgent(cfg)
+ reg := NewRegistry(
+ readFileToolT{root: cfg.Root},
+ listFilesToolT{root: cfg.Root},
+ grepTool{root: cfg.Root},
+ gitDiffToolT{root: cfg.Root},
+ runCheckToolT{root: cfg.Root}, // только allowlist: tests/lint/build
+ )
+ agent := newAgent(cfg, reg)Anti-patterns
| Грабля | Почему плохо | Как правильно |
|---|---|---|
Инструмент run_shell(cmd string) с произвольной командой от модели | Одна инъекция/галлюцинация — и rm -rf, curl | sh, утечка секретов; ровно так теряют данные | run_check(key) по allowlist *ключ → (бинарь, аргументы)*, заданному человеком |
| Пытаться «отфильтровать опасные строки» в произвольной команде | Чёрные списки обходятся бесконечно; безопасность через запреты не работает | Безопасность через сужение: разрешено только перечисленное, остальное запрещено |
| Команда без таймаута и без обрезки вывода | Зависание блокирует агента; гигантский вывод взрывает контекст и стоимость | CommandContext с дедлайном + truncate вывода до бюджета |
| Считать ненулевой код выхода тестов ошибкой инструмента | «Тесты упали» — это валидный полезный результат, а не сбой инструмента | Возвращать вывод как результат; падение тестов агент обрабатывает логикой (версия v3) |
Практическое задание (RA-v1)
- Ввести
allowlist(tests/lint/build) и инструментrun_check(key); запретить любую произвольную команду. - Добавить таймаут по контексту и обрезку вывода; запуск — в корне репозитория.
- Прогнать «прогони тесты» и убедиться, что несписочные команды отклоняются. Зафиксировать:
git commit -m "v1: run_check via allowlist"иgit tag v1.
Проверка знаний
Чтобы агент мог запускать тесты, инженер добавил инструмент run_shell, принимающий любую строку команды от модели.
Почему это опасно и какой дизайн правильный?
Верный ответ: B
B. Произвольное исполнение — самый прямой путь к необратимому ущербу и утечкам; именно так автономные агенты теряли данные. Безопасность достигается сужением (allowlist *ключ → команда*), а не фильтрацией опасных строк (D — обходится тривиально). Разовое подтверждение (A) не спасает от произвольной команды.
run_check{check:"tests"} вернул вывод go test с ненулевым кодом выхода (тесты упали). Как это трактовать?
Верный ответ: B
B. Падение тестов — это полезная информация, а не сбой инструмента. Инструмент успешно сделал свою работу (прогнал тесты); ненулевой код — часть результата. На версии v3 агент использует этот вывод в цикле self-correction.