OPA is general. SPIFFE answers "who." Neither answers the question every agent deployment eventually hits: "is this agent allowed to call this tool with these arguments — right now?"
agent-authz is a small, auditable Python policy engine built for exactly that question. It is not a general policy engine, and that's the point.
The gap
Agent frameworks (LangChain, AutoGen, CrewAI, DeepSeek Harness, MCP servers) trust tool calls by default. A prompt-injected agent — or simply an over-eager one — can call any tool with any arguments.
Meanwhile the identity/authorization toolbox has a hole in the middle:
| Layer | Answers | Doesn't answer |
|---|---|---|
| SPIFFE/SPIRE | "who is this workload?" | "which tools may it call?" |
| OPA | "any policy you can write in Rego" | "tool calls, as a first-class concept" |
Framework allowlist
|
"which tool names?" | "with what argument scope?" |
agent-authz fills the middle. Four design choices carry the whole thing.
1. Three-state decisions: allow / deny / ask
OPA is binary. Real agent operations need a third state — human-in-the-loop:
-
allow— in scope, let it through. -
deny— fail-closed. Unknown agent, unknown tool, out-of-scope args → deny. Explicit deny always wins. -
ask— "this hits the customers table" isn't forbidden, but a human should sign off.
The matching order is deliberately ask → allow → action → default deny. The decision maps cleanly onto existing approval surfaces (DSH's ctx.approval, LangChain human tools, MCP's approval flow).
from agent_authz import PolicyEngine, load_policy
engine = PolicyEngine(load_policy("policy.yaml"))
result = engine.evaluate(
agent_id="billing-agent",
tool="sql_query",
args_text="SELECT * FROM customers",
)
result.decision # Decision.ASK → route to human approval
2. YAML, not Rego — but with semantic constraints
The base layer is declarative YAML with glob arg-scoping:
version: 1
default: deny
agents:
- id: "billing-agent"
spiffe_id: "spiffe://acme.com/agents/billing"
tools:
- name: "read_file"
allow: "src/**"
- name: "sql_query"
allow: "SELECT * FROM billing**"
ask: "SELECT **FROM customers**"
- name: "bash"
action: deny
But glob can only pattern-match strings. It can't express "only SELECT, only the billing table, at most 100 rows." So agent-authz adds structured constraints on top of glob — semantic narrowing:
- name: "sql_query"
allow: "**"
constraints:
sql:
verbs: ["SELECT"] # only SELECT
tables: ["billing"] # only this table
max_rows: 100 # no LIMIT, or LIMIT > 100 → deny
- name: "http_request"
allow: "**"
constraints:
http:
methods: ["GET"]
hosts: ["api.acme.com", "*.internal"]
path_prefixes: ["/v1/"]
schemes: ["https"]
Constraint violations force DENY and land in result.violations for audit. Constraint types are a registry — SQL and HTTP ship today, file/other narrowers plug in trivially.
3. Framework-agnostic core, three deployment shapes
The engine is pure Python (stdlib + PyYAML). It deploys three ways:
-
In-process —
ToolCallGuardwraps execution for LangChain / AutoGen / CrewAI. Adapters throwPermissionDeniedError(deny) orNeedsApprovalError(ask) so the host framework's error path handles it natively. -
HTTP sidecar — a zero-dependency
http.serverexposesPOST /authorize, so a Node/TS stack (DeepSeek Harness) bridges to the same Python engine over HTTP. No dual-language implementation drift. -
MCP gateway — a stdio transparent proxy that sits between an MCP client and MCP server, intercepting every JSON-RPC message:
tools/listis filtered (agents never even see tools they can't use),tools/callis authorized, the rest passes through.
Agent Framework (LangChain / AutoGen / CrewAI / DSH / MCP)
│ tool call (agent_id, tool, args)
▼
┌──────────────────────────────────┐
│ agent-authz Engine │ ← YAML policy + glob + constraints
│ allow / deny / ask │
└──────────────────────────────────┘
│ decision + OCSF audit event
▼
SIEM (Splunk ES, etc.)
4. Identity-first, with audit out of the box
-
SPIFFE identity: policy keys off
spiffe_id. Beyond string parsing, it ships an issuer that mints stable, spec-compliant SPIFFE IDs per agent and binds them into policy — the "AI Identity governance" pattern Okta is pushing, where machine identities are first-class citizens. And a SPIRE client (spire_client.py) that fetches the live X.509-SVID from the Workload API, with a swappable fetcher so it stays zero-hard-dependency.
from agent_authz import SpiffeIssuer, load_policy, bind_to_policy
policy = load_policy("policy.yaml")
issuer = SpiffeIssuer("acme.com")
ident = issuer.issue("billing-agent") # spiffe://acme.com/agent/billing-agent
bind_to_policy(ident, policy) # identity → permission binding
-
OCSF audit: every decision emits an OCSF-compatible event (
category_uid=3,activity_id1=Allow / 2=Deny / 3=Pending), streamable straight into a SIEM. On top of OCSF sits a NOOA namespace adapter — because as of this writing NVIDIA's NOOA framework hasn't published a finalized audit schema, the mapping is isolated behindto_nooa()so the engine core never changes when the standard lands.
What it is not
- Not a replacement for SPIRE — it trusts SPIRE-verified identity and never forges SVIDs.
- Not a general policy engine — if you need arbitrary Rego over arbitrary data, use OPA.
- Not a sandbox — it's a decision layer; actual execution isolation belongs to the runtime.
Why it matters
The sharpest question in agent security isn't what can an agent do, but what is it actually allowed to do, on every single call — with a trail. agent-authz is that layer: 94 tests, zero heavy dependencies, three deployment shapes, identity and audit built in.
Apache-2.0. Try it: pip install -e ., then python -m agent_authz.cli validate -p examples/policy.yaml.
Top comments (0)