Агент-инженер по репозиторию · Модуль 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 (см. вводный раздел курса).

checks.go: allowlist команд и инструмент run_check (новый файл)
+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 // бюджет контекста
+}
main.go: собираем реестр версии v1 и обновляем системный промпт
 	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, принимающий любую строку команды от модели.

Почему это опасно и какой дизайн правильный?

  • A Не опасно, если добавить подтверждение пользователя один раз
  • B Произвольная команда = риск деструктивных/утечных действий (как в кейсе Replit); правильно — allowlist ключей, маппящихся на фиксированные бинарь+аргументы
  • C Опасно только на Windows
  • D Достаточно фильтровать слово rm

run_check{check:"tests"} вернул вывод go test с ненулевым кодом выхода (тесты упали). Как это трактовать?

  • A Как сбой инструмента — вернуть is_error и прервать агента
  • B Как валидный результат: тесты прогнаны и упали; вывод отдаётся агенту, который дальше решает, что чинить
  • C Как нехватку прав
  • D Как таймаут