feat(policy,komodo,config): vault zone and firefly skill rules, komodo python_tool with exec, tests
This commit is contained in:
@@ -7,6 +7,7 @@ check:
|
|||||||
uv run ruff format --check
|
uv run ruff format --check
|
||||||
uv run ruff check
|
uv run ruff check
|
||||||
uv run ty check
|
uv run ty check
|
||||||
|
uv run pytest -q
|
||||||
|
|
||||||
fix:
|
fix:
|
||||||
uv run ruff format
|
uv run ruff format
|
||||||
|
|||||||
@@ -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
|
`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.
|
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
|
## Editing config.py on the mac
|
||||||
|
|
||||||
`make sync` installs `../beaver-gateway` editable (extra `local`), so
|
`make sync` installs `../beaver-gateway` editable (extra `local`), so
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from datetime import UTC, date, datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
|
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts
|
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts
|
||||||
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
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.frontends.telegram import TelegramFrontend
|
||||||
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
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"
|
TZ = "Europe/Warsaw"
|
||||||
VAULT = Path("/vault")
|
VAULT = Path("/vault")
|
||||||
CHATS_DIR = VAULT / "💬 чаты"
|
CHATS_DIR = VAULT / "💬 чаты"
|
||||||
@@ -99,6 +111,18 @@ DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
|
|||||||
DISPATCHER_SKILLS = (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
|
def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001
|
||||||
"""Новый файл чата в `💬 чаты/`: помесячная папка, дата и тема."""
|
"""Новый файл чата в `💬 чаты/`: помесячная папка, дата и тема."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -125,6 +149,21 @@ def _calendar_mcps() -> list[HttpMcp]:
|
|||||||
calendar_mcps = _calendar_mcps()
|
calendar_mcps = _calendar_mcps()
|
||||||
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in 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 даёт ему белый список
|
# Секреты MCP - только через env подпроцесса (mcp stdio даёт ему белый список
|
||||||
# + это), никогда argv: процесс модели видит `ps` всего контейнера.
|
# + это), никогда argv: процесс модели видит `ps` всего контейнера.
|
||||||
mcps: list[McpServerT] = [
|
mcps: list[McpServerT] = [
|
||||||
@@ -144,6 +183,7 @@ mcps: list[McpServerT] = [
|
|||||||
),
|
),
|
||||||
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
|
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
|
||||||
*calendar_mcps,
|
*calendar_mcps,
|
||||||
|
*komodo_mcps,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои
|
# Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои
|
||||||
@@ -152,6 +192,7 @@ CLAUDE_MCPS = (
|
|||||||
ExposedMcp(name="firefly", deny=("delete_*",)),
|
ExposedMcp(name="firefly", deny=("delete_*",)),
|
||||||
ExposedMcp(name="telegram"),
|
ExposedMcp(name="telegram"),
|
||||||
*calendar_exposed,
|
*calendar_exposed,
|
||||||
|
*komodo_exposed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,11 +209,8 @@ def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|||||||
prompts=DISPATCHER_PROMPTS,
|
prompts=DISPATCHER_PROMPTS,
|
||||||
skill_sets=DISPATCHER_SKILLS,
|
skill_sets=DISPATCHER_SKILLS,
|
||||||
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
|
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
|
||||||
effort=effort,
|
policy=VAULT_POLICY,
|
||||||
# §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет.
|
|
||||||
disallowed_tools=("ExitPlanMode", "EnterPlanMode", "NotebookEdit"),
|
|
||||||
),
|
|
||||||
expose_mcps=CLAUDE_MCPS,
|
expose_mcps=CLAUDE_MCPS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,17 +224,8 @@ def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|||||||
skill_sets=DEEP_SKILLS,
|
skill_sets=DEEP_SKILLS,
|
||||||
# §8.4: «ок, обсудили» - тулза, закрытие после ответа.
|
# §8.4: «ок, обсудили» - тулза, закрытие после ответа.
|
||||||
gateway_tools=("close_chat",),
|
gateway_tools=("close_chat",),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
|
||||||
effort=effort,
|
policy=VAULT_POLICY,
|
||||||
# §3.7: у глубоких вопросы текстом, планов и сабагентов нет.
|
|
||||||
disallowed_tools=(
|
|
||||||
"AskUserQuestion",
|
|
||||||
"ExitPlanMode",
|
|
||||||
"EnterPlanMode",
|
|
||||||
"NotebookEdit",
|
|
||||||
"Task",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
expose_mcps=CLAUDE_MCPS,
|
expose_mcps=CLAUDE_MCPS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,8 +240,9 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|||||||
options=ClaudeOptions(
|
options=ClaudeOptions(
|
||||||
effort=effort,
|
effort=effort,
|
||||||
tools=("Read", "Write"),
|
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,
|
prompts=TRIAGE_PROMPTS,
|
||||||
gateway_tools=("say", "inject"),
|
gateway_tools=("say", "inject"),
|
||||||
options=ClaudeOptions(
|
options=ClaudeOptions(
|
||||||
effort=effort,
|
effort=effort, tools=(), disallowed_tools=TRIAGE_DISALLOWED
|
||||||
tools=(),
|
|
||||||
disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -451,26 +479,13 @@ KOMODO_STACK = os.environ.get("KOMODO_STACK", "beaver-agent")
|
|||||||
|
|
||||||
|
|
||||||
async def komodo_deploy() -> None:
|
async def komodo_deploy() -> None:
|
||||||
url, key, secret = (
|
if KOMODO is None:
|
||||||
os.environ.get(k, "") for k in ("KOMODO_URL", "KOMODO_KEY", "KOMODO_SECRET")
|
|
||||||
)
|
|
||||||
if not (url and key and secret):
|
|
||||||
_log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed")
|
_log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed")
|
||||||
return
|
return
|
||||||
body = {"type": "DeployStack", "params": {"stack": KOMODO_STACK, "services": []}}
|
receipt = await KOMODO.execute(
|
||||||
headers = {"X-Api-Key": key, "X-Api-Secret": secret}
|
"DeployStack", {"stack": KOMODO_STACK, "services": []}
|
||||||
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]
|
|
||||||
)
|
)
|
||||||
|
_log.info("deploy hook: komodo accepted %s", receipt.get("id"))
|
||||||
|
|
||||||
|
|
||||||
async def deploy(run: JobRun) -> None:
|
async def deploy(run: JobRun) -> None:
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ services:
|
|||||||
- "${PORT_GATEWAY:-62990}:62990"
|
- "${PORT_GATEWAY:-62990}:62990"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.py:/config/config.py:ro
|
- ./config.py:/config/config.py:ro
|
||||||
|
- ./policy.py:/config/policy.py:ro
|
||||||
|
- ./mcps:/config/mcps:ro
|
||||||
- ./config.json:/config/config.json:ro
|
- ./config.json:/config/config.json:ro
|
||||||
# §3.7: vault только на чтение, rw - подмонтирования зон агента.
|
# §3.7: vault только на чтение, rw - подмонтирования зон агента.
|
||||||
- vault:/vault:ro
|
- vault:/vault:ro
|
||||||
|
|||||||
+292
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -30,6 +30,13 @@ raycast-api = { git = "https://git.kotikot.com/beaver/raycast-api" }
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
"pytest-asyncio>=1.4.0",
|
||||||
"ruff>=0.15.13",
|
"ruff>=0.15.13",
|
||||||
"ty>=0.0.37",
|
"ty>=0.0.37",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ unfixable = ["F401"]
|
|||||||
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
"config.py" = ["INP001"]
|
"config.py" = ["INP001"]
|
||||||
|
"policy.py" = ["INP001"]
|
||||||
|
"tests/*" = ["ALL"]
|
||||||
|
|
||||||
[lint.pydocstyle]
|
[lint.pydocstyle]
|
||||||
convention = "google"
|
convention = "google"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -290,6 +290,8 @@ prod = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
]
|
]
|
||||||
@@ -304,6 +306,8 @@ provides-extras = ["local", "prod"]
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||||
{ name = "ruff", specifier = ">=0.15.13" },
|
{ name = "ruff", specifier = ">=0.15.13" },
|
||||||
{ name = "ty", specifier = ">=0.0.37" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.5.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
Reference in New Issue
Block a user