"""Small, explicit primitives for exposing tools to an agent job. The broker is a policy boundary, not a plugin system. A process constructs a ``Registry`` itself, then creates one ``ToolBroker`` per job with an immutable ``JobPolicy``. A broker snapshots the registered handlers, so later registry changes cannot expand a running job's capabilities. """ from __future__ import annotations import json import math import queue import re import threading import time from collections.abc import Callable, Mapping from dataclasses import dataclass, field from types import MappingProxyType from urllib.parse import urlsplit from .trace import OUTCOME_DENIED_OPERATOR, OUTCOME_DENIED_RUN, ToolTrace _TOOL_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") _VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") class ToolCallError(Exception): """A safe, expected tool failure that can be returned to the caller.""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message class ToolInputError(ToolCallError): """Raised by a tool when its arguments are invalid.""" def __init__(self, message: str) -> None: super().__init__("invalid_input", message) def _require_plain_int(name: str, value: object, minimum: int, maximum: int) -> int: if type(value) is not int: raise ValueError(f"{name} must be an integer (booleans are not accepted)") if not minimum <= value <= maximum: raise ValueError(f"{name} must be between {minimum} and {maximum}") return value def _require_timeout(value: object) -> float: if isinstance(value, bool) or not isinstance(value, int | float): raise ValueError("timeout_seconds must be a finite number") timeout = float(value) if not math.isfinite(timeout) or not 0.001 <= timeout <= 300.0: raise ValueError("timeout_seconds must be between 0.001 and 300") return timeout def _validate_tool_id(value: object) -> str: if not isinstance(value, str) or not 1 <= len(value) <= 96 or not _TOOL_ID_RE.fullmatch(value): raise ValueError("tool_id must be a lowercase, dotted identifier of at most 96 characters") return value def _validate_version(value: object) -> str: if not isinstance(value, str) or not 1 <= len(value) <= 32 or not _VERSION_RE.fullmatch(value): raise ValueError("version must be a simple identifier of at most 32 characters") return value def normalize_host(value: object) -> str: """Return a canonical ASCII hostname, rejecting URLs, ports and wildcards.""" if not isinstance(value, str) or not value or len(value) > 253: raise ValueError("allowed hosts must be non-empty hostnames") if any(character.isspace() for character in value) or ":" in value or "/" in value or "*" in value: raise ValueError("allowed hosts must not contain schemes, ports, paths or wildcards") try: host = value.rstrip(".").encode("idna").decode("ascii").lower() except UnicodeError as exc: raise ValueError("allowed host is not a valid IDNA hostname") from exc if not host or any(not _HOST_LABEL_RE.fullmatch(label) for label in host.split(".")): raise ValueError("allowed host is malformed") return host def _deep_freeze(value: object) -> object: if isinstance(value, Mapping): frozen = {str(key): _deep_freeze(item) for key, item in value.items()} return MappingProxyType(frozen) if isinstance(value, list | tuple): return tuple(_deep_freeze(item) for item in value) if isinstance(value, set): return frozenset(_deep_freeze(item) for item in value) return value @dataclass(frozen=True) class ToolRef: """An exact tool identity; versions are never resolved implicitly.""" tool_id: str version: str def __post_init__(self) -> None: object.__setattr__(self, "tool_id", _validate_tool_id(self.tool_id)) object.__setattr__(self, "version", _validate_version(self.version)) @dataclass(frozen=True) class ToolSpec: """Immutable metadata published for a registered tool. ``origin`` says where the definition came from: ``"builtin"`` for a tool written in this repository, ``"mcp:"`` for one read from an MCP server. It exists because ``description`` reaches the model's prompt, and a description written by a third party is untrusted input that a harness should fence the way it already fences tool *results*. Provenance that is not carried alongside the text cannot be checked at the point of use. """ tool_id: str version: str description: str input_schema: Mapping[str, object] = field(default_factory=dict) required_hosts: frozenset[str] = field(default_factory=frozenset) origin: str = "builtin" #: ``"tool"`` answers a question in-band; ``"skill"`` produces an artifact #: the run hands back to the user (a file, a plan, a document). Both travel #: the same registry, the same approval policy, the same broker and the #: same allowlist: the library treats them identically and the kind exists #: so surfaces can group them, never so policy can differ. kind: str = "tool" def __post_init__(self) -> None: object.__setattr__(self, "tool_id", _validate_tool_id(self.tool_id)) object.__setattr__(self, "version", _validate_version(self.version)) if self.kind not in {"tool", "skill"}: raise ValueError("kind must be 'tool' or 'skill'") if ( not isinstance(self.description, str) or not self.description.strip() or len(self.description) > 2_000 ): raise ValueError("description must be a non-empty string of at most 2,000 characters") if not isinstance(self.input_schema, Mapping): raise ValueError("input_schema must be a mapping") object.__setattr__(self, "input_schema", _deep_freeze(self.input_schema)) object.__setattr__( self, "required_hosts", frozenset(normalize_host(host) for host in self.required_hosts) ) if not isinstance(self.origin, str) or not self.origin.strip() or len(self.origin) > 128: raise ValueError("origin must be a short, non-empty string") object.__setattr__(self, "origin", self.origin.strip()) @property def ref(self) -> ToolRef: return ToolRef(self.tool_id, self.version) @property def third_party(self) -> bool: """True when the description was written outside this repository.""" return self.origin != "builtin" def to_dict(self) -> dict[str, object]: """Return JSON-ready metadata suitable for a model harness.""" return { "id": self.tool_id, "version": self.version, "ref": f"{self.tool_id}@{self.version}", "description": self.description, "input_schema": _thaw_json(self.input_schema), "required_hosts": sorted(self.required_hosts), "origin": self.origin, "third_party": self.third_party, "kind": self.kind, } @dataclass(frozen=True) class ToolResult: """A JSON-safe result envelope returned for every broker call.""" tool_id: str version: str ok: bool output: object | None = None error_code: str | None = None error_message: str | None = None elapsed_ms: int = 0 @classmethod def success(cls, ref: ToolRef, output: object, elapsed_ms: int) -> ToolResult: return cls(ref.tool_id, ref.version, True, output=output, elapsed_ms=elapsed_ms) @classmethod def failure(cls, ref: ToolRef, code: str, message: str, elapsed_ms: int = 0) -> ToolResult: return cls( ref.tool_id, ref.version, False, error_code=code, error_message=message, elapsed_ms=elapsed_ms, ) def to_dict(self) -> dict[str, object]: """Return a stable JSON-ready envelope for an inference harness.""" value: dict[str, object] = { "tool_id": self.tool_id, "version": self.version, "ok": self.ok, "elapsed_ms": self.elapsed_ms, } if self.ok: value["output"] = _thaw_json(self.output) else: value["error"] = { "code": self.error_code, "message": self.error_message, } return value @dataclass(frozen=True) class JobPolicy: """Immutable limits for exactly one job's broker.""" allowed_tools: frozenset[ToolRef] #: Kept in step with the server's per-run ceiling and the worker's hard #: cap; ``tests/test_tool_budget_agrees.py`` fails if they drift. max_calls: int = 10 per_tool_quotas: Mapping[ToolRef, int] = field(default_factory=dict) timeout_seconds: float = 15.0 max_input_bytes: int = 16_384 max_output_bytes: int = 131_072 allowed_hosts: frozenset[str] = field(default_factory=frozenset) def __post_init__(self) -> None: refs = frozenset(_coerce_ref(ref) for ref in self.allowed_tools) object.__setattr__(self, "allowed_tools", refs) object.__setattr__(self, "max_calls", _require_plain_int("max_calls", self.max_calls, 1, 100_000)) object.__setattr__(self, "timeout_seconds", _require_timeout(self.timeout_seconds)) object.__setattr__( self, "max_input_bytes", _require_plain_int("max_input_bytes", self.max_input_bytes, 1, 10 * 1024 * 1024), ) object.__setattr__( self, "max_output_bytes", _require_plain_int("max_output_bytes", self.max_output_bytes, 1, 10 * 1024 * 1024), ) quotas: dict[ToolRef, int] = {} for raw_ref, raw_limit in self.per_tool_quotas.items(): ref = _coerce_ref(raw_ref) if ref not in refs: raise ValueError("per-tool quota refers to a tool that is not allowed") quotas[ref] = _require_plain_int("per-tool quota", raw_limit, 1, 100_000) object.__setattr__(self, "per_tool_quotas", MappingProxyType(quotas)) object.__setattr__( self, "allowed_hosts", frozenset(normalize_host(host) for host in self.allowed_hosts) ) def _coerce_ref(value: object) -> ToolRef: if isinstance(value, ToolRef): return value if isinstance(value, tuple) and len(value) == 2: return ToolRef(value[0], value[1]) raise ValueError("tool references must be ToolRef values or (tool_id, version) tuples") @dataclass(frozen=True) class ToolContext: """Capabilities and deadline supplied to a handler by the broker.""" job_id: str deadline: float allowed_hosts: frozenset[str] def remaining_seconds(self) -> float: return max(0.0, self.deadline - time.monotonic()) def require_url(self, url: str) -> str: """Validate an outbound HTTPS URL against this job's exact host allowlist. **This is a check a handler calls, not a boundary around a handler.** A handler runs on an ordinary thread in the agent process, with no import hook, no audit hook, no seccomp filter and no namespace, so a handler that imports ``socket`` and opens a connection is not stopped by anything here and does not appear in the trace as having done so. That was reproduced: a spec declaring no ``required_hosts``, therefore eligible for the ``local`` bundle, ran under a policy with an empty ``allowed_hosts`` and sent the prompt to a listener, and the trace recorded a clean success. What the framework does enforce is declaration: a tool that *declares* a host is refused unless the operator separately approved egress for it. What it cannot enforce is behaviour, which is why every tool in this repository is reviewed for what it imports, and why a third-party in-process tool is an operator's decision about code they are choosing to run in their own process rather than something this policy contains for them. Calling this is still worth doing. It is the difference between a tool that reaches one approved host and a tool that reaches any host, for every handler written in good faith, which is all of them here. """ if not isinstance(url, str) or len(url) > 4_096: raise ToolCallError("host_not_allowed", "outbound URL is malformed") parsed = urlsplit(url) if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: raise ToolCallError("host_not_allowed", "only HTTPS URLs without user information are allowed") try: host = normalize_host(parsed.hostname) port = parsed.port except (ValueError, UnicodeError) as exc: raise ToolCallError("host_not_allowed", "outbound URL is malformed") from exc if port not in (None, 443) or host not in self.allowed_hosts: raise ToolCallError("host_not_allowed", "outbound host is not allowed for this job") return host ToolHandler = Callable[[Mapping[str, object], ToolContext], object] @dataclass(frozen=True) class _RegisteredTool: spec: ToolSpec handler: ToolHandler class Registry: """A registry populated only by direct calls to ``register``. There is deliberately no entry-point discovery, import scanning, or dynamic module loading. """ def __init__(self) -> None: self._tools: dict[ToolRef, _RegisteredTool] = {} self._lock = threading.RLock() def register(self, spec: ToolSpec, handler: ToolHandler) -> None: if not isinstance(spec, ToolSpec): raise TypeError("spec must be a ToolSpec") if not callable(handler): raise TypeError("handler must be callable") with self._lock: if spec.ref in self._tools: raise ValueError(f"tool {spec.tool_id} version {spec.version} is already registered") self._tools[spec.ref] = _RegisteredTool(spec, handler) def resolve(self, ref: ToolRef) -> _RegisteredTool: with self._lock: try: return self._tools[ref] except KeyError as exc: raise KeyError(f"tool {ref.tool_id} version {ref.version} is not registered") from exc def specs(self) -> tuple[ToolSpec, ...]: with self._lock: return tuple( entry.spec for _, entry in sorted( self._tools.items(), key=lambda item: (item[0].tool_id, item[0].version) ) ) def _copy_json(value: object, *, depth: int = 0, nodes: list | None = None) -> object: """Copy and validate the deliberately small JSON value subset used by tools.""" if nodes is None: nodes = [0] nodes[0] += 1 if nodes[0] > 10_000 or depth > 24: raise ValueError("JSON value is too complex") if value is None or type(value) is bool: return value if type(value) is str: return value if type(value) is int: if not -(2**63) <= value <= 2**63 - 1: raise ValueError("integers must fit in signed 64-bit range") return value if type(value) is float: if not math.isfinite(value): raise ValueError("non-finite numbers are not accepted") return value if isinstance(value, list | tuple): if len(value) > 10_000: raise ValueError("JSON arrays are too large") return [_copy_json(item, depth=depth + 1, nodes=nodes) for item in value] if isinstance(value, Mapping): if len(value) > 10_000: raise ValueError("JSON objects are too large") result: dict[str, object] = {} for key, item in value.items(): if type(key) is not str or len(key) > 256 or any(ord(character) < 32 for character in key): raise ValueError("JSON object keys must be short strings without control characters") result[key] = _copy_json(item, depth=depth + 1, nodes=nodes) return result raise ValueError("tool values must contain only JSON-compatible types") def _thaw_json(value: object) -> object: """Turn frozen metadata and validated output into ordinary JSON containers.""" if isinstance(value, Mapping): return {key: _thaw_json(item) for key, item in value.items()} if isinstance(value, tuple | list | frozenset): return [_thaw_json(item) for item in value] return value def _json_size(value: object) -> int: return len(json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")) class OperatorRefusal(PermissionError): """The worker's operator has not approved a tool this run asked for. Distinct from ``KeyError`` (not installed) and from ``tool_not_allowed`` (not selected for this run) because the three have different remedies and collapsing them leaves the operator with nothing to act on. """ def __init__(self, ref: ToolRef, reason: str) -> None: super().__init__(reason) self.ref = ref self.reason = reason class ToolBroker: """Policy-enforcing, per-job dispatcher for registered tools. Two allowlists apply and they come from different parties. ``policy`` carries what the *server* selected for this run. ``operator_policy``, if supplied, carries what the *worker's operator* has approved at all, and it is checked first: a server cannot reach a tool the operator withheld, even by naming it explicitly. """ def __init__( self, registry: Registry, policy: JobPolicy, *, job_id: str, operator_policy: object = None, ) -> None: if not isinstance(registry, Registry): raise TypeError("registry must be a Registry") if not isinstance(policy, JobPolicy): raise TypeError("policy must be a JobPolicy") if not isinstance(job_id, str) or not job_id or len(job_id) > 128 or any(ord(c) < 32 for c in job_id): raise ValueError("job_id must be a non-empty, short string without control characters") snapshot: dict[ToolRef, _RegisteredTool] = {} for ref in policy.allowed_tools: snapshot[ref] = registry.resolve(ref) # Checked after resolution so the message can name the tool, and # before anything is stored so a refused run has no broker at all. if operator_policy is not None: refuse = getattr(operator_policy, "refusal_reason", None) if not callable(refuse): raise TypeError("operator_policy must expose refusal_reason(spec)") for ref in sorted(snapshot, key=lambda item: (item.tool_id, item.version)): reason = refuse(snapshot[ref].spec) if reason: raise OperatorRefusal(ref, reason) self.job_id = job_id self.policy = policy self.operator_policy = operator_policy self._tools = MappingProxyType(snapshot) self._calls_started = 0 self._calls_by_tool: dict[ToolRef, int] = {} self._quota_lock = threading.Lock() self.trace = ToolTrace(job_id=job_id) @property def calls_started(self) -> int: with self._quota_lock: return self._calls_started def tool_specs(self) -> tuple[ToolSpec, ...]: """Return only the exact tool versions granted to this job.""" return tuple( self._tools[ref].spec for ref in sorted(self._tools, key=lambda item: (item.tool_id, item.version)) ) def tool_manifest(self) -> tuple[dict[str, object], ...]: """Return JSON-ready tool descriptions for prompt/tool-schema adapters.""" return tuple(spec.to_dict() for spec in self.tool_specs()) def tool_refs(self) -> tuple[str, ...]: """Return stable ``id@version`` capability identifiers.""" return tuple(f"{spec.tool_id}@{spec.version}" for spec in self.tool_specs()) def call(self, tool_id: str, version: str, arguments: Mapping[str, object]) -> ToolResult: """Call an exact tool version and return a non-throwing result envelope. Every outcome, including every refusal, is recorded on :attr:`trace` before the result is returned. A denial the caller chooses to swallow is still a fact about the run. """ result = self._dispatch(tool_id, version, arguments) self.trace.record_result( tool_id=result.tool_id, version=result.version, arguments=arguments, result_envelope=result.to_dict(), ) return result def record_external_denial( self, tool_ref: str, reason: str, *, operator: bool = False ) -> None: """Record a refusal decided before the call reached this broker. An adapter that rejects an unknown tool id upstream would otherwise leave no trace of the attempt, and "the model tried to call a tool you did not select" is precisely what the user needs to be told. """ tool_id, _, version = str(tool_ref).partition("@") self.trace.record( tool_id=tool_id, version=version, outcome=OUTCOME_DENIED_OPERATOR if operator else OUTCOME_DENIED_RUN, error_code="operator_not_approved" if operator else "tool_not_allowed", error_message=reason, ) def _dispatch(self, tool_id: str, version: str, arguments: Mapping[str, object]) -> ToolResult: started = time.monotonic() try: ref = ToolRef(tool_id, version) except (TypeError, ValueError) as exc: fallback = ToolRef("invalid", "invalid") return ToolResult.failure(fallback, "invalid_tool_ref", str(exc)) entry = self._tools.get(ref) if entry is None: return ToolResult.failure(ref, "tool_not_allowed", "tool id/version is not allowed for this job") missing_hosts = entry.spec.required_hosts.difference(self.policy.allowed_hosts) if missing_hosts: return ToolResult.failure( ref, "host_not_allowed", "the job policy does not allow this tool's network host" ) try: copied_arguments = _copy_json(arguments) if not isinstance(copied_arguments, dict): raise ValueError("tool arguments must be a JSON object") if _json_size(copied_arguments) > self.policy.max_input_bytes: raise ValueError("tool arguments exceed the job input limit") except (TypeError, ValueError) as exc: return ToolResult.failure(ref, "invalid_input", str(exc)) quota_error = self._reserve_call(ref) if quota_error is not None: return ToolResult.failure(ref, "quota_exceeded", quota_error) context = ToolContext( job_id=self.job_id, deadline=time.monotonic() + self.policy.timeout_seconds, allowed_hosts=self.policy.allowed_hosts, ) completed: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1) def run_handler() -> None: try: completed.put((True, entry.handler(copied_arguments, context))) except BaseException as exc: # contained at the tool boundary completed.put((False, exc)) worker = threading.Thread( target=run_handler, name=f"tool-{ref.tool_id}-{self.job_id}", daemon=True, ) worker.start() try: succeeded, payload = completed.get(timeout=self.policy.timeout_seconds) except queue.Empty: return ToolResult.failure( ref, "timeout", f"tool exceeded its {self.policy.timeout_seconds:.3f} second deadline", _elapsed_ms(started), ) if not succeeded: if isinstance(payload, ToolCallError): return ToolResult.failure(ref, payload.code, payload.message, _elapsed_ms(started)) return ToolResult.failure(ref, "tool_error", "tool execution failed", _elapsed_ms(started)) try: output = _copy_json(payload) if _json_size(output) > self.policy.max_output_bytes: return ToolResult.failure( ref, "output_too_large", "tool output exceeds the job output limit", _elapsed_ms(started), ) except (TypeError, ValueError): return ToolResult.failure( ref, "invalid_output", "tool returned a value that is not valid bounded JSON", _elapsed_ms(started), ) return ToolResult.success(ref, output, _elapsed_ms(started)) def invoke(self, tool_id: str, version: str, arguments: Mapping[str, object]) -> ToolResult: """Model-harness entry point using an explicit id and version.""" return self.call(tool_id, version, arguments) def invoke_ref(self, tool_ref: str, arguments: Mapping[str, object]) -> ToolResult: """Model-harness entry point using one exact ``id@version`` reference.""" if not isinstance(tool_ref, str) or tool_ref.count("@") != 1: failure = ToolResult.failure( ToolRef("invalid", "invalid"), "invalid_tool_ref", "tool reference must use the exact id@version form", ) self.trace.record_result( tool_id=failure.tool_id, version=failure.version, arguments=arguments, result_envelope=failure.to_dict(), ) return failure tool_id, version = tool_ref.split("@", 1) return self.call(tool_id, version, arguments) def _reserve_call(self, ref: ToolRef) -> str | None: with self._quota_lock: if self._calls_started >= self.policy.max_calls: return "job call quota is exhausted" current = self._calls_by_tool.get(ref, 0) tool_limit = self.policy.per_tool_quotas.get(ref) if tool_limit is not None and current >= tool_limit: return "per-tool call quota is exhausted" self._calls_started += 1 self._calls_by_tool[ref] = current + 1 return None def _elapsed_ms(started: float) -> int: return max(0, int(round((time.monotonic() - started) * 1_000)))