refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
@@ -17,9 +17,9 @@ from pathlib import Path # noqa: TC003 - pydantic runtime
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
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.agents.policy import PolicyRule # noqa: TC001 - pydantic runtime
|
||||
from beaver_gateway.agents.prompts import PromptSource # noqa: TC001 - pydantic runtime
|
||||
from beaver_gateway.conversations.kinds import KINDS, Kind
|
||||
|
||||
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"]
|
||||
|
||||
|
||||
@@ -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] + "…"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""System prompt assembly from a list of source files.
|
||||
|
||||
The gateway holds no prompt text: an agent names its granules (paths from
|
||||
``config.py``) and :func:`assemble` concatenates them in that order, so the
|
||||
result is byte-for-byte identical for every session of the same agent as
|
||||
long as the files are. A source is a path, or a ``(tag, path)`` pair whose
|
||||
content is wrapped in ``<tag>...</tag>`` - the markup lives here, the vault
|
||||
keeps plain markdown. Each granule's hash is logged at assembly so a
|
||||
drifted prompt can be traced to the file that changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
__all__ = ["PromptSource", "assemble"]
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.agents.prompts")
|
||||
|
||||
PromptSource = str | Path | tuple[str, str | Path]
|
||||
|
||||
|
||||
def assemble(sources: Iterable[PromptSource]) -> str:
|
||||
parts: list[str] = []
|
||||
for source in sources:
|
||||
tag, raw = source if isinstance(source, tuple) else (None, source)
|
||||
path = Path(raw)
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
_log.info(
|
||||
"prompt granule %s tag=%s sha=%s bytes=%d", path, tag, digest, len(text)
|
||||
)
|
||||
if not text:
|
||||
continue
|
||||
parts.append(f"<{tag}>\n{text}\n</{tag}>" if tag else text)
|
||||
return "\n\n".join(parts) + "\n"
|
||||
Reference in New Issue
Block a user