From 40231049ebfdfb04f25cd8eba8cae2e203645072 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 29 Aug 2026 15:27:32 +0200 Subject: [PATCH] feat(policy,komodo,config): vault zone and firefly skill rules, komodo python_tool with exec, tests --- Makefile | 1 + README.md | 19 +++ config.py | 93 ++++++++------ docker-compose.yml | 2 + mcps/__init__.py | 0 mcps/komodo.py | 292 +++++++++++++++++++++++++++++++++++++++++++ policy.py | 215 +++++++++++++++++++++++++++++++ pyproject.toml | 7 ++ ruff.toml | 2 + tests/test_komodo.py | 51 ++++++++ tests/test_policy.py | 117 +++++++++++++++++ uv.lock | 50 ++++++++ 12 files changed, 810 insertions(+), 39 deletions(-) create mode 100644 mcps/__init__.py create mode 100644 mcps/komodo.py create mode 100644 policy.py create mode 100644 tests/test_komodo.py create mode 100644 tests/test_policy.py diff --git a/Makefile b/Makefile index 214e64e..fda4a1b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 58b9bcb..9f26575 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.py b/config.py index 520c065..15c78fd 100644 --- a/config.py +++ b/config.py @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 2945bad..78388d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/mcps/__init__.py b/mcps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcps/komodo.py b/mcps/komodo.py new file mode 100644 index 0000000..5e76b10 --- /dev/null +++ b/mcps/komodo.py @@ -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) diff --git a/policy.py b/policy.py new file mode 100644 index 0000000..948b0cd --- /dev/null +++ b/policy.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 74e69b7..e4c66ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = ["."] diff --git a/ruff.toml b/ruff.toml index 90fedfe..cbaca42 100644 --- a/ruff.toml +++ b/ruff.toml @@ -26,6 +26,8 @@ unfixable = ["F401"] [lint.per-file-ignores] "config.py" = ["INP001"] +"policy.py" = ["INP001"] +"tests/*" = ["ALL"] [lint.pydocstyle] convention = "google" diff --git a/tests/test_komodo.py b/tests/test_komodo.py new file mode 100644 index 0000000..6c260d7 --- /dev/null +++ b/tests/test_komodo.py @@ -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 diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..a5ceb2a --- /dev/null +++ b/tests/test_policy.py @@ -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 diff --git a/uv.lock b/uv.lock index d306a01..e402c0a 100644 --- a/uv.lock +++ b/uv.lock @@ -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"