apparitor 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apparitor/__init__.py ADDED
@@ -0,0 +1,172 @@
1
+ """AuthZEN 1.0 authorization scanner plugin for Meta's LlamaFirewall.
2
+
3
+ This package answers the question content-safety scanners do not: *"is this agent
4
+ **allowed** to do this?"* It evaluates agent tool calls against any AuthZEN-compliant
5
+ Policy Decision Point (PDP) and maps the decision onto LlamaFirewall's
6
+ ALLOW / BLOCK / HUMAN_IN_THE_LOOP model.
7
+
8
+ Import layout (deliberate):
9
+
10
+ * :mod:`apparitor.models`, ``client``, ``adapters``, ``mapping``,
11
+ ``cache``, ``config`` and ``errors`` are **LlamaFirewall-free** — importable and
12
+ unit-testable without the (heavy) LlamaFirewall ML stack installed.
13
+ * :class:`apparitor.AuthZENScanner` lives in
14
+ :mod:`apparitor.scanner`, which **requires** ``llamafirewall``. It is
15
+ exposed lazily (PEP 562 ``__getattr__``) so that ``import apparitor``
16
+ succeeds even when LlamaFirewall is not installed; accessing the scanner without it
17
+ raises :class:`~apparitor.errors.MissingDependencyError`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ from .adapters import (
25
+ AnthropicToolCallAdapter,
26
+ LangChainToolCallAdapter,
27
+ NormalizedToolCall,
28
+ OpenAIToolCallAdapter,
29
+ ToolCallAdapter,
30
+ detect_adapter,
31
+ )
32
+ from .backends import DecisionBackend, OPABackend, build_backend
33
+ from .config import Backend, OnError, ScannerConfig
34
+ from .decision import Verdict, VerdictResult, VerdictStatus
35
+ from .engine import AuthorizationEngine, ReviewPredicate
36
+ from .errors import (
37
+ AuthZENClientError,
38
+ AuthZENConfigError,
39
+ AuthZENError,
40
+ AuthZENServiceError,
41
+ MalformedPDPResponseError,
42
+ MissingDependencyError,
43
+ PDPTimeoutError,
44
+ PDPUnavailableError,
45
+ )
46
+ from .mapping import (
47
+ MCP_SERVER_LABEL_KEY,
48
+ DefaultToolCallMapper,
49
+ DualPrincipalMapper,
50
+ MCPResourceMapper,
51
+ ToolCallMapper,
52
+ build_boundary_leg,
53
+ current_request_context,
54
+ current_subject,
55
+ mcp_resource_id,
56
+ request_context_scope,
57
+ subject_scope,
58
+ )
59
+ from .metrics import DEFAULT_BUCKETS, InMemoryMetrics, MetricsSink, NoopMetrics
60
+ from .models import (
61
+ Action,
62
+ BatchEvaluationRequest,
63
+ BatchEvaluationResponse,
64
+ EvaluationItem,
65
+ EvaluationRequest,
66
+ EvaluationResponse,
67
+ EvaluationSemantic,
68
+ EvaluationsOptions,
69
+ Resource,
70
+ Subject,
71
+ )
72
+
73
+ __version__ = "0.1.1"
74
+
75
+ if TYPE_CHECKING:
76
+ # For type-checkers only; these runtime exports are lazy (see __getattr__ below) because
77
+ # each pulls an optional dependency (llamafirewall / cedarpy / nemoguardrails / fastmcp /
78
+ # a2a-sdk).
79
+ from .a2a import A2AAuthorizationExecutor
80
+ from .cedar import CedarBackend
81
+ from .fastmcp import FastMCPAuthorizationMiddleware
82
+ from .nemo import NeMoAuthorizationRails
83
+ from .scanner import AuthZENScanner
84
+
85
+ __all__ = [ # noqa: RUF022 - grouped by concern, not alphabetised, for readability
86
+ "__version__",
87
+ # enforcement-point adapters (lazy; each needs an optional host SDK)
88
+ "AuthZENScanner",
89
+ "NeMoAuthorizationRails",
90
+ "FastMCPAuthorizationMiddleware",
91
+ "A2AAuthorizationExecutor",
92
+ # config
93
+ "ScannerConfig",
94
+ "OnError",
95
+ "Backend",
96
+ # decision backends (AuthZEN client by default; native OPA; native Cedar via cedarpy, lazy)
97
+ "DecisionBackend",
98
+ "OPABackend",
99
+ "CedarBackend",
100
+ "build_backend",
101
+ # engine / decision (LlamaFirewall-free orchestration)
102
+ "AuthorizationEngine",
103
+ "ReviewPredicate",
104
+ "Verdict",
105
+ "VerdictResult",
106
+ "VerdictStatus",
107
+ # metrics
108
+ "MetricsSink",
109
+ "InMemoryMetrics",
110
+ "NoopMetrics",
111
+ "DEFAULT_BUCKETS",
112
+ # models
113
+ "Subject",
114
+ "Action",
115
+ "Resource",
116
+ "EvaluationRequest",
117
+ "EvaluationResponse",
118
+ "EvaluationItem",
119
+ "EvaluationsOptions",
120
+ "EvaluationSemantic",
121
+ "BatchEvaluationRequest",
122
+ "BatchEvaluationResponse",
123
+ # adapters
124
+ "NormalizedToolCall",
125
+ "ToolCallAdapter",
126
+ "OpenAIToolCallAdapter",
127
+ "AnthropicToolCallAdapter",
128
+ "LangChainToolCallAdapter",
129
+ "detect_adapter",
130
+ # mapping
131
+ "ToolCallMapper",
132
+ "DefaultToolCallMapper",
133
+ "DualPrincipalMapper",
134
+ "MCPResourceMapper",
135
+ "build_boundary_leg",
136
+ "MCP_SERVER_LABEL_KEY",
137
+ "current_subject",
138
+ "current_request_context",
139
+ "subject_scope",
140
+ "request_context_scope",
141
+ "mcp_resource_id",
142
+ # errors
143
+ "AuthZENError",
144
+ "AuthZENConfigError",
145
+ "AuthZENClientError",
146
+ "AuthZENServiceError",
147
+ "PDPUnavailableError",
148
+ "PDPTimeoutError",
149
+ "MalformedPDPResponseError",
150
+ "MissingDependencyError",
151
+ ]
152
+
153
+
154
+ #: Lazy exports (PEP 562): each module pulls an optional host/engine SDK, so importing
155
+ #: them on first attribute access keeps a plain ``import apparitor`` working without any
156
+ #: extras installed.
157
+ _LAZY_EXPORTS = {
158
+ "AuthZENScanner": "scanner",
159
+ "CedarBackend": "cedar",
160
+ "NeMoAuthorizationRails": "nemo",
161
+ "FastMCPAuthorizationMiddleware": "fastmcp",
162
+ "A2AAuthorizationExecutor": "a2a",
163
+ }
164
+
165
+
166
+ def __getattr__(name: str) -> object:
167
+ module = _LAZY_EXPORTS.get(name)
168
+ if module is None:
169
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
170
+ from importlib import import_module
171
+
172
+ return getattr(import_module(f".{module}", __name__), name)
apparitor/a2a.py ADDED
@@ -0,0 +1,293 @@
1
+ """A2A agent-executor adapter (thin adapter over :class:`AuthorizationEngine`).
2
+
3
+ This is the **only** module that imports the A2A SDK. The import is guarded so that
4
+ importing it without ``a2a-sdk`` installed yields a clear
5
+ :class:`~apparitor.errors.MissingDependencyError` rather than an opaque ``ImportError``.
6
+
7
+ It binds the firewall-free :class:`AuthorizationEngine` in front of an A2A
8
+ ``AgentExecutor`` so every agent-to-agent invocation (``message/send`` and streaming
9
+ variants) is authorized server-side, *before* the wrapped executor runs — the same engine,
10
+ fail-closed semantics and metrics as the LlamaFirewall scanner, the NeMo rail and the
11
+ FastMCP middleware; only the boundary differs. Like the FastMCP middleware (and unlike the
12
+ in-runtime firewall adapters), the subject can come from a **validated** identity: the
13
+ authenticated peer the A2A server's authentication layer established for the request —
14
+ never from anything inside the message.
15
+
16
+ Subject resolution (first match wins; no match refuses the invocation):
17
+
18
+ 1. The authenticated caller from ``RequestContext.call_context.user`` →
19
+ ``Subject(type="agent", id=<user_name>)`` — the A2A peer is typically itself an agent;
20
+ ``subject_type`` is a constructor knob for deployments that authenticate end users.
21
+ 2. A trusted subject placed in ``ServerCallContext.state["subject"]`` by the
22
+ deployment's ``ServerCallContextBuilder`` — per-request and threaded through the SDK.
23
+ The ambient contextvar seam the other adapters offer is deliberately **not** consulted
24
+ here: the executor runs inside the SDK's detached, long-lived producer task, where
25
+ contextvars are snapshotted at task creation and go stale across turns — a
26
+ cross-request identity leak waiting to happen.
27
+ 3. ``config.agent_id``, **only** when ``allow_static_subject=True`` (typed with
28
+ ``config.subject_type``, deliberately distinct from the constructor's ``subject_type``,
29
+ which types authenticated peers). Off by default: an unauthenticated network caller
30
+ must never be silently authorized as a static subject (a confused deputy). Opt in only
31
+ where the transport itself is trusted.
32
+
33
+ Host enrichment attributes (``conversation_id`` / ``user_id`` / ``correlation_id``) are
34
+ likewise read from ``ServerCallContext.state``, never from ambient context.
35
+
36
+ The AuthZEN tuple: action ``agent.invoke``; resource ``{type: "a2a_agent",
37
+ id: <agent_label>}``, or — when ``skill_resolver`` resolves a skill for the request —
38
+ ``{type: "a2a_skill", id: "<agent_label>/<skill>"}`` (segments validated like every other
39
+ policy key: non-empty, no embedded ``/``; the skill is kept verbatim). **The resolver must
40
+ derive the skill from the exact field the delegate dispatches on** — deriving it from
41
+ caller-controlled metadata would let the caller pick which policy key is evaluated while
42
+ the delegate runs something else (authorization bypass by key-shifting). The A2A ``tenant``
43
+ rides along in the AuthZEN ``context`` for multi-tenant policies, beside the standard
44
+ host-context attributes — note it is the *request's* tenant as resolved by the SDK
45
+ (protocol routing data), so policies should treat it as a claim to check against the
46
+ subject, not as proof by itself.
47
+
48
+ Verdict mapping is fail-closed: only a clean ``ALLOW`` reaches the wrapped executor.
49
+ ``BLOCK``, ``HUMAN_REVIEW`` and any error refuse by raising an A2A
50
+ ``InvalidRequestError`` whose text the client receives **verbatim** — so refusals are
51
+ deliberately generic and the rich verdict/reason stays in the operator decision log and
52
+ metrics. ``InvalidRequestError`` maps to JSON-RPC -32600 / HTTP 400 / gRPC
53
+ ``INVALID_ARGUMENT`` — semantically "invalid request" rather than "forbidden", but the
54
+ 1.x SDK ships no authorization-flavored error. Note the SDK persists a ``failed`` task
55
+ for **every** refused invocation (its producer failure path), including unauthenticated
56
+ ones — reject unauthenticated traffic in HTTP/authn middleware to cap task-store growth —
57
+ and a denial on a follow-up turn fails the whole in-flight task, not just that turn.
58
+
59
+ Enforcement scope: every invocation path that runs the executor (``message/send`` and
60
+ ``message/stream``, on JSON-RPC, REST and gRPC alike — all three transports share the
61
+ request handler). Task **reads** never touch the executor and are not gated here:
62
+ ``tasks/get``/``tasks/list``/``tasks/subscribe`` return or stream task content, and
63
+ push-notification-config CRUD is likewise open — gate those in HTTP/authn middleware.
64
+ ``cancel`` is passed through: the SDK cancels the producer task *before*
65
+ ``executor.cancel`` runs, so gating at this seam could not actually prevent
66
+ cancellation — that, too, belongs in HTTP/authn middleware.
67
+
68
+ Wiring::
69
+
70
+ from a2a.server.request_handlers import DefaultRequestHandler
71
+ from apparitor.a2a import A2AAuthorizationExecutor
72
+
73
+ guarded = A2AAuthorizationExecutor(
74
+ MyExecutor(), pdp_url="https://pdp.internal", agent_label="travel-agent"
75
+ )
76
+ handler = DefaultRequestHandler(agent_executor=guarded, task_store=..., agent_card=...)
77
+
78
+ The adapter needs only the base ``a2a-sdk``; serving over HTTP additionally needs the
79
+ SDK's ``[http-server]`` extra, as in any A2A deployment.
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import logging
85
+ from typing import TYPE_CHECKING, Any
86
+
87
+ from .decision import (
88
+ VerdictResult,
89
+ is_allowed_gateway,
90
+ record_pre_engine_refusal,
91
+ refusal_message,
92
+ validate_gateway_subject_config,
93
+ )
94
+ from .engine import ReviewPredicate, build_engine, resolve_config
95
+ from .errors import AuthZENConfigError, MissingDependencyError
96
+ from .mapping import request_context_attrs
97
+ from .models import Action, EvaluationRequest, Resource, Subject
98
+
99
+ try: # pragma: no cover - exercised via import-guard tests
100
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
101
+ from a2a.utils.errors import InvalidRequestError
102
+ except ImportError as exc: # pragma: no cover
103
+ raise MissingDependencyError(
104
+ "apparitor.a2a requires the A2A SDK. Install it with:\n pip install 'apparitor[a2a]'"
105
+ ) from exc
106
+
107
+ if TYPE_CHECKING:
108
+ from collections.abc import Callable
109
+
110
+ import httpx
111
+ from a2a.server.events import EventQueue
112
+
113
+ from .config import ScannerConfig
114
+ from .metrics import MetricsSink
115
+
116
+ logger = logging.getLogger("apparitor")
117
+
118
+
119
+ class A2AAuthorizationExecutor(AgentExecutor): # type: ignore[misc] # a2a-sdk may be absent in the lint env (base is Any)
120
+ """Authorizes A2A invocations against an AuthZEN PDP before the wrapped executor runs.
121
+
122
+ Construct it like the other adapters — a ``pdp_url`` or a full :class:`ScannerConfig` —
123
+ plus the executor to guard and the agent's stable policy label, then hand it to the
124
+ request handler in the executor's place. A subject must be resolvable (authenticated
125
+ peer, host-injected subject, or the opt-in static fallback) or the invocation is
126
+ refused.
127
+
128
+ ``boundary_subject`` is **deployment-time** only, never per-request. When set, every
129
+ invocation is evaluated as a two-request batch — the resolved caller leg AND the
130
+ boundary leg — using the same AND/all-allow-or-block semantics as
131
+ :class:`~apparitor.mapping.DualPrincipalMapper`. The A2A request's tenant context
132
+ governs both legs (Amendment 1). Set it when the serving agent's own permission
133
+ boundary must be enforced regardless of what the caller is permitted.
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ delegate: AgentExecutor,
139
+ pdp_url: str | None = None,
140
+ *,
141
+ config: ScannerConfig | None = None,
142
+ agent_label: str,
143
+ skill_resolver: Callable[[RequestContext], str | None] | None = None,
144
+ subject_type: str = "agent",
145
+ allow_static_subject: bool = False,
146
+ boundary_subject: Subject | None = None,
147
+ http_client: httpx.AsyncClient | None = None,
148
+ review_predicate: ReviewPredicate | None = None,
149
+ metrics: MetricsSink | None = None,
150
+ ) -> None:
151
+ # Resolve config first so the workload guards can check config.subject_type.
152
+ config = resolve_config(pdp_url, config)
153
+ validate_gateway_subject_config(
154
+ config,
155
+ subject_type=subject_type,
156
+ allow_static_subject=allow_static_subject,
157
+ boundary_subject=boundary_subject,
158
+ )
159
+ label = agent_label.strip()
160
+ if not label or "/" in label:
161
+ raise AuthZENConfigError("agent_label must be non-empty and contain no '/'")
162
+ self._delegate = delegate
163
+ self._config = config
164
+ self._agent_label = label
165
+ self._skill_resolver = skill_resolver
166
+ self._subject_type = subject_type
167
+ self._allow_static_subject = allow_static_subject
168
+ self._boundary_subject = boundary_subject
169
+ self._engine = build_engine(
170
+ config,
171
+ http_client=http_client,
172
+ review_predicate=review_predicate,
173
+ metrics=metrics,
174
+ )
175
+ logger.info(
176
+ "apparitor: A2A executor gating agent.invoke for %r%s",
177
+ label,
178
+ f"; boundary={boundary_subject.type}:{boundary_subject.id}"
179
+ if boundary_subject is not None
180
+ else "",
181
+ )
182
+
183
+ @property
184
+ def metrics(self) -> MetricsSink:
185
+ """The engine's metrics sink (latency histogram + cache-hit counter)."""
186
+ return self._engine.metrics
187
+
188
+ async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
189
+ """Authorize the invocation; only a clean ALLOW reaches the wrapped executor."""
190
+ try:
191
+ verdict = await self._authorize(context)
192
+ except Exception:
193
+ # Defense in depth: an adapter-level fault must refuse, never execute. The
194
+ # generic message is deliberate — exception text reaches the calling agent.
195
+ logger.exception("apparitor: A2A authorization executor error (refusing)")
196
+ record_pre_engine_refusal(self._engine.metrics)
197
+ raise InvalidRequestError(message=refusal_message("agent invocation", None)) from None
198
+ if verdict is not None and is_allowed_gateway(verdict):
199
+ await self._delegate.execute(context, event_queue)
200
+ return
201
+ if verdict is None:
202
+ # No resolvable subject: the engine never ran; count the refusal so an
203
+ # all-misconfigured fleet doesn't show zero decisions.
204
+ record_pre_engine_refusal(self._engine.metrics)
205
+ raise InvalidRequestError(message=refusal_message("agent invocation", verdict))
206
+
207
+ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
208
+ """Pass task cancellation through ungated (v1 — see module docstring)."""
209
+ await self._delegate.cancel(context, event_queue)
210
+
211
+ async def _authorize(self, context: RequestContext) -> VerdictResult | None:
212
+ """Evaluate the invocation; ``None`` refuses without a PDP trip (no subject or
213
+ no sound policy key). When boundary_subject is set the caller leg and boundary leg
214
+ are sent as one batch; AuthZENConfigError from the collapse guard logs a WARNING
215
+ and maps to the generic refusal path (reason in operator log only)."""
216
+ # Per-request data only: ServerCallContext.state is threaded through the SDK for
217
+ # this request; ambient contextvars would be stale here (see module docstring).
218
+ state: dict[str, Any] = dict(context.call_context.state) if context.call_context else {}
219
+ subject = self._resolve_subject(context, state)
220
+ if subject is None:
221
+ return None
222
+ resource = self._resource(context)
223
+ if resource is None:
224
+ return None
225
+ request = EvaluationRequest(
226
+ subject=subject,
227
+ action=Action(name="agent.invoke"),
228
+ resource=resource,
229
+ context=self._context_attrs(context, state),
230
+ )
231
+ return await self._engine.evaluate_with_boundary(request, self._boundary_subject)
232
+
233
+ def _resolve_subject(self, context: RequestContext, state: dict[str, Any]) -> Subject | None:
234
+ user = context.call_context.user if context.call_context else None
235
+ if user is not None and user.is_authenticated:
236
+ name = user.user_name
237
+ if isinstance(name, str) and name.strip():
238
+ # Stripped like agent_label: whitespace variants must not split policy keys.
239
+ return Subject(type=self._subject_type, id=name.strip())
240
+ # Authenticated but nameless is a broken authn integration — refuse rather
241
+ # than guess; never fall through to a weaker subject.
242
+ logger.warning("apparitor: authenticated A2A user has no user_name; refusing")
243
+ return None
244
+ injected = state.get("subject")
245
+ if isinstance(injected, Subject):
246
+ return injected
247
+ if self._allow_static_subject and self._config.agent_id is not None:
248
+ return Subject(type=self._config.subject_type, id=self._config.agent_id)
249
+ logger.warning(
250
+ "apparitor: no authenticated subject for A2A invocation; refusing (configure"
251
+ " server authentication, inject one via ServerCallContext.state['subject'],"
252
+ " or opt in to allow_static_subject)"
253
+ )
254
+ return None
255
+
256
+ def _resource(self, context: RequestContext) -> Resource | None:
257
+ if self._skill_resolver is None:
258
+ return Resource(type="a2a_agent", id=self._agent_label)
259
+ skill = self._skill_resolver(context)
260
+ if skill is None:
261
+ return Resource(type="a2a_agent", id=self._agent_label)
262
+ # The skill is kept verbatim (distinct skills must not alias one policy key);
263
+ # an empty or "/"-bearing skill cannot form an unambiguous key — refuse.
264
+ if not isinstance(skill, str) or not skill.strip() or "/" in skill:
265
+ logger.warning("apparitor: unusable skill id from skill_resolver; refusing")
266
+ return None
267
+ return Resource(type="a2a_skill", id=f"{self._agent_label}/{skill}")
268
+
269
+ def _context_attrs(
270
+ self, context: RequestContext, state: dict[str, Any]
271
+ ) -> dict[str, Any] | None:
272
+ attrs = request_context_attrs(state) or {}
273
+ tenant = context.call_context.tenant if context.call_context else ""
274
+ if tenant:
275
+ attrs["tenant"] = tenant
276
+ return attrs or None
277
+
278
+ async def aclose(self) -> None:
279
+ """Release the underlying PDP client (the host owns executor teardown).
280
+
281
+ Only closes a client this adapter created; a bring-your-own ``http_client`` is
282
+ left for the caller to manage.
283
+ """
284
+ await self._engine.aclose()
285
+
286
+ async def __aenter__(self) -> A2AAuthorizationExecutor:
287
+ return self
288
+
289
+ async def __aexit__(self, *exc: object) -> None:
290
+ await self.aclose()
291
+
292
+
293
+ __all__ = ["A2AAuthorizationExecutor"]
apparitor/adapters.py ADDED
@@ -0,0 +1,137 @@
1
+ """Provider-aware extraction of tool calls from LlamaFirewall messages.
2
+
3
+ ``Message.tool_calls`` is typed ``list[dict] | None`` and is **not** normalised by
4
+ LlamaFirewall — its shape depends entirely on the integrating framework:
5
+
6
+ * **OpenAI** ``{"id", "type": "function", "function": {"name", "arguments": "<JSON str>"}}``
7
+ (note: ``arguments`` is a JSON-encoded *string*).
8
+ * **Anthropic** ``{"type": "tool_use", "id", "name", "input": {...}}``
9
+ * **LangChain** ``{"name", "args": {...}, "id"}``
10
+
11
+ A naive ``tc["name"]`` silently fails on OpenAI. These adapters detect the shape and
12
+ normalise to :class:`NormalizedToolCall`. This module is pure (no I/O, no
13
+ LlamaFirewall import) and is implemented now because mis-extraction is an
14
+ authorization-bypass risk, not merely a bug.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Protocol, runtime_checkable
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class NormalizedToolCall:
26
+ """A tool call reduced to the fields authorization cares about."""
27
+
28
+ name: str
29
+ arguments: dict[str, Any] = field(default_factory=dict)
30
+ id: str | None = None
31
+
32
+
33
+ @runtime_checkable
34
+ class ToolCallAdapter(Protocol):
35
+ """Detects and normalises one provider's tool-call dict shape."""
36
+
37
+ def matches(self, raw: dict[str, Any]) -> bool:
38
+ """Return ``True`` if ``raw`` looks like this provider's shape."""
39
+ ...
40
+
41
+ def normalize(self, raw: dict[str, Any]) -> NormalizedToolCall:
42
+ """Convert ``raw`` to a :class:`NormalizedToolCall`. May raise ``ValueError``."""
43
+ ...
44
+
45
+
46
+ def _coerce_args(value: Any) -> dict[str, Any]:
47
+ """Coerce a tool-call argument payload into a dict.
48
+
49
+ OpenAI encodes arguments as a JSON string; everything else uses a dict. A
50
+ non-dict / unparseable payload raises ``ValueError`` so the caller can fail
51
+ closed rather than guess.
52
+ """
53
+ if value is None:
54
+ return {}
55
+ if isinstance(value, dict):
56
+ return value
57
+ if isinstance(value, str):
58
+ try:
59
+ parsed = json.loads(value)
60
+ except json.JSONDecodeError as exc:
61
+ raise ValueError(f"tool-call arguments are not valid JSON: {exc}") from exc
62
+ if not isinstance(parsed, dict):
63
+ raise ValueError("decoded tool-call arguments are not a JSON object")
64
+ return parsed
65
+ raise ValueError(f"unsupported tool-call arguments type: {type(value).__name__}")
66
+
67
+
68
+ class OpenAIToolCallAdapter:
69
+ """OpenAI / Azure OpenAI function-calling shape."""
70
+
71
+ def matches(self, raw: dict[str, Any]) -> bool:
72
+ return isinstance(raw.get("function"), dict) or raw.get("type") == "function"
73
+
74
+ def normalize(self, raw: dict[str, Any]) -> NormalizedToolCall:
75
+ fn = raw.get("function")
76
+ if not isinstance(fn, dict) or not fn.get("name"):
77
+ raise ValueError("OpenAI tool call missing function.name")
78
+ return NormalizedToolCall(
79
+ name=str(fn["name"]),
80
+ arguments=_coerce_args(fn.get("arguments")),
81
+ id=raw.get("id"),
82
+ )
83
+
84
+
85
+ class AnthropicToolCallAdapter:
86
+ """Anthropic raw ``tool_use`` block shape."""
87
+
88
+ def matches(self, raw: dict[str, Any]) -> bool:
89
+ return raw.get("type") == "tool_use"
90
+
91
+ def normalize(self, raw: dict[str, Any]) -> NormalizedToolCall:
92
+ if not raw.get("name"):
93
+ raise ValueError("Anthropic tool_use missing name")
94
+ return NormalizedToolCall(
95
+ name=str(raw["name"]),
96
+ arguments=_coerce_args(raw.get("input")),
97
+ id=raw.get("id"),
98
+ )
99
+
100
+
101
+ class LangChainToolCallAdapter:
102
+ """LangChain-normalised tool-call shape (``{name, args, id}``)."""
103
+
104
+ def matches(self, raw: dict[str, Any]) -> bool:
105
+ return "args" in raw and "name" in raw
106
+
107
+ def normalize(self, raw: dict[str, Any]) -> NormalizedToolCall:
108
+ if not raw.get("name"):
109
+ raise ValueError("LangChain tool call missing name")
110
+ return NormalizedToolCall(
111
+ name=str(raw["name"]),
112
+ arguments=_coerce_args(raw.get("args")),
113
+ id=raw.get("id"),
114
+ )
115
+
116
+
117
+ # Detection order matters: most specific shapes first.
118
+ _DEFAULT_ADAPTERS: tuple[ToolCallAdapter, ...] = (
119
+ AnthropicToolCallAdapter(),
120
+ OpenAIToolCallAdapter(),
121
+ LangChainToolCallAdapter(),
122
+ )
123
+
124
+
125
+ def detect_adapter(
126
+ raw: dict[str, Any],
127
+ adapters: tuple[ToolCallAdapter, ...] = _DEFAULT_ADAPTERS,
128
+ ) -> ToolCallAdapter | None:
129
+ """Return the first adapter whose ``matches`` accepts ``raw``, or ``None``.
130
+
131
+ A ``None`` result means the shape is unrecognised; per the threat model the
132
+ caller must fail closed (BLOCK), never skip.
133
+ """
134
+ for adapter in adapters:
135
+ if adapter.matches(raw):
136
+ return adapter
137
+ return None