Шлюз подтверждения
Сейчас в 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 / для субагента.
Быстрый путь
- Создать
approval.py. - В
tools_bash.pyзаменить аргументsafe_prefixesнаneeds_approval. - В
main.pyсобратьcreate_approval(...)→create_bash_tool(...). - Прогнать две команды в разных режимах.
Какие файлы трогаем
| Файл | Что делать |
|---|---|
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(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.pybash собран черезcreate_approval+create_bash_tool - В
interactive:git statusпроходит,pip install— блок - Переключение на
background/delegatedменяет поведение без правки фабрики
needs_approval == True → не молчим, что-то возвращаем.