MODULE_02 · УРОК 2.3

Шлюз подтверждения

Сейчас в create_bash_tool зашит один белый список: «не в списке — стоп». Дома ок, в CI — нет (некому жать OK), субагенту тоже не нужна вся ваша власть. В этом уроке вынесем правило в отдельный файл и научимся переключать три режима одной строкой в main.py.

ЧТО ПОЛУЧИТСЯ Файл approval.py + правка tools_bash.py + пара строк в main.py. Меняете {"mode": "interactive"} на "background" или "delegated" — и та же команда то проходит, то блокируется.

Слова по делу

  • needs_approval(command) — функция: True = «не запускай, верни текст блокировки», False = «можно в subprocess».
  • Режим (mode) — как эта функция решает: дома / в CI / для субагента.

Быстрый путь

  1. Создать approval.py.
  2. В tools_bash.py заменить аргумент safe_prefixes на needs_approval.
  3. В main.py собрать create_approval(...)create_bash_tool(...).
  4. Прогнать две команды в разных режимах.

Какие файлы трогаем

Файл Что делать
approval.py Создать. Типы конфига + create_approval + список SAFE_PREFIXES.
tools_bash.py Править. Сигнатура фабрики: вместо списка префиксов — функция needs_approval.
main.py Править. Импорты, вызов create_approval, передать результат в create_bash_tool.
local_bash.py Не трогать. Он по-прежнему только крутит subprocess.

Шаг 1. Создайте approval.py

Новый файл рядом с main.py. Здесь живут белый список и логика «блокировать или нет» — без subprocess и без агента.

approval.py (целиком)

from collections.abc import Callable
from typing import Literal, TypedDict

class InteractiveConfig(TypedDict):
    mode: Literal["interactive"]

class BackgroundConfig(TypedDict):
    mode: Literal["background"]

class DelegatedConfig(TypedDict):
    mode: Literal["delegated"]
    trust: list[str]

ApprovalConfig = InteractiveConfig | BackgroundConfig | DelegatedConfig

# Общий белый список для режима interactive (дома за клавиатурой)
SAFE_PREFIXES = [
    "ls", "cat", "echo", "pwd", "which", "find",
    "head", "tail", "wc",
    "git log", "git status", "git diff",
]


def create_approval(config: ApprovalConfig) -> Callable[[str], bool]:
    """Вернуть функцию needs_approval(command) -> bool."""

    def needs_approval(command: str) -> bool:
        cmd = command.strip()

        # CI: человека нет — пускаем всё (осознанный риск)
        if config["mode"] == "background":
            return False

        # Субагент: только то, что дали в trust
        if config["mode"] == "delegated":
            allowed = config["trust"]
            return not any(cmd.startswith(p) for p in allowed)

        # interactive: как старый белый список
        return not any(cmd.startswith(p) for p in SAFE_PREFIXES)

    return needs_approval

Три режима простыми словами:

  • interactive — можно то, что в SAFE_PREFIXES; остальное блок.
  • background — можно всё (для CI).
  • delegated — можно только строки из trust.

Шаг 2. Поправьте tools_bash.py

Сейчас у вас примерно так (урок 2.2):

def create_bash_tool(
    operations: BashOperations,
    safe_prefixes: list[str],
) -> Callable[..., str]:
    def is_safe(command: str) -> bool:
        ...
    async def bash(command: str) -> str:
        if not is_safe(command):
            return "Заблокировано..."
        ...

Замените второй аргумент на готовую функцию из approval.py. Внутри больше не считайте белый список сами:

tools_bash.py — новая сигнатура фабрики

from collections.abc import Callable

# ExecResult, BashOperations — как были в уроке 2.2

