feat(policy,claude_sdk,cli,config_loader): pretooluse policy rules per agent, tool call audit, sibling imports for config

This commit is contained in:
hh
2026-08-29 15:27:26 +02:00
parent 1786406b31
commit 72639c4b90
10 changed files with 420 additions and 1 deletions
+64
View File
@@ -458,3 +458,67 @@ async def test_deltas_reach_the_caller_before_the_turn_ends(cwd: Path) -> None:
GatedClient.gate.set()
rest = [e async for e in events]
assert isinstance(rest[-1], RawMessageStopEvent)
async def test_policy_hook_denies_and_audits(cwd: Path) -> None:
from beaver_gateway.core.policy import Deny, ToolCall
def no_days(c: ToolCall):
p = c.path()
if c.tool == "Write" and p is not None and "📅 дни" in p.parts:
return Deny(reason="дневник только читать")
c.state["seen"] = c.state.get("seen", 0) + 1
return None
audits = []
async def sink(a):
audits.append(a)
backend = _backend(cwd, InMemorySessionStore(), policy=(no_days,))
backend._audit_sink = sink
await _drain(
backend.complete(
agent=backend.agent,
messages=[{"role": "user", "content": "hi"}],
conversation_id="conv-1",
)
)
hook = FakeClient.instances[0].options.hooks["PreToolUse"][0].hooks[0]
denied = await hook(
{"tool_name": "Write", "tool_input": {"file_path": "/vault/📅 дни/x.md"}},
"t1",
{},
)
assert denied["hookSpecificOutput"]["permissionDecision"] == "deny"
assert (
denied["hookSpecificOutput"]["permissionDecisionReason"]
== "дневник только читать"
)
allowed = await hook(
{"tool_name": "Write", "tool_input": {"file_path": "/vault/мета/бобер/x.md"}},
"t2",
{},
)
assert allowed == {}
await hook(
{"tool_name": "Read", "tool_input": {"file_path": "/vault/a.md"}}, "t3", {}
)
assert backend.live("conv-1").state["seen"] == 2
assert [(a.tool, a.decision) for a in audits] == [
("Write", "deny"),
("Write", "allow"),
("Read", "allow"),
]
assert audits[0].brief == "/vault/📅 дни/x.md"
assert audits[0].kind == "deep" and audits[0].conversation == "conv-1"
async def test_no_policy_no_hooks(cwd: Path) -> None:
backend = _backend(cwd, InMemorySessionStore())
await _drain(
backend.complete(
agent=backend.agent, messages=[{"role": "user", "content": "hi"}]
)
)
assert FakeClient.instances[0].options.hooks is None
+16
View File
@@ -0,0 +1,16 @@
import tempfile
from pathlib import Path
from beaver_gateway import config_loader
def test_config_imports_sibling_modules():
root = Path(tempfile.mkdtemp(prefix="beaver-cfg-"))
(root / "policy_sibling.py").write_text("RULES = ('x',)\n")
(root / "config.py").write_text(
"from policy_sibling import RULES\n"
"assert RULES == ('x',)\n"
"gateway = Gateway()\n"
)
gw = config_loader.load(root / "config.py")
assert gw.agents == []
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
import pytest
from beaver_gateway.core.policy import Deny, ToolCall, brief, evaluate, hook_output
def call(tool: str, **tool_input) -> ToolCall:
return ToolCall(
tool=tool,
input=tool_input,
agent="a",
kind="master",
conversation="c1",
cwd=Path("/vault"),
)
def test_path_resolves_relative_against_cwd():
assert call("Write", file_path="мета/бобер/x.md").path() == Path(
"/vault/мета/бобер/x.md"
)
assert call("Write", file_path="/vault/a/../b.md").path() == Path("/vault/b.md")
assert call("Write", file_path="../../etc/passwd").path() == Path("/etc/passwd")
assert call("Bash", command="ls").path() is None
def test_mcp_split():
assert call("mcp__firefly__store_transaction").mcp == (
"firefly",
"store_transaction",
)
assert call("Write").mcp is None
async def test_first_deny_wins_and_async_rules_work():
seen = []
def allow(c):
seen.append("allow")
async def deny(c):
return Deny(reason="no")
def never(c):
raise AssertionError
verdict = await evaluate((allow, deny, never), call("Write"))
assert verdict == Deny(reason="no")
assert seen == ["allow"]
async def test_raising_rule_fails_closed():
def boom(c):
raise RuntimeError("x")
verdict = await evaluate((boom,), call("Write"))
assert verdict is not None and "boom" in verdict.reason
def test_hook_output_shapes():
assert hook_output(None) == {}
out = hook_output(Deny(reason="r"))
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert out["hookSpecificOutput"]["permissionDecisionReason"] == "r"
def test_brief_prefers_path_then_command_and_truncates():
assert brief({"file_path": "/v/x.md", "content": "a" * 999}) == "/v/x.md"
assert brief({"command": "ls -la"}) == "ls -la"
assert len(brief({"q": "a" * 999})) == 240