DEV Community

Sanya
Sanya

Posted on • Originally published at github.com

agent-authz: Least-Privilege Authorization for AI Agent Tool Calls

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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:

  1. In-processToolCallGuard wraps execution for LangChain / AutoGen / CrewAI. Adapters throw PermissionDeniedError (deny) or NeedsApprovalError (ask) so the host framework's error path handles it natively.
  2. HTTP sidecar — a zero-dependency http.server exposes POST /authorize, so a Node/TS stack (DeepSeek Harness) bridges to the same Python engine over HTTP. No dual-language implementation drift.
  3. MCP gateway — a stdio transparent proxy that sits between an MCP client and MCP server, intercepting every JSON-RPC message: tools/list is filtered (agents never even see tools they can't use), tools/call is 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.)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • OCSF audit: every decision emits an OCSF-compatible event (category_uid=3, activity_id 1=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 behind to_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)