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
+53
View File
@@ -53,6 +53,7 @@ from claude_agent_sdk import (
CanUseToolShadowedWarning,
ClaudeAgentOptions,
ClaudeSDKClient,
HookMatcher,
MirrorErrorMessage,
PermissionResultAllow,
PermissionResultDeny,
@@ -66,6 +67,7 @@ from claude_agent_sdk import (
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.events import (
StopReason,
@@ -101,6 +103,7 @@ if TYPE_CHECKING:
SessionStore,
ToolPermissionContext,
)
from claude_agent_sdk.types import HookEvent
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.agents.claude import ClaudeAgent
@@ -116,6 +119,7 @@ warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
ASK_TOOL = "AskUserQuestion"
__all__ = [
"AuditSink",
"ClaudeSdkBackend",
"RunnerConfig",
"SessionClient",
@@ -166,6 +170,8 @@ ClientFactory = "Callable[[ClaudeAgentOptions], SessionClient]"
UsageSink = "Callable[[UsageEvent], Awaitable[None]]"
ToolServerFactory = "Callable[[str, str], McpSdkServerConfig | 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]]"
"""``(conversation_key, AskUserQuestion input) -> text the model reads as the
tool result``. The only channel an answer has in bypass mode is
@@ -218,8 +224,10 @@ class ClaudeSdkBackend:
pool: SessionPool | None = None,
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
) -> None:
self._agent = agent
self._audit_sink = audit_sink
self._store = session_store
self._runner = runner or RunnerConfig()
self._usage_sink = usage_sink
@@ -594,6 +602,7 @@ class ClaudeSdkBackend:
cli_path=str(self._exec_wrapper(extra_keep=tuple(env))),
include_partial_messages=opt.include_partial_messages,
can_use_tool=self._can_use_tool(key) if self._asker else None,
hooks=self._hooks(key, spec),
session_store=self._store,
session_store_flush=cast("Any", opt.session_store_flush),
resume=resume,
@@ -629,6 +638,50 @@ class ClaudeSdkBackend:
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]]:
plugins: list[dict[str, str]] = []
root = self._work_dir / "plugins" / self._agent.name