Shell с безопасностью
Сейчас описание bash, белый список и вызов
subprocess свалены в одну кучу. Для домашнего ноутбука ок.
Чуть позже команда поедет в песочницу (sandbox) — и переписывать tool
целиком будет обидно. Вынесем «что видит модель» и «где крутится команда»
в разные слои.
create_bash_tool(...): контракт для модели и проверка
безопасности внутри, а реальный запуск — через подставляемый объект
«операций». Сегодня подставим локальный subprocess; завтра — sandbox,
не трогая docstring.
Слова, которые встретятся
-
Фабрика (factory) — функция, которая собирает
инструмент и возвращает готовую функцию/объект. Вы не копируете
bashруками каждый раз — вызываете фабрику с нужными настройками. -
Шов / seam — место, где можно подменить реализацию,
не ломая остальное. Здесь шов — «кто выполняет
exec». -
Protocol (в typing) — «контракт класса» без наследования:
«у тебя должен быть метод
exec». Утка крякает — значит утка. - Sandbox — изолированная среда (позже). Пока достаточно знать: это «другой бэкенд для тех же команд».
Быстрый путь
- Создайте
tools_bash.py— Protocol и фабрикаcreate_bash_tool. - Создайте
local_bash.py— классLocalBashOpsнаsubprocess. - В
main.pyуберите старый inline-bashи подключите фабрику.
Какие файлы трогаем
| Файл | Что делать |
|---|---|
tools_bash.py |
Создать. Protocol BashOperations, тип результата, фабрика create_bash_tool (описание + белый список). |
local_bash.py |
Создать. Класс LocalBashOps — единственное место, где живёт subprocess. |
main.py |
Править. Удалить старую функцию bash / @agent.tool с subprocess внутри. Собрать tool через фабрику и зарегистрировать у агента. |
read / grep |
Не трогать в этом уроке. |
Если всё ещё лежит одним файлом — нормально: можете временно держать
классы в main.py, но имена ролей те же. Лучше сразу разнести:
потом модуль 4 подменит только local_bash.py.
Упражнение 2.2
- Проверка белого списка остаётся в фабрике (
tools_bash.py), не вLocalBashOps. LocalBashOps.execвсегда возвращает пару stdout + код выхода (и при ошибке тоже).readпока не трогаем — фабрика нужна там, где бэкенд реально разъедется.
Где режем
В старом main.py было примерно так (всё в одном месте):
if not is_safe(command):
return "Заблокировано..."
proc = subprocess.run(command, shell=True, cwd=ctx.deps, ...)
Когда появится песочница, вместо subprocess будет
sandbox.exec. Фабрика вводит шов — его объявляем в
tools_bash.py:
from typing import Protocol, TypedDict
class ExecResult(TypedDict):
stdout: str
exit_code: int
class BashOperations(Protocol):
async def exec(self, command: str) -> ExecResult: ...
Над швом — то, что видит модель (описание, аргументы, белый список). Под швом — железо или эмулятор.
1. Файл tools_bash.py (создать)
tools_bash.py
from collections.abc import Callable
from typing import Protocol, TypedDict
class ExecResult(TypedDict):
stdout: str
exit_code: int
class BashOperations(Protocol):
async def exec(self, command: str) -> ExecResult: ...
def create_bash_tool(
operations: BashOperations,
safe_prefixes: list[str],
) -> Callable[..., str]:
def is_safe(command: str) -> bool:
cmd = command.strip()
return any(cmd.startswith(p) for p in safe_prefixes)
async def bash(command: str) -> str:
"""Выполнить shell-команду в рабочей директории.
WHEN TO USE: сборка, тесты, git, список файлов.
WHEN NOT TO USE: чтение файла (read); поиск (grep).
DO NOT USE FOR: чтение файлов (read), поиск по коду (grep).
USAGE: одна строка shell; вне белого списка — понятная блокировка.
EXAMPLES:
- ls -la
- git status
"""
if not is_safe(command):
return f'Заблокировано: "{command}" не из белого списка.'
result = await operations.exec(command)
return result["stdout"] or "(нет вывода)"
return bash
Заметьте: внутри фабрики нет subprocess и нет знания про cwd.
Есть только «что-то с методом exec».
2. Файл local_bash.py (создать)
Здесь живёт только запуск на вашей машине. Белый список сюда не тащите — иначе шов размажется.
Важно: тип ExecResult объявлен в tools_bash.py.
В local_bash.py его нужно импортировать.
Если вы временно кладёте LocalBashOps прямо в
main.py — либо импортируйте
from tools_bash import ExecResult, либо сначала
объявите ExecResult выше класса. Иначе будет
NameError: name 'ExecResult' is not defined.
local_bash.py
import subprocess
from pathlib import Path
from tools_bash import ExecResult # без этой строки — NameError
class LocalBashOps:
def __init__(self, cwd: Path) -> None:
self.cwd = cwd
async def exec(self, command: str) -> ExecResult:
try:
proc = subprocess.run(
command,
shell=True,
cwd=self.cwd,
capture_output=True,
text=True,
timeout=30,
)
out = (proc.stdout or "") + (proc.stderr or "")
return {"stdout": out.strip(), "exit_code": proc.returncode}
except subprocess.TimeoutExpired:
return {"stdout": "Таймаут: больше 30 секунд.", "exit_code": 124}
3. Файл main.py (править)
Удалите целиком старый
@agent.tool / функцию с именем bash
(ту, где ещё был subprocess). Если оставить её
и добавить tool из фабрики — получите:
bash, а вы регистрируете
второй с тем же именем. Нужен один bash: только из фабрики.
Найдите в main.py старый
@agent.tool + def bash и сотрите блок.
Дальше соберите tool из фабрики и повесьте на агента один раз:
main.py — фрагмент сборки
import asyncio
import sys
from pathlib import Path
from pydantic_ai import Agent, RunContext, Tool
from local_bash import LocalBashOps
from tools_bash import create_bash_tool
MAX_LINES = 500
# Белый список: команда должна НАЧИНАТЬСЯ с одной из этих строк
SAFE_PREFIXES = [
"ls", "cat", "echo", "pwd", "which", "find",
"head", "tail", "wc",
"git log", "git status", "git diff",
]
cwd = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
prompt = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "Привет!"
local_ops = LocalBashOps(cwd)
bash_fn = create_bash_tool(local_ops, SAFE_PREFIXES)
agent = Agent(
"deepseek:deepseek-chat",
deps_type=Path,
tools=[Tool(bash_fn, takes_ctx=False)],
instructions=(
f"Ты coding-агент.\nРабочая директория: {cwd}\n"
"Используй инструмент read, чтобы открывать файлы. "
"Не выдумывай содержимое и не имитируй tool-calls текстом."
),
)
tools=[Tool(bash_fn, takes_ctx=False)] — этого достаточно.
Не добавляйте сверху ещё agent.tool_plain(bash_fn):
будет конфликт имени bash.
read / grep оставляйте на
@agent.tool. А subprocess — только в
local_bash.py.
create_bash_tool(sandbox_ops, SAFE_PREFIXES).
Описание и белый список никуда не переезжают — меняется только «двигатель».
read. Пока не надо.
Фабрика окупается, когда бэкенды реально разные. Для bash давление уже есть:
политика безопасности и место выполнения тянут в разные стороны.
Рефакторим под давлением, не «на всякий случай».
Проверьте
uv run python main.py . "Перечисли файлы в этой директории"
uv run python main.py . "Выполни: rm -rf .venv"
Поведение как в уроке 1.3: безопасное — работает, опасное — текст блокировки. Модель не обязана заметить рефакторинг. Это и есть комплимент архитектуре.
Готово, если:
- Есть файл
tools_bash.pyс Protocol иcreate_bash_tool - Есть файл
local_bash.pyсLocalBashOps(subprocess только там) - В
main.pyстарый inline-bashубран, tool собран через фабрику - Безопасные команды проходят, опасные по-прежнему блокируются текстом
- (бонус) Набросали
MockOps, который всегда отвечает «(pretend output)»
MockOps: на любую команду — фиктивный stdout.
Подставьте вместо local_ops. Агент будет уверенно «успешен»,
а диск молчит. Так выглядит шов, на котором позже поедет sandbox.