Files
beaver-agent/policy.py
T

216 lines
7.0 KiB
Python

"""§3.7: правила PreToolUse - граница без permission-промптов.
Модель везде в `bypassPermissions`; что ей можно, решают маунты,
`disallowed_tools` и правила отсюда. Правило получает `ToolCall` и
возвращает `Deny` с причиной, которую модель читает как результат тулзы,
или `None`. Маунт `/vault:ro` - первая линия, хук - вторая: он ловит то,
что маунт не различает (зоны внутри одного тома, порядок «скилл - тулза»).
"""
from __future__ import annotations
import fnmatch
import shlex
from dataclasses import dataclass
from typing import TYPE_CHECKING
from beaver_gateway.core.policy import Deny, PolicyRule, ToolCall
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
__all__ = [
"DEEP_DISALLOWED",
"DISPATCHER_DISALLOWED",
"DISTILLER_DISALLOWED",
"TRIAGE_DISALLOWED",
"Zones",
"bash_zones",
"requires_skill",
"skill_tracker",
"vault_zones",
]
# §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет;
# у глубоких вопросы текстом, сабагентов нет; дистиллятор и триаж - без веба.
DISPATCHER_DISALLOWED = ("ExitPlanMode", "EnterPlanMode", "NotebookEdit")
DEEP_DISALLOWED = (
"AskUserQuestion",
"ExitPlanMode",
"EnterPlanMode",
"NotebookEdit",
"Task",
)
DISTILLER_DISALLOWED = ("AskUserQuestion", "Task", "WebSearch", "WebFetch")
TRIAGE_DISALLOWED = DISTILLER_DISALLOWED
FILE_TOOLS = ("Write", "Edit", "MultiEdit", "NotebookEdit")
MUTATING = frozenset(
{
"rm",
"rmdir",
"unlink",
"mv",
"cp",
"tee",
"truncate",
"touch",
"mkdir",
"ln",
"install",
"rsync",
"dd",
"chmod",
"chown",
"shred",
}
)
COPYING = frozenset({"cp", "install", "rsync"})
INPLACE = frozenset({"sed", "perl"})
REDIRECTS = frozenset({">", ">>", ">|", "&>", "&>>", ">&"})
SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
@dataclass(frozen=True, slots=True)
class Zones:
"""Зоны vault: ``write`` - полная запись, ``create`` - только новые файлы."""
vault: Path
write: tuple[Path, ...]
create: tuple[Path, ...]
def verdict(self, path: Path, *, creating: bool) -> Deny | None:
if not _under(path, self.vault):
return None
if any(_under(path, zone) for zone in self.write):
return None
rel = path.relative_to(self.vault)
for zone in self.create:
if _under(path, zone):
if creating and not path.exists():
return None
return Deny(
reason=f"{rel}: в «{zone.relative_to(self.vault)}» можно только "
"создавать новые файлы, существующие не трогаем"
)
zones = ", ".join(f{z.relative_to(self.vault)}»" for z in self.write)
return Deny(reason=f"{rel}: vault только на чтение; писать можно в {zones}")
def _under(path: Path, root: Path) -> bool:
return path == root or root in path.parents
def vault_zones(zones: Zones) -> PolicyRule:
"""Write/Edit/NotebookEdit только в зонах; Write в create-зону - новый файл."""
def rule(call: ToolCall) -> Deny | None:
if call.tool not in FILE_TOOLS:
return None
path = call.path()
if path is None:
return None
return zones.verdict(path, creating=call.tool == "Write")
return rule
def bash_zones(zones: Zones) -> PolicyRule:
"""`rm`/`mv`/`cp`/`tee`/`sed -i`/редирект с путём в vault вне зон - отказ."""
def rule(call: ToolCall) -> Deny | None:
if call.tool != "Bash":
return None
command = call.input.get("command")
if not isinstance(command, str):
return None
for target, creating in _bash_targets(command):
deny = zones.verdict(call.resolve(target), creating=creating)
if deny is not None:
return Deny(reason=f"Bash: {deny.reason}")
return None
return rule
def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
for segment in _segments(command):
plain: list[str] = []
i = 0
while i < len(segment):
word = segment[i]
if word in REDIRECTS:
if i + 1 < len(segment) and _pathlike(segment[i + 1]):
yield segment[i + 1], True
i += 2
continue
plain.append(word)
i += 1
for j, word in enumerate(plain):
rest = plain[j + 1 :]
inplace = word in INPLACE and any(a.startswith("-i") for a in rest)
if word not in MUTATING and not inplace:
continue
args = [a for a in rest if _pathlike(a)]
if word in COPYING:
args = args[-1:]
for arg in args:
yield arg, False
break
def _segments(command: str) -> Iterator[list[str]]:
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
try:
tokens = list(lexer)
except ValueError:
tokens = command.split()
segment: list[str] = []
for token in tokens:
if token in SEPARATORS:
if segment:
yield segment
segment = []
else:
segment.append(token)
if segment:
yield segment
def _pathlike(word: str) -> bool:
return bool(word) and not (
word.startswith(("-", "&", "$", "/dev/"))
or word.isdigit()
or ("=" in word and "/" not in word.split("=", 1)[0])
)
def skill_tracker() -> PolicyRule:
"""Запоминает открытые скиллы в состоянии сессии (для `requires_skill`)."""
def rule(call: ToolCall) -> None:
if call.tool != "Skill":
return
name = str(call.input.get("skill") or call.input.get("name") or "")
opened: set[str] = call.state.setdefault("skills", set())
opened.add(name.rsplit(":", 1)[-1])
return rule
def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule:
"""§4.3: жёсткий протокол - тулзы из ``tools`` только после `Skill(skill)`."""
def rule(call: ToolCall) -> Deny | None:
if not any(fnmatch.fnmatchcase(call.tool, pattern) for pattern in tools):
return None
if skill in call.state.get("skills", ()):
return None
return Deny(
reason=f"сначала открой скилл «{skill}» (тулза Skill), потом {call.tool}"
)
return rule