feat(policy,komodo,config): vault zone and firefly skill rules, komodo python_tool with exec, tests

This commit is contained in:
hh
2026-08-29 15:27:32 +02:00
parent 9dfb8ba201
commit 40231049eb
12 changed files with 810 additions and 39 deletions
+1
View File
@@ -7,6 +7,7 @@ check:
uv run ruff format --check
uv run ruff check
uv run ty check
uv run pytest -q
fix:
uv run ruff format
+19
View File
@@ -129,6 +129,25 @@ Needs `KOMODO_URL` / `KOMODO_KEY` / `KOMODO_SECRET` in `.env` (and
`POST /hooks/komodo` the same way - they land in the master as an urgent
inject. Jobs, the queue and the subscription window are on the admin **Jobs** page.
The same key backs the `komodo` tool the dispatcher and deep chats get
(`mcps/komodo.py`, архитектура §4.4): one MCP tool with an enumerated
`action` - `status`, `stacks`, `containers`, `logs`, `search_logs`, `updates`,
`update`, `deploy`, `restart`, `exec`. `exec` is `docker exec` into any
container of the fleet except infrastructure ones (`exec_deny`, periphery by
default); prune, destroy and a host terminal do not exist in the enumeration,
so they cannot be asked for. The key never reaches the model process.
## Policy (`policy.py`)
`bypassPermissions` everywhere; the boundary is the read-only vault mount
plus `PreToolUse` rules declared per agent (`ClaudeAgent.policy`, архитектура
§3.7): writes only under `мета/бобер/`, new files only under `💬 чаты/`,
`rm`/`mv`/`cp`/`tee`/`sed -i`/redirects into the vault outside those zones
are refused with a reason, and `mcp__firefly__store_*`/`update_*` need the
`firefly` skill opened first in the same session. Every tool call lands in
the admin **Audit** page as `tool_call`. Run `make check` - it includes the
policy and komodo tests.
## Editing config.py on the mac
`make sync` installs `../beaver-gateway` editable (extra `local`), so
+53 -38
View File
@@ -4,7 +4,6 @@ from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import aiohttp
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
@@ -25,6 +24,19 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
from beaver_gateway.frontends.telegram import TelegramFrontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
from mcps.komodo import Komodo
from policy import (
DEEP_DISALLOWED,
DISPATCHER_DISALLOWED,
DISTILLER_DISALLOWED,
TRIAGE_DISALLOWED,
Zones,
bash_zones,
requires_skill,
skill_tracker,
vault_zones,
)
TZ = "Europe/Warsaw"
VAULT = Path("/vault")
CHATS_DIR = VAULT / "💬 чаты"
@@ -99,6 +111,18 @@ DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
DISPATCHER_SKILLS = (SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault")
# §3.7: зоны vault - запись только в мета/бобер, в 💬 чаты только новые файлы;
# firefly пишет только после открытого скилла. Маунт ro - первая линия.
ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS_DIR,))
FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*")
VAULT_POLICY = (
skill_tracker(),
vault_zones(ZONES),
bash_zones(ZONES),
requires_skill("firefly", FIREFLY_WRITES),
)
def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001
"""Новый файл чата в `💬 чаты/`: помесячная папка, дата и тема."""
today = date.today()
@@ -125,6 +149,21 @@ def _calendar_mcps() -> list[HttpMcp]:
calendar_mcps = _calendar_mcps()
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_mcps)
# §4.4: komodo - python_tool с перечислимыми действиями, ключ остаётся в gateway.
KOMODO = (
Komodo(
url=os.environ["KOMODO_URL"],
key=os.environ["KOMODO_KEY"],
secret=os.environ["KOMODO_SECRET"],
)
if os.environ.get("KOMODO_URL")
else None
)
komodo_mcps = (
[McpServer.python_tool(name="komodo", tools=[KOMODO.komodo])] if KOMODO else []
)
komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else ()
# Секреты MCP - только через env подпроцесса (mcp stdio даёт ему белый список
# + это), никогда argv: процесс модели видит `ps` всего контейнера.
mcps: list[McpServerT] = [
@@ -144,6 +183,7 @@ mcps: list[McpServerT] = [
),
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
*calendar_mcps,
*komodo_mcps,
]
# Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои
@@ -152,6 +192,7 @@ CLAUDE_MCPS = (
ExposedMcp(name="firefly", deny=("delete_*",)),
ExposedMcp(name="telegram"),
*calendar_exposed,
*komodo_exposed,
)
@@ -168,11 +209,8 @@ def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
prompts=DISPATCHER_PROMPTS,
skill_sets=DISPATCHER_SKILLS,
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
options=ClaudeOptions(
effort=effort,
# §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет.
disallowed_tools=("ExitPlanMode", "EnterPlanMode", "NotebookEdit"),
),
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
policy=VAULT_POLICY,
expose_mcps=CLAUDE_MCPS,
)
@@ -186,17 +224,8 @@ def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
skill_sets=DEEP_SKILLS,
# §8.4: «ок, обсудили» - тулза, закрытие после ответа.
gateway_tools=("close_chat",),
options=ClaudeOptions(
effort=effort,
# §3.7: у глубоких вопросы текстом, планов и сабагентов нет.
disallowed_tools=(
"AskUserQuestion",
"ExitPlanMode",
"EnterPlanMode",
"NotebookEdit",
"Task",
),
),
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
policy=VAULT_POLICY,
expose_mcps=CLAUDE_MCPS,
)
@@ -211,8 +240,9 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
options=ClaudeOptions(
effort=effort,
tools=("Read", "Write"),
disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"),
disallowed_tools=DISTILLER_DISALLOWED,
),
policy=(vault_zones(ZONES),),
)
@@ -225,9 +255,7 @@ def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
prompts=TRIAGE_PROMPTS,
gateway_tools=("say", "inject"),
options=ClaudeOptions(
effort=effort,
tools=(),
disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"),
effort=effort, tools=(), disallowed_tools=TRIAGE_DISALLOWED
),
)
@@ -451,26 +479,13 @@ KOMODO_STACK = os.environ.get("KOMODO_STACK", "beaver-agent")
async def komodo_deploy() -> None:
url, key, secret = (
os.environ.get(k, "") for k in ("KOMODO_URL", "KOMODO_KEY", "KOMODO_SECRET")
)
if not (url and key and secret):
if KOMODO is None:
_log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed")
return
body = {"type": "DeployStack", "params": {"stack": KOMODO_STACK, "services": []}}
headers = {"X-Api-Key": key, "X-Api-Secret": secret}
async with (
aiohttp.ClientSession() as http,
http.post(
f"{url.rstrip('/')}/execute",
json=body,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30),
) as response,
):
_log.info(
"deploy hook: komodo %s %s", response.status, (await response.text())[:200]
receipt = await KOMODO.execute(
"DeployStack", {"stack": KOMODO_STACK, "services": []}
)
_log.info("deploy hook: komodo accepted %s", receipt.get("id"))
async def deploy(run: JobRun) -> None:
+2
View File
@@ -52,6 +52,8 @@ services:
- "${PORT_GATEWAY:-62990}:62990"
volumes:
- ./config.py:/config/config.py:ro
- ./policy.py:/config/policy.py:ro
- ./mcps:/config/mcps:ro
- ./config.json:/config/config.json:ro
# §3.7: vault только на чтение, rw - подмонтирования зон агента.
- vault:/vault:ro
View File
+292
View File
@@ -0,0 +1,292 @@
"""§3.7, §4.4: Komodo как python_tool с перечислимыми действиями.
Ключ живёт в gateway и в контекст модели не попадает. В перечислении нет
`Prune*`, `Destroy*`, `DeleteServer` и терминала хоста; `exec` - это
`docker exec` в контейнер, не шелл на сервере, и контейнеры из
``exec_deny`` (periphery с docker.sock - это и есть хост) ему не даются.
"""
from __future__ import annotations
import asyncio
import fnmatch
import json
import re
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Literal
import aiohttp
__all__ = ["Action", "Komodo"]
Action = Literal[
"status",
"stacks",
"containers",
"logs",
"search_logs",
"updates",
"update",
"deploy",
"restart",
"exec",
]
ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
EXIT_MARK = "__KOMODO_EXIT_CODE:"
NOT_RUNNING_LIMIT = 15
@dataclass(frozen=True, slots=True)
class Komodo:
url: str
key: str
secret: str
exec_deny: tuple[str, ...] = ("*periphery*", "*komodo*")
terminal: str = "beaver"
shell: str = "sh"
timeout: float = 120.0
wait: float = 180.0
max_chars: int = 12_000
async def komodo(
self,
action: Action,
*,
stack: str | None = None,
service: str | None = None,
container: str | None = None,
command: str | None = None,
terms: str | None = None,
tail: int = 100,
update: str | None = None,
server: str | None = None,
) -> str:
"""Прод через Komodo: посмотреть, а по просьбе Бобра - подействовать.
action:
- status: серверы и стеки не в running.
- stacks: все стеки - сервер, состояние, есть ли незадеплоенные коммиты.
- containers: контейнеры сервера (server; без него - все серверы).
- logs: логи стека (stack, service опционально, tail строк).
- search_logs: поиск по логам стека (stack, terms - слова через пробел).
- updates: последние операции - кто, что, статус.
- update: подробности одной операции по id (update).
- deploy / restart: стек целиком или один service; ждёт исхода до 3 мин.
- exec: команда внутри контейнера (container, command; server, если имя
контейнера есть на нескольких серверах). Это `docker exec` от
пользователя контейнера. Долгое - запускай в фон с выводом в файл.
Деплой и рестарт - только когда об этом попросили, не «заодно».
"""
match action:
case "status":
return await self._status()
case "stacks":
return await self._stacks()
case "containers":
return await self._containers(server)
case "logs":
return await self._logs(_need(stack, "stack"), service, tail)
case "search_logs":
return await self._search(
_need(stack, "stack"), _need(terms, "terms"), service
)
case "updates":
return await self._updates()
case "update":
return await self._update(_need(update, "update"))
case "deploy":
return await self._run("DeployStack", _need(stack, "stack"), service)
case "restart":
return await self._run("RestartStack", _need(stack, "stack"), service)
case "exec":
return await self._exec(
_need(container, "container"), _need(command, "command"), server
)
async def execute(self, operation: str, params: dict[str, Any]) -> dict[str, Any]:
return await self._json("execute", {"type": operation, "params": params})
async def read(self, request: str, params: dict[str, Any]) -> Any:
return await self._json("read", {"type": request, "params": params})
async def _status(self) -> str:
servers, stacks = await asyncio.gather(
self.read("ListServers", {}), self.read("ListStacks", {})
)
lines = [f"{s['name']:24} {s['info']['state']}" for s in servers]
bad = [s for s in stacks if s["info"]["state"] != "running"]
lines.append(f"стеков: {len(stacks)}, не running: {len(bad)}")
lines.extend(
f" {s['info']['state']:12} {s['name']} @{s['info'].get('server_name')}"
for s in bad[:NOT_RUNNING_LIMIT]
)
return "\n".join(lines)
async def _stacks(self) -> str:
stacks = await self.read("ListStacks", {})
lines = []
for s in sorted(stacks, key=lambda s: s["name"]):
info = s["info"]
behind = info.get("latest_hash") and info.get("deployed_hash") != info.get(
"latest_hash"
)
lines.append(
f"{info['state']:12} {s['name']:28} @{info.get('server_name')}"
+ (" (есть незадеплоенные коммиты)" if behind else "")
)
return "\n".join(lines)
async def _containers(self, server: str | None) -> str:
names = (
[server]
if server
else [s["name"] for s in await self.read("ListServers", {})]
)
lines = []
for name in names:
containers = await self.read("ListDockerContainers", {"server": name})
lines.append(f"{name}:")
lines.extend(
f" {c['state']:10} {c['name']:36} {c.get('status', '')}"
for c in sorted(containers, key=lambda c: c["name"])
)
return "\n".join(lines)
async def _logs(self, stack: str, service: str | None, tail: int) -> str:
log = await self.read(
"GetStackLog",
{"stack": stack, "services": [service] if service else [], "tail": tail},
)
return self._clip(_log_text(log))
async def _search(self, stack: str, terms: str, service: str | None) -> str:
log = await self.read(
"SearchStackLog",
{
"stack": stack,
"services": [service] if service else [],
"terms": terms.split(),
},
)
return self._clip(_log_text(log))
async def _updates(self) -> str:
updates = (await self.read("ListUpdates", {}))["updates"][:15]
return "\n".join(_update_line(u) for u in updates) or "операций нет"
async def _update(self, update_id: str) -> str:
return self._clip(
_update_detail(await self.read("GetUpdate", {"id": update_id}))
)
async def _run(self, operation: str, stack: str, service: str | None) -> str:
receipt = await self.execute(
operation, {"stack": stack, "services": [service] if service else []}
)
update_id = receipt.get("id") or receipt.get("_id", {}).get("$oid")
if not update_id:
return f"{operation} {stack}: Komodo вернул {receipt!r}"
deadline = time.monotonic() + self.wait
while time.monotonic() < deadline:
await asyncio.sleep(5)
current = await self.read("GetUpdate", {"id": update_id})
if current.get("status") == "Complete":
return self._clip(_update_detail(current))
return f"{operation} {stack}: ещё идёт, update={update_id} - проверь позже"
async def _exec(self, container: str, command: str, server: str | None) -> str:
if any(fnmatch.fnmatchcase(container, p) for p in self.exec_deny):
return f"exec в {container} запрещён: это контейнер инфраструктуры"
server = server or await self._server_of(container)
if server is None:
return f"контейнер {container} не найден ни на одном сервере"
body = {
"target": {
"type": "Container",
"params": {"server": server, "container": container},
},
"terminal": self.terminal,
"command": command,
"init": {"command": self.shell, "recreate": "Never"},
}
status, text = await self._post("terminal/execute", body)
if status != 200:
body["init"]["recreate"] = "Always"
status, text = await self._post("terminal/execute", body)
if status != 200:
return f"exec {container}: Komodo {status}: {text[:500]}"
output, _, code = ANSI.sub("", text).rpartition(EXIT_MARK)
header = f"[{container}@{server}] exit {code.strip() or '?'}"
return f"{header}\n{self._clip(output.strip())}"
async def _server_of(self, container: str) -> str | None:
for s in await self.read("ListServers", {}):
containers = await self.read("ListDockerContainers", {"server": s["name"]})
if any(c["name"] == container for c in containers):
return s["name"]
return None
async def _json(self, route: str, body: dict[str, Any]) -> Any:
status, text = await self._post(route, body)
if status != 200:
msg = f"Komodo {route} {body.get('type')}: {status} {text[:500]}"
raise RuntimeError(msg)
return json.loads(text)
async def _post(self, route: str, body: dict[str, Any]) -> tuple[int, str]:
headers = {"X-Api-Key": self.key, "X-Api-Secret": self.secret}
async with (
aiohttp.ClientSession() as http,
http.post(
f"{self.url.rstrip('/')}/{route}",
json=body,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response,
):
return response.status, await response.text()
def _clip(self, text: str) -> str:
if len(text) <= self.max_chars:
return text
head, tail = self.max_chars * 2 // 3, self.max_chars // 3
cut = len(text) - head - tail
return f"{text[:head]}\n…[обрезано {cut} символов]…\n{text[-tail:]}"
def _need(value: str | None, name: str) -> str:
if not value:
msg = f"для этого действия нужен параметр {name}"
raise ValueError(msg)
return value
def _log_text(log: dict[str, Any]) -> str:
if "error" in log:
return f"Komodo: {log['error']}"
return ANSI.sub("", (log.get("stdout") or "") + (log.get("stderr") or "")).strip()
def _ts(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%m-%d %H:%M")
def _update_line(u: dict[str, Any]) -> str:
ok = "ok" if u.get("success") else "FAIL"
head = f"{_ts(u['start_ts'])} {u['operation']:20} {u['status']:10} {ok}"
return f"{head} user={u.get('username')} id={u.get('id')}"
def _update_detail(u: dict[str, Any]) -> str:
lines = [f"{u['operation']} {u['status']} {'ok' if u.get('success') else 'FAIL'}"]
for stage in u.get("logs", []):
out = ANSI.sub(
"", (stage.get("stdout") or "") + " " + (stage.get("stderr") or "")
)
lines.append(f"-- {stage['stage']} {'ok' if stage['success'] else 'FAIL'}")
lines.append(re.sub("<[^>]+>", "", out).strip()[:1500])
return "\n".join(lines)
+215
View File
@@ -0,0 +1,215 @@
"""§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
+7
View File
@@ -30,6 +30,13 @@ raycast-api = { git = "https://git.kotikot.com/beaver/raycast-api" }
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"ruff>=0.15.13",
"ty>=0.0.37",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
+2
View File
@@ -26,6 +26,8 @@ unfixable = ["F401"]
[lint.per-file-ignores]
"config.py" = ["INP001"]
"policy.py" = ["INP001"]
"tests/*" = ["ALL"]
[lint.pydocstyle]
convention = "google"
+51
View File
@@ -0,0 +1,51 @@
import pytest
from beaver_gateway.mcp.types import McpServer
from beaver_gateway.mcp.wrap import build_python_tool_server
from fastmcp import Client
from fastmcp.exceptions import ToolError
from mcps.komodo import Komodo
@pytest.fixture
def server():
tool = Komodo(url="http://127.0.0.1:9", key="k", secret="s")
return build_python_tool_server(
McpServer.python_tool(name="komodo", tools=[tool.komodo])
)
async def test_only_listed_actions_exist(server):
async with Client(server) as client:
(tool,) = await client.list_tools()
actions = tool.inputSchema["properties"]["action"]["enum"]
assert (
"prune" not in actions
and "destroy" not in actions
and "terminal" not in actions
)
assert {"logs", "deploy", "restart", "exec"} <= set(actions)
with pytest.raises(ToolError):
await client.call_tool(
"komodo", {"action": "prune", "stack": "beaver-agent"}
)
async def test_exec_denies_infrastructure_before_any_request(server):
async with Client(server) as client:
result = await client.call_tool(
"komodo", {"action": "exec", "container": "dell-periphery", "command": "id"}
)
assert "запрещён" in result.content[0].text
async def test_missing_parameter_is_an_error(server):
async with Client(server) as client:
with pytest.raises(ToolError, match="stack"):
await client.call_tool("komodo", {"action": "logs"})
def test_clip_keeps_head_and_tail():
k = Komodo(url="u", key="k", secret="s", max_chars=30)
out = k._clip("a" * 100)
assert out.startswith("a" * 20) and out.endswith("a" * 10) and "обрезано" in out
+117
View File
@@ -0,0 +1,117 @@
import tempfile
from pathlib import Path
import pytest
from beaver_gateway.core.policy import Deny, ToolCall
from policy import Zones, bash_zones, requires_skill, skill_tracker, vault_zones
@pytest.fixture
def vault() -> Path:
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
(root / d).mkdir(parents=True)
(root / "💬 чаты/старый.md").write_text("x")
(root / "📅 дни/2026-08-29.md").write_text("x")
return root
@pytest.fixture
def zones(vault: Path) -> Zones:
return Zones(
vault=vault, write=(vault / "мета/бобер",), create=(vault / "💬 чаты",)
)
def call(vault: Path, tool: str, state=None, **tool_input) -> ToolCall:
return ToolCall(
tool=tool,
input=tool_input,
agent="a",
kind="master",
conversation="c",
cwd=vault,
state=state if state is not None else {},
)
def test_write_outside_zones_denied(vault, zones):
rule = vault_zones(zones)
deny = rule(call(vault, "Write", file_path=str(vault / "📅 дни/2026-08-29.md")))
assert isinstance(deny, Deny) and "мета/бобер" in deny.reason
assert rule(call(vault, "Edit", file_path=str(vault / "👤 люди/x.md"))) is not None
assert rule(call(vault, "Write", file_path="новое.md")) is not None
def test_zones_allow_and_create_only(vault, zones):
rule = vault_zones(zones)
assert (
rule(call(vault, "Write", file_path=str(vault / "мета/бобер/дни/x.md"))) is None
)
assert rule(call(vault, "Edit", file_path="мета/бобер/состояние.md")) is None
assert rule(call(vault, "Write", file_path=str(vault / "💬 чаты/новый.md"))) is None
assert (
rule(call(vault, "Write", file_path=str(vault / "💬 чаты/старый.md")))
is not None
)
assert (
rule(call(vault, "Edit", file_path=str(vault / "💬 чаты/новый.md"))) is not None
)
assert rule(call(vault, "Read", file_path=str(vault / "📅 дни/x.md"))) is None
assert rule(call(vault, "Write", file_path="/tmp/scratch.md")) is None
@pytest.mark.parametrize(
"command",
[
"rm '📅 дни/2026-08-29.md'",
"rm -rf 👤\\ люди",
"mv '👤 люди/x.md' 'мета/бобер/x.md'",
"cp мета/бобер/x.md '📅 дни/y.md'",
"echo hi > '📅 дни/today.md'",
"cat a.md | tee '💬 чаты/старый.md'",
"sed -i 's/a/b/' '👤 люди/x.md'",
"cd 👤\\ люди && rm x.md",
"ls; touch new.md",
],
)
def test_bash_mutations_outside_zones_denied(vault, zones, command):
deny = bash_zones(zones)(call(vault, "Bash", command=command))
assert isinstance(deny, Deny), command
assert deny.reason.startswith("Bash: ")
@pytest.mark.parametrize(
"command",
[
"cat '👤 люди/x.md' | grep foo",
"rm мета/бобер/дни/old.md",
"echo hi > мета/бобер/x.md 2>&1",
"cp '👤 люди/x.md' мета/бобер/копия.md",
"sed 's/a/b/' '👤 люди/x.md'",
"echo hi > '💬 чаты/новый.md'",
"rm /tmp/x && ls > /dev/null",
"python3 -c \"print('x')\" >> /tmp/log",
"grep -r 'x' . --include='*.md'",
],
)
def test_bash_reads_and_zone_writes_allowed(vault, zones, command):
assert bash_zones(zones)(call(vault, "Bash", command=command)) is None, command
def test_bash_unbalanced_quotes_fall_back(vault, zones):
assert bash_zones(zones)(call(vault, "Bash", command='rm "📅')) is not None
def test_firefly_requires_open_skill(vault):
state = {}
tracker, gate = (
skill_tracker(),
requires_skill("firefly", ("mcp__firefly__store_*",)),
)
store = call(vault, "mcp__firefly__store_transaction", state=state, data={})
assert isinstance(gate(store), Deny)
assert gate(call(vault, "mcp__firefly__list_account", state=state)) is None
tracker(call(vault, "Skill", state=state, skill="vault:firefly"))
assert gate(store) is None
Generated
+50
View File
@@ -290,6 +290,8 @@ prod = [
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "ty" },
]
@@ -304,6 +306,8 @@ provides-extras = ["local", "prod"]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "ruff", specifier = ">=0.15.13" },
{ name = "ty", specifier = ">=0.0.37" },
]
@@ -971,6 +975,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -1434,6 +1447,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.5.2"
@@ -1750,6 +1772,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod')" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"