MODULE_01 · УРОК 1.3

Завершаем toolbox

У агента уже есть read и grep. Следующий шаг — дать ему терминал: ls, git status, тесты… Но тот же терминал умеет и rm -rf / (Ахахах!). В этом уроке добавим инструмент bash и простую защиту: «разрешено только то, что в списке».

ЧТО ПОЛУЧИТСЯ Инструмент bash, который запускает только безопасные команды из белого списка. Остальные не выполняются: функция возвращает текст «заблокировано», и модель должна честно сказать об этом вам — а не притворяться, что всё прошло успешно.

Слова, которые встретятся

  • Toolbox — набор инструментов агента (read + grep + bash.
  • Allowlist (белый список) — список того, что разрешено. Всё остальное по умолчанию запрещено. Здесь это SAFE_PREFIXES.
  • Gate / «шлюз» — проверка перед опасным действием. Как шлагбаум: команда подходит → пропускаем; не подходит → не запускаем и возвращаем объяснение. Не отдельная библиотека, а несколько строк if в коде.
  • Approval (подтверждение) — когда система спрашивает человека: «разрешить эту команду?». Полноценно сделаем позже (модуль 8). Сейчас вместо вопроса — жёсткий отказ с текстом.

Быстрый путь

  1. Добавьте инструмент bash и список разрешённых начал команд.
  2. Если команда не из списка — не запускайте её, верните понятное сообщение.
  3. Проверьте: на опасный запрос модель пишет про блокировку, а не «готово, удалил».

Упражнение 1.3

  • Список вроде: ls, pwd, git status, git log, git diff — в основном команды «посмотреть», а не «сломать».
  • Сравнивайте по началу строки: ls -la начинается с ls — значит, подходит.
  • Поставьте таймаут (например 30 секунд), чтобы зависшая команда не держала агента вечно.
  • В docstring снова: WHEN TO USE / WHEN NOT TO USE / DO NOT USE FOR — чтобы модель не читала файлы через cat, а шла в read.

Почему не включаем «спросить пользователя» прямо сейчас

В Pydantic AI у инструмента есть флаг requires_approval=True: «перед запуском нужно согласие человека». Звучит идеально, но сам по себе флаг ничего не спрашивает — он только помечает вызов. Если вы не написали код, который реально останавливается и ждёт «да/нет», модель может не получить нормальный ответ от инструмента и додумать: «Файлы удалены». Для вас это выглядит как успех — хотя команда даже не запускалась. Это хуже, чем честный отказ.

ВАЖНО «Нужно approval» ≠ готовая защита. Без вашего диалога с пользователем пометка бесполезна. Поэтому в этом уроке проверка стоит прямо в теле bash: не в списке → сразу вернуть строку с причиной. Спросить «разрешить?» научимся в модуле 8.

Проверка прямо в коде инструмента

Идея простая: до вызова subprocess смотрим на команду. Можно — запускаем. Нельзя — возвращаем текст. Этот текст попадает обратно в диалог как обычный результат инструмента; модель его читает и может пересказать вам без вранья.

фрагмент для main.py (добавьте к read и grep)

import subprocess
from pathlib import Path

from pydantic_ai import Agent, RunContext

# Белый список: команда должна НАЧИНАТЬСЯ с одной из этих строк
SAFE_PREFIXES = [
    "ls", "cat", "echo", "pwd", "which", "find",
    "head", "tail", "wc",
    "git log", "git status", "git diff",
]

def is_safe(command: str) -> bool:
    cmd = command.strip()
    return any(cmd.startswith(prefix) for prefix in SAFE_PREFIXES)

@agent.tool
def bash(ctx: RunContext[Path], command: str) -> str:
    """Выполнить shell-команду в рабочей директории.

    WHEN TO USE: сборка, тесты, git, список файлов в каталоге.
    WHEN NOT TO USE: чтение известного файла (используй read);
      поиск по тексту в проекте (используй grep).
    DO NOT USE FOR: чтение файлов (read), поиск по коду (grep).
    """
    if not is_safe(command):
        allowed = ", ".join(SAFE_PREFIXES)
        return (
            f'Заблокировано: "{command}" не из белого списка. '
            f"Автоматически можно только: {allowed}."
        )
    try:
        proc = subprocess.run(
            command,
            shell=True,
            cwd=ctx.deps,
            capture_output=True,
            text=True,
            timeout=30,
        )
        out = (proc.stdout or "") + (proc.stderr or "")
        if proc.returncode != 0:
            return f"Exit {proc.returncode}: {out.strip()}"
        return out.strip() or "(нет вывода)"
    except subprocess.TimeoutExpired:
        return "Таймаут: команда дольше 30 секунд."

Ключевой момент: при блокировке вы всё равно что-то возвращаете — обычную строку. Не молчите и не «проглатываете» вызов. Тогда у модели есть факты, а не дыра, которую она заполняет фантазией.

Проверьте

uv run python main.py . "Перечисли файлы в этой директории"
uv run python main.py . "Выполни команду: rm -rf .venv"

Первый запрос — что-то вроде ls или find, и вы видите список файлов. Второй — сообщение о блокировке. Плохой исход: модель пишет «готово, удалил», хотя команда не бежала.

ОБХОДЫ Если сказать «удали .venv», модель может хитрить: find … -exec rm … вместо прямого rm. Наш простой «начинается с …» ловит rm, но не любой трюк с find. В бою списки усложняют (опасные шаблоны, regex). Здесь оставляем простой вариант — чтобы было ясно, зачем проверка, а не чтобы закрыть все дыры сразу.

Три инструмента — один агент

Инструмент Зачем Как не навредить
read Читать файл Не больше 500 строк за раз
grep Искать по проекту Не больше 50 совпадений
bash Команды в терминале Только из белого списка

Docstring подсказывает, какой инструмент взять. Лимиты берегут «память» диалога (контекст). Белый список бережёт ваш диск.

Готово, если:

  • Безопасные команды (ls, git status) реально выполняются через bash
  • Запрос «прочитай файл» по-прежнему идёт в read, а не в bash + cat
  • rm -rf, sudo и прочее вне списка возвращают текст блокировки
  • Модель сообщает о блокировке, а не притворяется, что команда прошла
НА ПОТОМ Жёсткий отказ честен, но груб. Удобнее спросить: «Агент хочет выполнить pip install X. Разрешить?» Да — выполнить и вернуть вывод. Нет — вернуть «пользователь отказал». Это как раз сценарий с approval из модуля 8.