def create_bash_tool(
    operations: BashOperations,
    needs_approval: Callable[[str], bool],  # было: safe_prefixes: list[str]
) -> Callable[..., str]:

    async def bash(command: str) -> str:
        """Выполнить shell-команду в рабочей директории.
        ... ваш полный docstring из 2.1 ...
        """
        if needs_approval(command):
            return (
                f'Заблокировано: "{command}" требует подтверждения '
                f"(режим политики в main.py)."
            )
        result = await operations.exec(command)
        return result["stdout"] or "(нет вывода)"

    return bash
СЛОМАЕТСЯ СТАРЫЙ ВЫЗОВ В main.py больше нельзя писать create_bash_tool(local_ops, SAFE_PREFIXES) — второй аргумент теперь функция, не список. Иначе TypeError / странное поведение. Сразу переходите к шагу 3.

Шаг 3. Соберите всё в main.py

Найдите место, где вы делали create_bash_tool(local_ops, SAFE_PREFIXES), и замените на:

main.py — фрагмент

from local_bash import LocalBashOps
from tools_bash import create_bash_tool
from approval import create_approval
from pydantic_ai import Agent, Tool

# ... cwd как раньше ...

local_ops = LocalBashOps(cwd)

# Переключайте ТОЛЬКО эту строку, чтобы менять поведение:
needs_approval = create_approval({"mode": "interactive"})
# needs_approval = create_approval({"mode": "background"})
# needs_approval = create_approval({
#     "mode": "delegated",
#     "trust": ["pwd", "find", "git status"],
# })

bash_fn = create_bash_tool(local_ops, needs_approval)

agent = Agent(
    "deepseek:deepseek-chat",
    deps_type=Path,
    # Один способ зарегистрировать bash — через tools=[...]
    tools=[Tool(bash_fn, takes_ctx=False)],
    instructions=...,
)

# read / grep — как раньше, через @agent.tool ниже
# НЕ вызывайте ещё и agent.tool_plain(bash_fn): будет конфликт имени 'bash'
tools=[...] ИЛИ tool_plain — НЕ ОБА Зарегистрировать функцию из фабрики можно так: tools=[Tool(bash_fn, takes_ctx=False)] при создании Agent, или позже agent.tool_plain(bash_fn). Достаточно одного варианта. Если уже есть tools=[Tool(bash_fn, ...)], строка agent.tool_plain(bash_fn) не нужна — иначе снова Tool name conflicts: 'bash'.

Список SAFE_PREFIXES и старую функцию is_safe из main.py удалите: они переехали в approval.py / больше не используются фабрикой.

ЗАЧЕМ ВООБЩЕ ОТДЕЛЬНЫЙ ФАЙЛ Чтобы в main.py менять поведение одной строкой-словарём, а не копировать три разные функции. Потом тот же словарь можно прочитать из конфига или отдать субагенту — код политики уже готов.

Проверьте

Оставьте mode: "interactive" и выполните:

uv run python main.py . "Выполни: git status"
uv run python main.py . "Выполни: pip install requests"

Первое — должно пройти (префикс в SAFE_PREFIXES). Второе — текст блокировки, не установка пакета.

Потом в main.py раскомментируйте background, закомментируйте interactive, снова pip install requests: шлюз пропустит (команда может сама упасть — это уже не политика).

Если раскомментируете delegated с trust: ["git status"], то будет такой результат: git status — ок, pip install requests — блок.

ОДОБРИЛИ ≠ УСПЕХ КОМАНДЫ Шлюз сказал «можно», а pytest всё равно красный — это провал тестов, не политики. Смотрите exit_code отдельно.

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

  • Есть файл approval.py с create_approval
  • В tools_bash.py фабрика принимает needs_approval, а не список префиксов
  • В main.py bash собран через create_approval + create_bash_tool
  • В interactive: git status проходит, pip install — блок
  • Переключение на background / delegated меняет поведение без правки фабрики
НА ПОТОМ Сейчас «блок» — просто строка, без вопроса человеку. Настоящий диалог «разрешить?» — в модуле 8. Идея та же: needs_approval == True → не молчим, что-то возвращаем.