import tempfile from pathlib import Path import pytest from beaver_gateway.core.policy import Deny, ToolCall from policy import ( Zones, bash_zones, forbid_skills, 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, kind: str = "master", **tool_input ) -> ToolCall: return ToolCall( tool=tool, input=tool_input, agent="a", kind=kind, 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 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 - дом, удаление - только своё.""" (vault / ".obsidian").mkdir() (vault / "мета/шаблоны").mkdir() return Zones( vault=vault, write=(vault / "мета/бобер",), create=(vault / "💬 чаты",), edit=(vault,), protected=(vault / ".obsidian", vault / "мета"), ) def test_home_zones_allow_notes_but_protect_obsidian_and_meta(vault, home): rule = vault_zones(home) assert rule(call(vault, "Write", file_path=str(vault / "👤 люди/новый.md"))) is None assert ( rule(call(vault, "Edit", file_path=str(vault / "📅 дни/2026-08-29.md"))) is None ) assert rule(call(vault, "Write", file_path=str(vault / "мета/бобер/x.md"))) is None assert ( rule(call(vault, "Edit", file_path=str(vault / "💬 чаты/старый.md"))) is not None ) deny = rule(call(vault, "Write", file_path=str(vault / ".obsidian/app.json"))) assert isinstance(deny, Deny) and "Бобра" in deny.reason assert ( rule(call(vault, "Edit", file_path=str(vault / "мета/шаблоны/x.md"))) is not None ) @pytest.mark.parametrize( "command", [ "mv '👤 люди/x.md' '👤 люди/архив/x.md'", "echo hi >> '📅 дни/2026-08-29.md'", "sed -i 's/a/b/' '👤 люди/x.md'", "rm мета/бобер/дни/old.md", "mkdir '💻 проекты/новый'", ], ) def test_home_bash_edits_allowed(vault, home, command): assert bash_zones(home)(call(vault, "Bash", command=command)) is None, command @pytest.mark.parametrize( "command", [ "rm '📅 дни/2026-08-29.md'", "rm -rf 👤\\ люди", "truncate -s 0 '👤 люди/x.md'", "echo x > .obsidian/app.json", "mv '👤 люди/x.md' мета/шаблоны/x.md", ], ) def test_home_bash_deletes_and_protected_denied(vault, home, command): deny = bash_zones(home)(call(vault, "Bash", command=command)) assert isinstance(deny, Deny), command