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 check
|
||||
uv run ty check
|
||||
uv run pytest -q
|
||||
|
||||
fix:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+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]
|
||||
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 = ["."]
|
||||
|
||||
@@ -26,6 +26,8 @@ unfixable = ["F401"]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"config.py" = ["INP001"]
|
||||
"policy.py" = ["INP001"]
|
||||
"tests/*" = ["ALL"]
|
||||
|
||||
[lint.pydocstyle]
|
||||
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]
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user