diff --git a/config.py b/config.py index 8f057eb..2f35699 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,7 @@ from typing import Any from zoneinfo import ZoneInfo 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, SkillSets from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences from beaver_gateway.core.conversations import ( ConversationTexts, @@ -27,7 +27,7 @@ from beaver_gateway.frontends.api import ApiFrontend from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.markdown import MarkdownFrontend from beaver_gateway.frontends.mcp_server import McpServerFrontend -from beaver_gateway.frontends.telegram import TelegramFrontend +from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT from curator import Watched, briefing @@ -42,6 +42,7 @@ from policy import ( TRIAGE_DISALLOWED, Zones, bash_zones, + forbid_skills, requires_skill, skill_tracker, vault_zones, @@ -127,9 +128,23 @@ CURATOR_PROMPTS = Prompts( ) QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый"))) -# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault. +# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault; +# мастер - без vault (firefly, beaver durden - только в ветке и в глубоком); +# форк и джоб диспетчера - без скиллов вовсе. DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault") -DISPATCHER_SKILLS = (SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault") +DISPATCHER_SKILLS = SkillSets( + master=(SKILLS / "общие", SKILLS / "диспетчер"), + branch=(SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault"), + fork=(), + job=(), +) +# Мастер не становится Дерденом: без набора скилл и так не найдётся, правило - +# ради внятного отказа («открой ветку»). +MASTER_FORBIDDEN_SKILLS = ( + tuple(p.name for p in sorted((SKILLS / "vault").iterdir()) if p.is_dir()) + if (SKILLS / "vault").exists() + else () +) # §3.7 (решение h, 2026-08-30): vault - дом диспетчера. Создавать, править, @@ -290,7 +305,10 @@ def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent: skill_sets=DISPATCHER_SKILLS, gateway_tools=("read_conversation", "spawn", "say", "schedule"), options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED), - policy=VAULT_POLICY, + policy=( + *VAULT_POLICY, + forbid_skills(MASTER_FORBIDDEN_SKILLS, kinds=("master",)), + ), # §4.4: t3code и вайбграм - руки диспетчера (мастер, ветки), глубоким # не даются: наружу и в код ходит только он. expose_mcps=(*CLAUDE_MCPS, *t3code_exposed, *vibegram_exposed), @@ -409,6 +427,11 @@ TELEGRAM = ( user_id=int(os.environ["TELEGRAM_USER_ID"]), master_agent="beaver-dispatcher", branch_agent="beaver-dispatcher", + # §3.8: вложения - входящая папка в зоне агента, по дням; что стоит + # хранить, он переносит к заметке сам, остальное сметается через месяц. + attachments=Attachments( + mode="vault", dir=BEAVER / "вложения", keep_days=30, tz=TZ + ), ) if os.environ.get("TELEGRAM_BOT_TOKEN") else None @@ -850,9 +873,9 @@ jobs = [ Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False), Job("закрытие", close_idle, cron="20 4 * * *", critical=False), Job("память", memory, cron="30 4 * * 0", critical=False), - # крон в UTC контейнера: 02/10/14/18/22 по Варшаве летом; + # кроны считаются в Gateway.tz (Варшава), не в UTC контейнера; # руками - POST /api/jobs/куратор/run - Job("куратор", curate, cron="0 0,8,12,16,20 * * *", critical=False), + Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False), Job("deploy", deploy, webhook=True), # алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя Job("komodo", komodo_alert, webhook=True, dedupe=False), diff --git a/policy.py b/policy.py index 398bf4d..9d33675 100644 --- a/policy.py +++ b/policy.py @@ -27,6 +27,7 @@ __all__ = [ "TRIAGE_DISALLOWED", "Zones", "bash_zones", + "forbid_skills", "requires_skill", "skill_tracker", "vault_zones", @@ -220,15 +221,39 @@ def _pathlike(word: str) -> bool: ) +def _skill_name(call: ToolCall) -> str: + """Имя скилла из `Skill({"skill": "vault:firefly", ...})`, без плагина.""" + name = str(call.input.get("skill") or call.input.get("name") or "") + return name.rsplit(":", 1)[-1] + + 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]) + opened.add(_skill_name(call)) + + return rule + + +def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule: + """§4.3: скиллы из ``names`` (fnmatch, без учёта регистра) в ``kinds`` закрыты. + + Мастер их не открывает - для этого есть ветка; правило отвечает так, + чтобы модель сама её открыла. + """ + patterns = tuple(p.lower() for p in names) + + def rule(call: ToolCall) -> Deny | None: + if call.tool != "Skill" or call.kind not in kinds: + return None + name = _skill_name(call) + if not any(fnmatch.fnmatchcase(name.lower(), p) for p in patterns): + return None + return Deny(reason=f"скилл {name} недоступен в {call.kind}: открой ветку") return rule diff --git a/tests/test_policy.py b/tests/test_policy.py index 9a1cf7c..6ec6f0b 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -4,7 +4,14 @@ 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 +from policy import ( + Zones, + bash_zones, + forbid_skills, + requires_skill, + skill_tracker, + vault_zones, +) @pytest.fixture @@ -24,12 +31,14 @@ def zones(vault: Path) -> Zones: ) -def call(vault: Path, tool: str, state=None, **tool_input) -> ToolCall: +def call( + vault: Path, tool: str, state=None, kind: str = "master", **tool_input +) -> ToolCall: return ToolCall( tool=tool, input=tool_input, agent="a", - kind="master", + kind=kind, conversation="c", cwd=vault, state=state if state is not None else {}, @@ -117,6 +126,18 @@ def test_firefly_requires_open_skill(vault): assert gate(store) is None +def test_forbid_skills_only_named_kinds_and_only_skill_tool(vault): + rule = forbid_skills(("firefly", "beaver *"), kinds=("master", "job")) + deny = rule(call(vault, "Skill", skill="vault:firefly")) + assert isinstance(deny, Deny) + assert deny.reason == "скилл firefly недоступен в master: открой ветку" + assert rule(call(vault, "Skill", kind="job", skill="Beaver Durden")) is not None + assert rule(call(vault, "Skill", name="vault:FireFly")) is not None + assert rule(call(vault, "Skill", kind="branch", skill="vault:firefly")) is None + assert rule(call(vault, "Skill", skill="общие:заметки")) is None + assert rule(call(vault, "Read", file_path="скиллы/vault/firefly/SKILL.md")) is None + + @pytest.fixture def home(vault: Path) -> Zones: """Зоны диспетчера с 2026-08-30: vault - дом, удаление - только своё.""" diff --git a/uv.lock b/uv.lock index e402c0a..8f1245b 100644 --- a/uv.lock +++ b/uv.lock @@ -390,7 +390,7 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.103.0" }, { name = "anyio", specifier = ">=4.13.0" }, { name = "argon2-cffi", specifier = ">=25.1.0" }, - { name = "claude-agent-sdk", specifier = ">=0.2.146" }, + { name = "claude-agent-sdk", specifier = ">=0.2.150" }, { name = "croniter", specifier = ">=6.2.4" }, { name = "fastapi", specifier = ">=0.136.1" }, { name = "fastmcp", specifier = ">=3.3.1" }, @@ -502,7 +502,7 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.2.146" +version = "0.2.150" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -510,13 +510,13 @@ dependencies = [ { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/08/a79f73aad2156f4518ae6e0fffbcdb403cd5347d62b994b005a0c9bb12fb/claude_agent_sdk-0.2.146.tar.gz", hash = "sha256:9e402548466018421cfd0725a7601e14d9e3e879276a6a4a877c5a2ff6633546", size = 344704, upload-time = "2026-08-27T22:25:46.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/e2/7618d7ffd3d4580d53d1147e7baba005c3bf4359d8bb8ab9beab62b5a0ec/claude_agent_sdk-0.2.146-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7bcb91dd34c2e0e5b4f006648d457bbfba9aa1e44cf2e5d02abb441dcbbaff98", size = 84087716, upload-time = "2026-08-27T22:25:50.846Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e6/004b957547e32f6df4948c3a5a3c06974c6c19539c357ebe40a1a23941d3/claude_agent_sdk-0.2.146-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:9285b3dd900376b045a06d2d6f5e19bfd96a3f6cb88d21166bb840aff14ee505", size = 88567306, upload-time = "2026-08-27T22:25:54.876Z" }, - { url = "https://files.pythonhosted.org/packages/37/fa/1c1cb847ff8a92f6ec9a0176a984ec1902cf6eab6c027fd45059d211a96d/claude_agent_sdk-0.2.146-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ad536806e6c6fc1972ae3950b1bb171f1494e6f47002c24dbc68f19af1c34997", size = 94195787, upload-time = "2026-08-27T22:25:59.367Z" }, - { url = "https://files.pythonhosted.org/packages/60/f9/48aa74e5a85ebe11b916a99b053cc64f47bea8409ec2b9ada5b5a1169a7a/claude_agent_sdk-0.2.146-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:1f2bae0759f3962be2714c786c1a6961824c5ce3d4b7f44f0828191bd44ccebe", size = 94385743, upload-time = "2026-08-27T22:26:03.218Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d4/ff986dc35330146cba60a1bb6ee3f521cdec25ccc0306ef359b9d0cb59c4/claude_agent_sdk-0.2.146-py3-none-win_amd64.whl", hash = "sha256:dc2c4773dc02492b52c74f957a059ecbc0aec7063bd1a16c039b78613194b3cf", size = 97049829, upload-time = "2026-08-27T22:26:08.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" }, + { url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" }, ] [[package]]