Vendor-neutral authorization for AI agents — checks agent tool calls against your policy engine (AuthZEN, Cedar, OpenFGA, Rego) from inside agentic firewalls. Answers "is this agent allowed to do this?"
Project description
apparitor
Your agents route around the authorization you already run. apparitor brings them back under it. Every agent action (an LLM tool call, an MCP request, an agent-to-agent invocation) is checked against the policy engine you already trust (OpenFGA, Cedar, OPA), before it executes. It answers the question content-safety layers never ask: is this agent allowed to do this? Vendor-neutral, built on the AuthZEN 1.0 interop standard, Apache-2.0.
The gap
Every safety layer in your stack inspects the content of an agent's action: is the prompt a jailbreak, is the generated code malicious. None asks the question your security model actually depends on. Is this agent allowed to do this, for this user, against this resource, right now?
The actions that matter most are the ones that look harmless. An agent reading a customer record is benign text; reading another tenant's record is a breach. No content scanner can tell them apart. The difference isn't in the text, it's in who is acting and what they're entitled to.
Agent for alice@acme → read_records(tenant="globex")
│
▼
Safety scanning → "Is this prompt malicious?" → PASS (benign request)
│
▼
??? nothing ??? → "May alice@acme read globex's records?" → NO CHECK
│
▼
Tool executes. Cross-tenant data returned.
That missing hop is an authorization decision, and you almost certainly run an engine
that makes them already. It just isn't wired to the point where the agent acts. apparitor
is that wiring: it routes each agent action to a policy decision point (PDP) and maps the
verdict onto the enforcement point's ALLOW / BLOCK / HUMAN_IN_THE_LOOP model.
Agent for alice@acme → read_records(tenant="globex")
│
▼
Safety scanning (PromptGuard, AlignmentCheck, CodeShield, …) → PASS
│
▼
apparitor ──────────POST /access/v1/evaluation──────▶ Policy engine (OpenFGA / Cedar / OPA / …)
│ │
│ ◀────────────────── { "decision": false } ────────┘
▼
BLOCK: "alice@acme is not authorized to call read_records for tenant globex"
Why not just write the check yourself?
The naive version, if allowed: run(), is a security bug in four ways apparitor exists
to handle:
- The subject is a confused-deputy trap. The firewall layer sees model output, not an authenticated principal. Infer who is acting from the tool call and the agent can name its own privileged subject. apparitor takes the subject from the host, request-scoped (at the MCP boundary, from the validated OAuth token), never from model output. See Identity.
- The default failure is fail-open. A timed-out PDP, a
5xx, a missing or non-booleandecision, an unparseable call: each is a falsyallowedyourifwaves through. apparitor resolves every one to BLOCK or human review; there is no fail-open option. See Fail-closed by default. - You would write it four times. The check belongs at the firewall, the MCP server, and the agent-to-agent boundary, each with different objects and different identity sources. apparitor is one engine behind four adapters.
- The agent should be more constrained than its user. A jailbroken agent acting for a privileged user must not borrow that user's rights. apparitor can evaluate both the user's grant and the agent's own permission boundary, proceeding only when both allow. This is a separately-audited control that holds across engines. See Level 2.
And you write no new policy: it stays in the engine your org already authors policy in, audited where the rest of your authorization lives.
Four enforcement points, one engine. The check runs wherever your stack lets you intercept the action: inside an agentic firewall (as a LlamaFirewall scanner or a NeMo Guardrails rail), at the MCP boundary as FastMCP server middleware, or at the agent-to-agent boundary as an A2A executor. Same engine, same fail-closed semantics everywhere; only the boundary differs.
One integration, many policy engines. apparitor speaks the AuthZEN 1.0 interop standard, so the same wiring reaches the engines you already author policy in: OpenFGA (Zanzibar / ReBAC, experimental), Cedar (policy-as-code), and OPA / Rego, with no policy rewrite. OPA and Cedar also have native backends that skip the AuthZEN hop.
Status:
0.1.0, beta. Shipping today: all four enforcement points above and the AuthZEN evaluation pipeline, working end-to-end against any AuthZEN 1.0 PDP (OpenFGA, Cedar, OPA, Cerbos, Topaz) plus native OPA and in-process Cedar backends, with ≥90% test coverage (CI-enforced) on the adapter-free core (seeCHANGELOG). Fail-closed on every error path, subject isolation, and an SSRF-guarded transport are tested invariants. An internal adversarial security review (six findings, all fixed) is documented indocs/security-review.md, and an independent external review is an adoption-gated goal, not yet done. Solo-maintained, best-effort cadence. On the roadmap: a native OpenFGA backend. Seedocs/requirements.mdfor the design andROADMAP. APIs may change.
Installation
pip install apparitor # AuthZEN client + models, no firewall dependency
pip install "apparitor[llamafirewall]" # LlamaFirewall scanner (pulls torch / ML stack)
pip install "apparitor[nemo]" # NeMo Guardrails rail
pip install "apparitor[fastmcp]" # FastMCP server middleware
pip install "apparitor[a2a]" # A2A agent-executor adapter
pip install "apparitor[cedar]" # in-process Cedar backend (cedarpy, no server)
Note:
[llamafirewall]pulls LlamaFirewall's ML dependencies (torch, PromptGuard). The bare install and all other extras work without it.
Quickstart
Pick the enforcement point your stack already has; the engine behind each is the same.
Inside LlamaFirewall: point the scanner at any AuthZEN-compliant policy decision point (PDP) and bind it to the assistant role, so it gates tool calls before they are dispatched. Tool calls in OpenAI, Anthropic, and LangChain shapes are detected and normalised automatically; an unrecognised shape blocks (fail closed):
from llamafirewall import LlamaFirewall, Role
from apparitor import AuthZENScanner, ScannerConfig
# Point at any AuthZEN-compliant PDP. Secure defaults: fail-closed, TLS-verified.
# A subject must be resolvable: set config.agent_id, or current_subject per request.
scanner = AuthZENScanner(config=ScannerConfig(pdp_url="https://pdp.internal", agent_id="travel-bot"))
firewall = LlamaFirewall(scanners={Role.ASSISTANT: [scanner]})
result = await firewall.scan_async(assistant_message) # ALLOW / BLOCK / HUMAN_IN_THE_LOOP
Per request, supply the real end user the agent acts for (recommended over a static
agent_id). See Identity: who the agent acts for.
As a NeMo Guardrails rail, the identical check registers as a custom action
(pip install "apparitor[nemo]"). The host passes the agent's proposed tool calls into
the flow as $tool_calls; the action returns an allowed boolean that fails closed
under NeMo's mapping, and the rail refuses denied calls. The full verdict is surfaced in
the rails context for host-built escalation; see the apparitor.nemo module docs for
the flow wiring:
from nemoguardrails import LLMRails, RailsConfig
from apparitor.nemo import NeMoAuthorizationRails
rails = LLMRails(RailsConfig.from_path("config"))
NeMoAuthorizationRails(pdp_url="https://pdp.internal").register(rails)
At the MCP boundary, the same engine runs server-side, before any tool executes.
The subject is the validated OAuth identity of the caller (the token's sub),
not a host-asserted value (pip install "apparitor[fastmcp]"):
from fastmcp import FastMCP
from apparitor.fastmcp import FastMCPAuthorizationMiddleware
server = FastMCP("files", auth=my_token_verifier) # auth supplies the validated identity
server.add_middleware(FastMCPAuthorizationMiddleware(pdp_url="https://pdp.internal"))
Register the middleware after any custom auth middleware (so the token is populated).
It gates tools/call, resources/read (action resource.read), and prompts/get
(action prompt.get) by default (gate_resources/gate_prompts opt a hook out), and
can additionally hide unauthorized tools from tools/list with filter_listings=True
(advisory; tools/call remains the enforcement invariant). Client-credentials tokens can
be authorized as distinct workload subjects via allow_workload_subject=True. Under
server composition pin server_label for stable policy keys. FastMCP never tears
middleware down, so call await middleware.aclose() on shutdown to release the PDP client.
For a vendor MCP server you cannot modify, front it with a thin proxy you own and put the
middleware on the proxy. See examples/gateway/.
At the A2A boundary, the same engine guards agent-to-agent invocations. The subject
is the authenticated peer the A2A server established, and the request's tenant is
forwarded to policies as a claim to cross-check (pip install "apparitor[a2a]"):
from apparitor.a2a import A2AAuthorizationExecutor
guarded = A2AAuthorizationExecutor(
my_executor, pdp_url="https://pdp.internal", agent_label="travel-agent"
)
# hand `guarded` to DefaultRequestHandler in your executor's place
The AuthZEN client and models are adapter-free and usable on their own:
from apparitor.models import EvaluationRequest # no firewall dependency needed
Identity: who the agent acts for
Every decision needs a subject: the principal your policy is written against. apparitor never infers it from model or tool output (that would be a confused deputy); the host supplies it, request-scoped, because the firewall layer sees messages, not an authenticated principal. There is a maturity ladder of three levels, and the same seam feeds every mapper-driven adapter (the LlamaFirewall scanner, the NeMo rail, the FastMCP middleware). At the MCP boundary the middleware fills this seam itself from the validated OAuth token; see the note below.
Level 0, a static agent identity. Set agent_id; every call is authorized as that
agent. Enough for policies that don't depend on the end user, such as "no agent may call a
destructive tool":
scanner = AuthZENScanner(config=ScannerConfig(pdp_url="https://pdp.internal", agent_id="travel-bot"))
Level 1, the real end user, per request (recommended). Where your host already
authenticated the user, bind it for the agent run with subject_scope. It resets the value
on exit, so a subject can never leak to a later request that reuses the same task/event loop:
from apparitor import Subject, subject_scope
with subject_scope(Subject(type="user", id="alice@acme.com")):
result = await firewall.scan_async(assistant_message)
Level 2, the agentic permission boundary (user ∧ agent). Use Level 1 by default;
add Level 2 when the agent's privileges must be narrower than its user's. The
DualPrincipalMapper evaluates two decisions per call (the end user's grant and
the agent's own boundary), and the call proceeds only when both allow. That is the
evaluation semantics of a permission boundary: at every mapper-gated call, the agent can
never exercise a permission its boundary denies, even when the human holds it. The A2A
executor and the FastMCP resources/read / prompts/get paths use boundary_subject
instead (the mapper seam does not reach them); for FastMCP tools and listing use
mapper=DualPrincipalMapper(config). A full deployment sets both:
from apparitor import DualPrincipalMapper, ScannerConfig
config = ScannerConfig(pdp_url="https://pdp.internal", agent_id="travel-bot")
scanner = AuthZENScanner(config=config, mapper=DualPrincipalMapper(config))
# per request: subject_scope(user) supplies the user leg; "travel-bot" is the boundary
Unlike an in-policy forbid (the three-peps demo's deny-override,
which works when one PDP holds all your policy), the dual mapper makes the boundary a
separate, separately-audited decision that works across engines and policy stores.
Cost: two decisions per call, sent as one batched PDP round trip (the native OPA backend
fans a batch out as one Data API query per leg). When boundary_subject is used instead
of the mapper (A2A agent.invoke, FastMCP resources/read, prompts/get), that is two
decisions per gated invoke / resources-read / prompts-get, sent as one batched PDP round
trip (and the ALLOW cache is not consulted).
With neither a request-context subject nor current_subject set and no agent_id, the
scan fails closed. Request-scoped attributes (user_id, conversation_id, …) can ride
along as AuthZEN context for policy conditions. See
docs/setup.md for the full resolution order
and a request-context example.
Enforcement points that carry a validated identity of their own populate this same subject seam: the FastMCP middleware reads the verified OAuth token's
subclaim and it outranks any host-asserted subject. A token is never silently downgraded. A token without a usable claim refuses the call, and the staticagent_idfallback requires an explicitallow_static_subject=Trueopt-in (local/stdio only).
Fail-closed by default
Every path that cannot produce a clean ALLOW refuses: an unreachable or timed-out PDP,
a malformed response (strict validation, where a missing or non-boolean decision is an
error, never a coerced allow), a missing subject, an unparseable tool call. There is no
fail-open option: a PDP failure resolves per on_error to deny (the default) or
human_review. PDP URLs must be HTTPS and pass an SSRF guard, with TLS verified and
redirects never followed. The only opt-out is the explicit allow_insecure_pdp flag,
intended for local development; retries are bounded within a per-request wall-clock
budget. A
review_predicate over the PDP's response context can escalate a decision to
HUMAN_IN_THE_LOOP, never downgrade one (advisory response context exists only on the
AuthZEN backend; the native OPA and Cedar backends return plain booleans).
Decision caching is off by default. When enabled it caches ALLOW decisions only, keyed by a digest of the full request tuple (arguments included), with a short TTL that is clamped, never extended. See docs/requirements.md for the full failure-handling and caching design.
Observability
Every decision is timed and counted. The scanner (and the standalone AuthorizationEngine)
exposes a metrics sink, by default an in-process InMemoryMetrics with a latency
histogram and decision/cache counters:
m = scanner.metrics # InMemoryMetrics by default
m.latency_histogram() # [(le_seconds, cumulative_count), …, (+Inf, n)]
m.decisions # {("allow", "success"): 12, ("block", "error"): 1}
m.cache_hits, m.cache_misses # cache effectiveness (single-call decisions)
To export, pass your own MetricsSink (forward to Prometheus/OpenTelemetry) or
NoopMetrics() to disable. The default InMemoryMetrics is lock-free and meant for
single-event-loop use; a long-lived server scraping it from another thread (or a sink shared
across threads) must provide its own synchronisation by passing a thread-safe MetricsSink.
Each decision also emits one structured audit log line (verdict,
status, subject id, correlation id, resource ids, and an argument fingerprint). Raw tool
arguments and tokens are never logged; arguments are fingerprinted. The subject id is the
decision principal (with the FastMCP middleware that is the OAuth sub, which may be an
email), so treat the apparitor logger as sensitive and route it accordingly. The log
format is a documented stability contract from 0.1.0. See
docs/audit-log.md.
What apparitor connects
Enforcement points (the agent-side hooks apparitor plugs into):
| Enforcement point | Vendor | Status |
|---|---|---|
| LlamaFirewall | Meta | shipping (AuthZENScanner) |
| NeMo Guardrails | NVIDIA | shipping (NeMoAuthorizationRails) |
| FastMCP server middleware | Prefect | shipping (FastMCPAuthorizationMiddleware) |
| A2A agent executor | Linux Foundation | shipping (A2AAuthorizationExecutor) |
Policy engines (where the authorization decision is made). apparitor reaches these over
AuthZEN; OPA and Cedar also have native backends that skip the AuthZEN hop, selected by
config (backend="opa" / backend="cedar"):
| Engine | Paradigm | How apparitor reaches it | Example |
|---|---|---|---|
| Mock PDP (testing/demo) | n/a | AuthZEN | examples/mock_pdp/ |
| OpenFGA | Zanzibar / ReBAC | native AuthZEN (experimental) | examples/openfga/ |
| Cedar | policy-as-code (ABAC) | AuthZEN gateway · native in-process (backend="cedar") |
examples/cedar/ |
| OPA / Rego | policy-as-code | AuthZEN gateway · native Data API (backend="opa") |
examples/opa/ |
| Amazon Verified Permissions | managed Cedar | AWS AuthZEN interface | examples/avp/ |
| Any AuthZEN 1.0 PDP (Cerbos, Topaz, …) | varies | AuthZEN | docs/setup.md |
Documentation
- Technical requirements & design decisions
- Architecture
- Setup: connecting to a policy engine
- EU AI Act / CADA compliance reference
- Roadmap
- Contributing · Security policy · Changelog
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file apparitor-0.1.1.tar.gz.
File metadata
- Download URL: apparitor-0.1.1.tar.gz
- Upload date:
- Size: 182.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2b97afd53839e8886defcc89d4297374f0f36765bcd4ff279abbe7917b06e60
|
|
| MD5 |
ff99a4209f1a3be5ecef9860bc71f1bc
|
|
| BLAKE2b-256 |
bca1cda5f0ff0b3932726e5f038226406bce4510a8617001e61e3eaa1db61eb9
|
Provenance
The following attestation bundles were made for apparitor-0.1.1.tar.gz:
Publisher:
release.yml on jhawlwut/apparitor
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
apparitor-0.1.1.tar.gz -
Subject digest:
f2b97afd53839e8886defcc89d4297374f0f36765bcd4ff279abbe7917b06e60 - Sigstore transparency entry: 1841431679
- Sigstore integration time:
-
Permalink:
jhawlwut/apparitor@d951b49e4479e36e79ca49f1de0276beb6c0186c -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/jhawlwut
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d951b49e4479e36e79ca49f1de0276beb6c0186c -
Trigger Event:
push
-
Statement type:
File details
Details for the file apparitor-0.1.1-py3-none-any.whl.
File metadata
- Download URL: apparitor-0.1.1-py3-none-any.whl
- Upload date:
- Size: 71.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ddb082e2953ee6694f7158ad9f02179c396c453eca5199aca4d9d9b0cd715143
|
|
| MD5 |
a8fc1473e78887965fde1d6b2fa76cc2
|
|
| BLAKE2b-256 |
63865776ded053463ea74111da857610ae516572a4f22419322c596113835a2c
|
Provenance
The following attestation bundles were made for apparitor-0.1.1-py3-none-any.whl:
Publisher:
release.yml on jhawlwut/apparitor
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
apparitor-0.1.1-py3-none-any.whl -
Subject digest:
ddb082e2953ee6694f7158ad9f02179c396c453eca5199aca4d9d9b0cd715143 - Sigstore transparency entry: 1841432659
- Sigstore integration time:
-
Permalink:
jhawlwut/apparitor@d951b49e4479e36e79ca49f1de0276beb6c0186c -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/jhawlwut
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d951b49e4479e36e79ca49f1de0276beb6c0186c -
Trigger Event:
push
-
Statement type: