Skip to main content

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

CI OpenSSF Scorecard CodeQL pip-audit Coverage License: Apache 2.0 Python

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-boolean decision, an unparseable call: each is a falsy allowed your if waves 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 (see CHANGELOG). 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 in docs/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. See docs/requirements.md for the design and ROADMAP. 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 sub claim and it outranks any host-asserted subject. A token is never silently downgraded. A token without a usable claim refuses the call, and the static agent_id fallback requires an explicit allow_static_subject=True opt-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

License

Apache License 2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

apparitor-0.1.1.tar.gz (182.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

apparitor-0.1.1-py3-none-any.whl (71.7 kB view details)

Uploaded Python 3

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

Hashes for apparitor-0.1.1.tar.gz
Algorithm Hash digest
SHA256 f2b97afd53839e8886defcc89d4297374f0f36765bcd4ff279abbe7917b06e60
MD5 ff99a4209f1a3be5ecef9860bc71f1bc
BLAKE2b-256 bca1cda5f0ff0b3932726e5f038226406bce4510a8617001e61e3eaa1db61eb9

See more details on using hashes here.

Provenance

The following attestation bundles were made for apparitor-0.1.1.tar.gz:

Publisher: release.yml on jhawlwut/apparitor

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for apparitor-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ddb082e2953ee6694f7158ad9f02179c396c453eca5199aca4d9d9b0cd715143
MD5 a8fc1473e78887965fde1d6b2fa76cc2
BLAKE2b-256 63865776ded053463ea74111da857610ae516572a4f22419322c596113835a2c

See more details on using hashes here.

Provenance

The following attestation bundles were made for apparitor-0.1.1-py3-none-any.whl:

Publisher: release.yml on jhawlwut/apparitor

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page