feat(policy,claude_sdk,cli,config_loader): pretooluse policy rules per agent, tool call audit, sibling imports for config
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user