feat(policy,komodo,config): vault zone and firefly skill rules, komodo python_tool with exec, tests
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user