Skip to main content

Policy-as-Code for Agents: OPA, Rego, and the Decision Point Your Tool Loop Doesn't Have

· 12 min read
Tian Pan
Software Engineer

The first time a regulator asks you to prove that your support agent did not access a Tier-2 customer's billing record on March 14th, you discover an unpleasant truth about your authorization architecture: the system prompt said "do not access billing for Tier-2," the YAML tool manifest said tools: [search_orders, refund_order, get_billing], and somewhere in between, the model decided. There is no record of a decision, because no decision point existed. Whether the agent did the right thing is not auditable, only inferable from logs of what happened.

This is the part of agent engineering that nobody put on the architecture diagram. Tool permissions today still live in a YAML file edited by whoever spawned the agent, surfaced to the model through a system prompt that describes intent, and enforced — when it is enforced at all — by application code wrapping each tool call in an if user.tier == "premium" check. As tool catalogs cross fifty entries and conditions multiply across tenants, data classes, and user roles, that hand-rolled lattice stops scaling, and the system prompt stops being a reliable enforcement surface. The model is not your authorization layer, even when it acts like one.

What is replacing it is policy-as-code: a dedicated policy engine — OPA with Rego, AWS Cedar, or a similar declarative tool — sitting in front of every tool call as a Policy Decision Point. The engine answers a single question per call: given this principal, this tool, these arguments, this context, is the action allowed? The agent runtime never gets to vote. This post is about what that architecture looks like in practice, and the four problems it solves that no amount of prompt engineering can.

The Prompt Was Never the Authorization Layer

Treating the system prompt as policy expression is seductive because it feels declarative — you write down the rules, the model follows them, end of story. The failure mode is that the prompt is interpreted, not enforced. A sufficiently tricky input, a tool description that subtly hints at capability, or a goal that conflicts with the rule, and the model's "judgment" produces a policy violation that looks identical in the transcript to a legitimate action. The OWASP Top 10 for Agentic Applications for 2026 names this the Least Agency failure: the question stopped being only what an agent can access and started being how much freedom it has to act on that access without a checkpoint.

YAML manifests are no better. They scale until you need a condition. The moment "this agent can call refund_order" becomes "this agent can call refund_order if the order belongs to the principal's tenant, the order is younger than thirty days, the amount is under $500, and the principal has the refunds:write scope," YAML stops being expressive enough and you start scattering checks across tool wrappers. Six months later, a new compliance requirement lands and you cannot find every place the rule needs to change.

The Policy Decision Point pattern, well-known from XACML and from cloud IAM, externalizes that logic. Application code becomes a Policy Enforcement Point: it takes the inputs, asks the PDP, and acts on the answer. The policy itself lives in a separate file, version-controlled, reviewed by security, and runnable as a unit test. The agent runtime calls into the PEP just like any other service does. The model has no opinion in the loop because the model is not asked.

OPA, Rego, and Cedar: The Engine Choices

Three policy engines have shown up in the agent runtime conversation, and the differences between them are starting to matter.

Open Policy Agent (OPA) is the CNCF-graduated general-purpose engine, with Rego as its policy language. Rego is declarative, supports rich data joins (you can reason over the entire request context plus external data loaded via OPAL), and has a mature ecosystem — sidecars, Wasm bundles, language SDKs. The cost is Rego's learning curve: it is closer to Datalog than to YAML, and policies that look simple in English can take a real session to express. For agent runtimes, OPA-as-sidecar fronting the tool gateway is the most-cited pattern: the gateway calls OPA before invoking the tool, and OPA answers in well under a millisecond.

AWS Cedar is the newer entrant, an open-source language designed for authorization policies with a deliberately constrained grammar. Cedar's design choices — default-deny, forbid-wins-over-permit, order-independent evaluation, no side effects — make policies easier to reason about compositionally. AWS shipped Cedar as the policy engine inside Amazon Bedrock AgentCore Policy in March 2026, where it intercepts every agent-tool call at the gateway boundary and authorizes against a policy set that can be authored either directly in Cedar or generated from natural-language statements that get formalized into Cedar. The natural-language path is interesting because it acknowledges that writing policy is the hard part, not running it.

Cerbos and similar PDP services are the YAML-policy alternative for teams that don't want to learn a new language. Policies are defined in YAML, evaluation is sub-millisecond, and the integration story is HTTP. The tradeoff is expressiveness: complex policies that require joining external data are easier in Rego than in Cerbos's model.

For most agent runtimes, the engine matters less than the architectural commitment: every tool call gets a PDP decision before it executes. Treat the PDP as you would a database — pick one, run it as infrastructure, give it a clear contract, and stop hand-rolling the equivalent in TypeScript.

Capability Tokens Replace YAML Grants

Even with a PDP in place, the what does this agent currently have permission to do question still needs a representation. The pattern that has settled out — and that the IETF draft on Transaction Tokens for Agents is now formalizing — is short-lived capability tokens, derived per task from a longer-lived user session via OAuth 2.0 Token Exchange (RFC 8693).

The shape: a user logs in and gets a session token with a TTL of hours. When that user kicks off an agent task, the agent runtime exchanges the session token for a capability token scoped narrowly to the task — specific tools, specific resource scopes, specific tenant — with a TTL measured in minutes and explicitly non-refreshable. If the agent needs a new capability mid-task, it goes back to the authorization server, not back to the YAML file.

Two structural details make this work.

The Transaction Token draft separates principal (the human or upstream system that initiated the task) from actor (the agent currently performing the action). Every policy decision can be made against both: "this agent may call this tool, but only if the principal has the underlying right to the data." Confused-deputy attacks, where an agent gets prompt-injected into using its own permissions on behalf of a third party, get harder to express because the policy can require principal/actor agreement.

