feat(policy,claude_sdk,cli,config_loader): pretooluse policy rules per agent, tool call audit, sibling imports for config
This commit is contained in:
@@ -16,6 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||||||
|
|
||||||
from beaver_gateway.agents.base import BaseAgent
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
from beaver_gateway.core.kinds import KINDS, Kind
|
from beaver_gateway.core.kinds import KINDS, Kind
|
||||||
|
from beaver_gateway.core.policy import PolicyRule # noqa: TC001 - pydantic runtime
|
||||||
from beaver_gateway.core.prompt import PromptSource # noqa: TC001 - pydantic runtime
|
from beaver_gateway.core.prompt import PromptSource # noqa: TC001 - pydantic runtime
|
||||||
|
|
||||||
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts"]
|
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts"]
|
||||||
@@ -86,6 +87,9 @@ class ClaudeAgent(BaseAgent):
|
|||||||
``say``, ``schedule``, ``inject``); empty = no gateway MCP server."""
|
``say``, ``schedule``, ``inject``); empty = no gateway MCP server."""
|
||||||
|
|
||||||
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
|
options: ClaudeOptions = Field(default_factory=ClaudeOptions)
|
||||||
|
policy: tuple[PolicyRule, ...] = ()
|
||||||
|
"""``PreToolUse`` rules (§3.7), run in order on every tool call; the
|
||||||
|
first ``Deny`` is what the model reads back. See ``core/policy``."""
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _kinds_follow_prompts(self) -> ClaudeAgent:
|
def _kinds_follow_prompts(self) -> ClaudeAgent:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from claude_agent_sdk import (
|
|||||||
CanUseToolShadowedWarning,
|
CanUseToolShadowedWarning,
|
||||||
ClaudeAgentOptions,
|
ClaudeAgentOptions,
|
||||||
ClaudeSDKClient,
|
ClaudeSDKClient,
|
||||||
|
HookMatcher,
|
||||||
MirrorErrorMessage,
|
MirrorErrorMessage,
|
||||||
PermissionResultAllow,
|
PermissionResultAllow,
|
||||||
PermissionResultDeny,
|
PermissionResultDeny,
|
||||||
@@ -66,6 +67,7 @@ from claude_agent_sdk import (
|
|||||||
project_key_for_directory,
|
project_key_for_directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from beaver_gateway.core import policy as policy_mod
|
||||||
from beaver_gateway.core import prompt as prompt_assembly
|
from beaver_gateway.core import prompt as prompt_assembly
|
||||||
from beaver_gateway.core.events import (
|
from beaver_gateway.core.events import (
|
||||||
StopReason,
|
StopReason,
|
||||||
@@ -101,6 +103,7 @@ if TYPE_CHECKING:
|
|||||||
SessionStore,
|
SessionStore,
|
||||||
ToolPermissionContext,
|
ToolPermissionContext,
|
||||||
)
|
)
|
||||||
|
from claude_agent_sdk.types import HookEvent
|
||||||
|
|
||||||
from beaver_gateway.agents.base import BaseAgent
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
@@ -116,6 +119,7 @@ warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
|
|||||||
ASK_TOOL = "AskUserQuestion"
|
ASK_TOOL = "AskUserQuestion"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AuditSink",
|
||||||
"ClaudeSdkBackend",
|
"ClaudeSdkBackend",
|
||||||
"RunnerConfig",
|
"RunnerConfig",
|
||||||
"SessionClient",
|
"SessionClient",
|
||||||
@@ -166,6 +170,8 @@ ClientFactory = "Callable[[ClaudeAgentOptions], SessionClient]"
|
|||||||
UsageSink = "Callable[[UsageEvent], Awaitable[None]]"
|
UsageSink = "Callable[[UsageEvent], Awaitable[None]]"
|
||||||
ToolServerFactory = "Callable[[str, str], McpSdkServerConfig | None]"
|
ToolServerFactory = "Callable[[str, str], McpSdkServerConfig | None]"
|
||||||
"""``(conversation_key, kind) -> in-process MCP server config`` or ``None``."""
|
"""``(conversation_key, kind) -> in-process MCP server config`` or ``None``."""
|
||||||
|
AuditSink = "Callable[[policy_mod.ToolAudit], Awaitable[None]]"
|
||||||
|
"""Receives every tool call the PreToolUse hook saw, allowed or denied."""
|
||||||
Asker = "Callable[[str, dict[str, Any]], Awaitable[str]]"
|
Asker = "Callable[[str, dict[str, Any]], Awaitable[str]]"
|
||||||
"""``(conversation_key, AskUserQuestion input) -> text the model reads as the
|
"""``(conversation_key, AskUserQuestion input) -> text the model reads as the
|
||||||
tool result``. The only channel an answer has in bypass mode is
|
tool result``. The only channel an answer has in bypass mode is
|
||||||
@@ -218,8 +224,10 @@ class ClaudeSdkBackend:
|
|||||||
pool: SessionPool | None = None,
|
pool: SessionPool | None = None,
|
||||||
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
|
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
|
||||||
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
|
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
|
||||||
|
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._agent = agent
|
self._agent = agent
|
||||||
|
self._audit_sink = audit_sink
|
||||||
self._store = session_store
|
self._store = session_store
|
||||||
self._runner = runner or RunnerConfig()
|
self._runner = runner or RunnerConfig()
|
||||||
self._usage_sink = usage_sink
|
self._usage_sink = usage_sink
|
||||||
@@ -594,6 +602,7 @@ class ClaudeSdkBackend:
|
|||||||
cli_path=str(self._exec_wrapper(extra_keep=tuple(env))),
|
cli_path=str(self._exec_wrapper(extra_keep=tuple(env))),
|
||||||
include_partial_messages=opt.include_partial_messages,
|
include_partial_messages=opt.include_partial_messages,
|
||||||
can_use_tool=self._can_use_tool(key) if self._asker else None,
|
can_use_tool=self._can_use_tool(key) if self._asker else None,
|
||||||
|
hooks=self._hooks(key, spec),
|
||||||
session_store=self._store,
|
session_store=self._store,
|
||||||
session_store_flush=cast("Any", opt.session_store_flush),
|
session_store_flush=cast("Any", opt.session_store_flush),
|
||||||
resume=resume,
|
resume=resume,
|
||||||
@@ -629,6 +638,50 @@ class ClaudeSdkBackend:
|
|||||||
|
|
||||||
return can_use_tool
|
return can_use_tool
|
||||||
|
|
||||||
|
def _hooks(
|
||||||
|
self, key: str, spec: _SessionSpec
|
||||||
|
) -> dict[HookEvent, list[HookMatcher]] | None:
|
||||||
|
"""§3.7: one in-process ``PreToolUse`` hook - policy rules, then audit."""
|
||||||
|
agent = self._agent
|
||||||
|
if not agent.policy and self._audit_sink is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def pre_tool_use(
|
||||||
|
hook_input: Any, _tool_use_id: str | None, _ctx: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
live = self._pool.get(key)
|
||||||
|
call = policy_mod.ToolCall(
|
||||||
|
tool=str(hook_input.get("tool_name", "")),
|
||||||
|
input=hook_input.get("tool_input") or {},
|
||||||
|
agent=agent.name,
|
||||||
|
kind=spec.kind,
|
||||||
|
conversation=key,
|
||||||
|
cwd=agent.cwd,
|
||||||
|
state=live.state if live is not None else {},
|
||||||
|
)
|
||||||
|
deny = await policy_mod.evaluate(agent.policy, call)
|
||||||
|
if deny is not None:
|
||||||
|
_log.info(
|
||||||
|
"policy: %s denied %s: %s", agent.name, call.tool, deny.reason
|
||||||
|
)
|
||||||
|
if self._audit_sink is not None:
|
||||||
|
audit = policy_mod.ToolAudit(
|
||||||
|
agent=agent.name,
|
||||||
|
conversation=key,
|
||||||
|
kind=spec.kind,
|
||||||
|
tool=call.tool,
|
||||||
|
decision="deny" if deny else "allow",
|
||||||
|
reason=deny.reason if deny else None,
|
||||||
|
brief=policy_mod.brief(call.input),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._audit_sink(audit)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("tool audit failed for %s", call.tool)
|
||||||
|
return policy_mod.hook_output(deny)
|
||||||
|
|
||||||
|
return {"PreToolUse": [HookMatcher(hooks=[cast("Any", pre_tool_use)])]}
|
||||||
|
|
||||||
def _plugins(self) -> list[dict[str, str]]:
|
def _plugins(self) -> list[dict[str, str]]:
|
||||||
plugins: list[dict[str, str]] = []
|
plugins: list[dict[str, str]] = []
|
||||||
root = self._work_dir / "plugins" / self._agent.name
|
root = self._work_dir / "plugins" / self._agent.name
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ from beaver_gateway.frontends.base import GatewayRuntime
|
|||||||
from beaver_gateway.frontends.root import build_root_app
|
from beaver_gateway.frontends.root import build_root_app
|
||||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||||
from beaver_gateway.settings import Settings
|
from beaver_gateway.settings import Settings
|
||||||
from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage
|
from beaver_gateway.storage import (
|
||||||
|
Database,
|
||||||
|
PostgresSessionStore,
|
||||||
|
Usage,
|
||||||
|
append_audit,
|
||||||
|
append_usage,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from claude_agent_sdk import McpSdkServerConfig
|
from claude_agent_sdk import McpSdkServerConfig
|
||||||
@@ -70,6 +76,7 @@ if TYPE_CHECKING:
|
|||||||
from starlette.types import ASGIApp
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
from beaver_gateway.backends.base import Backend
|
from beaver_gateway.backends.base import Backend
|
||||||
|
from beaver_gateway.core.policy import ToolAudit
|
||||||
from beaver_gateway.mcp.types import McpServerT
|
from beaver_gateway.mcp.types import McpServerT
|
||||||
|
|
||||||
|
|
||||||
@@ -432,6 +439,27 @@ async def _build_backends(
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("usage write failed for %s", event.agent_name)
|
_log.exception("usage write failed for %s", event.agent_name)
|
||||||
|
|
||||||
|
async def record_tool(event: ToolAudit) -> None:
|
||||||
|
detail = {
|
||||||
|
"conversation": event.conversation,
|
||||||
|
"kind": event.kind,
|
||||||
|
"tool": event.tool,
|
||||||
|
"decision": event.decision,
|
||||||
|
"reason": event.reason,
|
||||||
|
"brief": event.brief,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with db.session() as session:
|
||||||
|
await append_audit(
|
||||||
|
session,
|
||||||
|
actor=f"agent:{event.agent}",
|
||||||
|
kind="tool_call",
|
||||||
|
agent_name=event.agent,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("tool audit write failed for %s", event.agent)
|
||||||
|
|
||||||
for a in agents:
|
for a in agents:
|
||||||
if isinstance(a, ClaudeAgent):
|
if isinstance(a, ClaudeAgent):
|
||||||
adapter = ClaudeSdkBackend(
|
adapter = ClaudeSdkBackend(
|
||||||
@@ -444,6 +472,7 @@ async def _build_backends(
|
|||||||
pool=pool,
|
pool=pool,
|
||||||
tool_server=functools.partial(late.server, names=a.gateway_tools),
|
tool_server=functools.partial(late.server, names=a.gateway_tools),
|
||||||
asker=late.ask,
|
asker=late.ask,
|
||||||
|
audit_sink=record_tool,
|
||||||
)
|
)
|
||||||
await stack.enter_async_context(adapter)
|
await stack.enter_async_context(adapter)
|
||||||
backends[a.name] = adapter
|
backends[a.name] = adapter
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ contents here before handing it back.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
@@ -59,6 +60,10 @@ def load(path: Path) -> Gateway:
|
|||||||
raise ConfigError(msg) from exc
|
raise ConfigError(msg) from exc
|
||||||
|
|
||||||
code = compile(source, str(path), "exec")
|
code = compile(source, str(path), "exec")
|
||||||
|
# Siblings of the config (``policy.py``, ``mcps/``) import by name.
|
||||||
|
parent = str(path.resolve().parent)
|
||||||
|
if parent not in sys.path:
|
||||||
|
sys.path.insert(0, parent)
|
||||||
namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES}
|
namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES}
|
||||||
exec(code, namespace) # noqa: S102 - exec'ing user config is the feature
|
exec(code, namespace) # noqa: S102 - exec'ing user config is the feature
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ KNOWN_KINDS: frozenset[str] = frozenset(
|
|||||||
{
|
{
|
||||||
"messages", # POST /v1/messages accepted
|
"messages", # POST /v1/messages accepted
|
||||||
"mcp_call", # /mcp/<ns>/... proxied
|
"mcp_call", # /mcp/<ns>/... proxied
|
||||||
|
"tool_call", # a model's tool call seen by the PreToolUse hook
|
||||||
"login_ok",
|
"login_ok",
|
||||||
"login_failed",
|
"login_failed",
|
||||||
"logout",
|
"logout",
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""PreToolUse policy - the boundary without permission prompts (§3.7).
|
||||||
|
|
||||||
|
``bypassPermissions`` everywhere; what a model may do is decided by
|
||||||
|
mounts, ``disallowed_tools`` and the rules here. A rule is a callable
|
||||||
|
``(ToolCall) -> Deny | None`` declared per agent (``ClaudeAgent.policy``);
|
||||||
|
the SDK backend registers one in-process ``PreToolUse`` hook that runs
|
||||||
|
the rules in order and turns the first :class:`Deny` into a hook deny
|
||||||
|
whose reason the model reads as the tool result. Rules never see
|
||||||
|
secrets and never prompt - they only say no, with a reason.
|
||||||
|
|
||||||
|
A rule that raises is a deny too: the boundary fails closed, the
|
||||||
|
traceback lands in the log.
|
||||||
|
|
||||||
|
Every tool call - allowed or denied - is reported to the audit sink the
|
||||||
|
backend was given, so the admin audit page shows what the model touched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Mapping, MutableMapping
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditSink",
|
||||||
|
"Decision",
|
||||||
|
"Deny",
|
||||||
|
"PolicyRule",
|
||||||
|
"ToolAudit",
|
||||||
|
"ToolCall",
|
||||||
|
"brief",
|
||||||
|
"evaluate",
|
||||||
|
"hook_output",
|
||||||
|
]
|
||||||
|
|
||||||
|
_log = logging.getLogger("beaver_gateway.policy")
|
||||||
|
|
||||||
|
PATH_KEYS: tuple[str, ...] = ("file_path", "notebook_path", "path")
|
||||||
|
"""Input keys built-in file tools use for their target."""
|
||||||
|
|
||||||
|
Decision = Literal["allow", "deny"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Deny:
|
||||||
|
"""Refusal with the reason the model reads as the tool result."""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ToolCall:
|
||||||
|
"""One tool invocation as the hook sees it.
|
||||||
|
|
||||||
|
``state`` is a mutable per-session dict rules may write to (which
|
||||||
|
skills were opened, what was already asked); it lives as long as the
|
||||||
|
claude process and is empty again after a resume.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tool: str
|
||||||
|
input: Mapping[str, Any]
|
||||||
|
agent: str
|
||||||
|
kind: str
|
||||||
|
conversation: str
|
||||||
|
cwd: Path
|
||||||
|
state: MutableMapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mcp(self) -> tuple[str, str] | None:
|
||||||
|
"""``(server, tool)`` for ``mcp__<server>__<tool>`` names, else ``None``."""
|
||||||
|
if not self.tool.startswith("mcp__"):
|
||||||
|
return None
|
||||||
|
_, _, rest = self.tool.partition("__")
|
||||||
|
server, sep, name = rest.partition("__")
|
||||||
|
return (server, name) if sep else (server, "")
|
||||||
|
|
||||||
|
def path(self, *keys: str) -> Path | None:
|
||||||
|
"""Target of a file tool, absolute; relative paths resolve against ``cwd``."""
|
||||||
|
for key in keys or PATH_KEYS:
|
||||||
|
raw = self.input.get(key)
|
||||||
|
if isinstance(raw, str) and raw:
|
||||||
|
return self.resolve(raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve(self, raw: str) -> Path:
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = self.cwd / path
|
||||||
|
# ``resolve`` would follow symlinks on the gateway host, which may
|
||||||
|
# not be the model's view; normalise lexically instead.
|
||||||
|
return Path(*_normalize(path.parts))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(parts: Iterable[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
if part == "..":
|
||||||
|
if len(out) > 1:
|
||||||
|
out.pop()
|
||||||
|
elif part != ".":
|
||||||
|
out.append(part)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
type PolicyRule = Callable[[ToolCall], Deny | None | Awaitable[Deny | None]]
|
||||||
|
"""A rule; sync or async. First ``Deny`` wins, ``None`` passes to the next."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ToolAudit:
|
||||||
|
agent: str
|
||||||
|
conversation: str
|
||||||
|
kind: str
|
||||||
|
tool: str
|
||||||
|
decision: Decision
|
||||||
|
reason: str | None
|
||||||
|
brief: str
|
||||||
|
|
||||||
|
|
||||||
|
AuditSink = "Callable[[ToolAudit], Awaitable[None]]"
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate(
|
||||||
|
rules: Iterable[Callable[[ToolCall], Any]], call: ToolCall
|
||||||
|
) -> Deny | None:
|
||||||
|
"""Run ``rules`` in order; the first deny wins, an exception is a deny."""
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
verdict = rule(call)
|
||||||
|
if inspect.isawaitable(verdict):
|
||||||
|
verdict = await verdict
|
||||||
|
except Exception: # noqa: BLE001 - a broken rule must fail closed
|
||||||
|
name = getattr(rule, "__name__", repr(rule))
|
||||||
|
_log.exception("policy rule %s failed on %s", name, call.tool)
|
||||||
|
return Deny(reason=f"policy rule {name} failed; the call is refused")
|
||||||
|
if isinstance(verdict, Deny):
|
||||||
|
return verdict
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hook_output(deny: Deny | None) -> dict[str, Any]:
|
||||||
|
"""``PreToolUse`` hook JSON for a verdict: empty means allow."""
|
||||||
|
if deny is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": deny.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def brief(tool_input: Mapping[str, Any], *, limit: int = 240) -> str:
|
||||||
|
"""Short, log-safe summary of a call: the path, the command, or the args."""
|
||||||
|
for key in (*PATH_KEYS, "command", "skill", "pattern", "query", "url"):
|
||||||
|
value = tool_input.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return _cut(value, limit)
|
||||||
|
try:
|
||||||
|
return _cut(json.dumps(tool_input, ensure_ascii=False, sort_keys=True), limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _cut(repr(tool_input), limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _cut(text: str, limit: int) -> str:
|
||||||
|
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||||
@@ -68,6 +68,8 @@ class Session:
|
|||||||
last_used: float = field(default_factory=time.monotonic)
|
last_used: float = field(default_factory=time.monotonic)
|
||||||
created_at: float = field(default_factory=time.monotonic)
|
created_at: float = field(default_factory=time.monotonic)
|
||||||
turns: int = 0
|
turns: int = 0
|
||||||
|
state: dict[str, Any] = field(default_factory=dict)
|
||||||
|
"""Scratch for policy rules (``core/policy``); dies with the process."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def busy(self) -> bool:
|
def busy(self) -> bool:
|
||||||
|
|||||||
@@ -458,3 +458,67 @@ async def test_deltas_reach_the_caller_before_the_turn_ends(cwd: Path) -> None:
|
|||||||
GatedClient.gate.set()
|
GatedClient.gate.set()
|
||||||
rest = [e async for e in events]
|
rest = [e async for e in events]
|
||||||
assert isinstance(rest[-1], RawMessageStopEvent)
|
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
|
||||||
|
|||||||
@@ -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 == []
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user