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 +172 -0
- apparitor/a2a.py +293 -0
- apparitor/adapters.py +137 -0
- apparitor/backends.py +153 -0
- apparitor/cache.py +82 -0
- apparitor/cedar.py +158 -0
- apparitor/client.py +270 -0
- apparitor/config.py +117 -0
- apparitor/decision.py +205 -0
- apparitor/engine.py +548 -0
- apparitor/errors.py +70 -0
- apparitor/fastmcp.py +487 -0
- apparitor/mapping.py +381 -0
- apparitor/metrics.py +99 -0
- apparitor/models.py +163 -0
- apparitor/nemo.py +204 -0
- apparitor/py.typed +0 -0
- apparitor/scanner.py +136 -0
- apparitor-0.1.1.dist-info/METADATA +409 -0
- apparitor-0.1.1.dist-info/RECORD +22 -0
- apparitor-0.1.1.dist-info/WHEEL +4 -0
- apparitor-0.1.1.dist-info/licenses/LICENSE +201 -0
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
|