The second detail is that capability tokens scope down, never up. When agent A spawns agent B, the token A passes to B is derived from A's token at the authorization server, with scopes equal to or narrower than A's. Parent agents cannot grant child agents capabilities they don't hold themselves; the authorization server enforces this at exchange time, not in application logic, which is the only way it stays correct as the codebase evolves.

The result is that "what can this agent do right now" stops being a question you answer by reading a YAML file. It is whatever the current capability token says, and that token expires faster than most agent-induced incidents take to develop.

"Model Refused" and "Policy Refused" Are Different Sentences

In a typical agent transcript, two outcomes are indistinguishable: the model said no because its training told it to, and the policy said no because the rule denied it. Both render as "I cannot help with that" in the conversation, and both look like compliance from the user's side. From the security side, they mean opposite things.

A model refusal tells you the model's safety post-training thinks the request is risky. It is not authoritative for compliance purposes — a different prompt, a different model version, or a jailbreak might produce a different answer. A policy refusal is authoritative: the policy engine evaluated the request and denied it, which is exactly the evidence a regulator wants to see. If you cannot distinguish the two in your logs, you cannot answer the question "did our governance policy work" with anything stronger than "the model usually behaves."

The fix is to log both, separately and structurally. Every PDP call emits a decision log: who, what tool, what arguments (redacted appropriately), what policy version, what decision, what reason. Those logs stream into your SIEM. Model refusals also get logged but as a different event class. A spike in model refusals on a particular tool is a prompt-probing signal worth investigating; a spike in policy denials is a misconfigured agent or a real attempted policy violation, depending on the principal pattern.

This is also where structured logging earns its keep. The audit record needs to be parseable: agent identity, principal identity, tool name, data class, operation type, policy decision, policy version, timestamp — all as discrete fields, not free text buried in a model output. SIEM anomaly detection runs against those fields. The same record that satisfies an evidence request from a regulator is also the record that pages security when an agent starts probing scopes outside its allowed surface.

Identity Propagation Across the Tool Loop

The principal-propagation problem is the part that breaks naive implementations the fastest. Agent A, running on behalf of user U, calls tool T1 (which is itself implemented by a downstream service). T1's authorization layer needs to know: who is asking? The agent's service identity? User U? Both?

The wrong answer — the one most early agent stacks ship with — is that agents run as a service account. Every tool call is authorized as the agent, the agent has the union of all permissions any user might need, and the audit trail records "agent did the thing" with no link back to a human principal. This is the service-account footgun: it works until your first cross-tenant data leak, at which point the lack of principal binding turns a single bug into a fleet incident.

The right answer is dual-identity propagation. The capability token carries both principal (user U) and actor (agent A). Every downstream tool call propagates both. The PDP at the tool boundary makes its decision against both: the agent must be allowed to call the tool, and the principal must be allowed to access the resource the call touches. If the agent spawns a sub-agent B, the chain continues — the token sent to B records U as principal, A as the immediate actor delegating, and B as the new actor for the next hop. SentinelAgent and the OAuth Transaction Tokens draft are converging on this shape because no other shape supports the question "whose authorization chain led to this action?" after the fact.

For practical engineering, this means three things. Agent runtimes need to mint and forward tokens, not API keys. Tool servers need to read both fields and pass them to the PDP. Policy authors need to write policies that reference both — principal.tenant == resource.tenant && actor in agent_class.support rather than just agent_class.support. None of this is conceptually hard; all of it is architecturally invasive enough that retrofitting it onto a shipped agent stack is a quarter of work.

What This Looks Like in a Diagram

The settled architecture is starting to look like this. A user-initiated request enters the agent runtime carrying a session token. The runtime exchanges that token for a task-scoped capability token at the authorization server. The agent loop runs; before each tool call, the runtime calls the PDP (OPA, Cedar, or equivalent) with the principal, actor, tool, and arguments. The PDP returns allow/deny plus a reason; allow proceeds, deny short-circuits with a structured policy-refusal event in the log. The tool call carries the capability token to the downstream service, which has its own PEP that calls its own PDP — defense in depth, because a compromised agent runtime should not be able to spoof its own authorization. Decision logs from every PDP stream into the SIEM. The capability token expires when the task ends.

Nothing about that diagram is novel for service-to-service authorization. What is novel is treating the agent the same way: as an untrusted client whose actions are authorized by a policy engine, not whose intentions are constrained by a system prompt.

The Audit Conversation You Are Eventually Having

The forcing function for adopting this architecture is rarely the engineering team. It is the audit conversation. A regulator, an enterprise customer's procurement security review, or an internal compliance lead asks: "Can you prove that your AI agent, when handling a Tier-2 customer's request on March 14th, did not access data outside that customer's tenant?" If the answer involves reading model logs, summarizing prompts, and gesturing at the YAML manifest, it is not an answer. The answer that lands is "here is the policy decision log for every tool call in that session, signed and tamper-evident, showing the policy version evaluated, the inputs, and the deny-or-allow decision." That answer requires a PDP in the loop and structured decision logs streaming to your audit pipeline.

The teams that get this right earlier pay a real engineering cost: a policy engine to operate, a token-exchange flow to maintain, a logging pipeline to plumb, and the gap between "policy as YAML" and "policy as Rego or Cedar" to cross. The teams that get it right later pay it as an emergency project after a regulator's letter, with a deadline and the wrong vendor and the wrong design. The shape of the right architecture is no longer in doubt; the only question is when each agent stack catches up to it.

The model is not your authorization layer. Your authorization layer can be your authorization layer. Wire it in.

References:Let's stay in touch and Follow me for more thoughts and updates