- 01A prompt is a preference. A policy evaluated on the tool call is a constraint. Only the second survives an argument.
- 02An agent is not a principal — it is a delegate. That is why RBAC does not fit: an agent has no role of its own.
- 03Capability-based authorisation is the model that fits: narrow, explicit, revocable rights scoped to one task.
- 04Gate on properties of the action — reversibility, value, blast radius, confidence — not on tool names, which go stale.
- 05Authorise the request, not the agent. If the agent can do what the user could not, that is a confused deputy.
Method. The Rego in this article is written against the OPA 1.0 language — if and contains are mandatory keywords there, so policies written for earlier versions look different. Syntax and the test framework are quoted from the OPA reference. The policy design is ours and is offered as a starting structure rather than a finished ruleset.
A prompt is not a policy
This is the whole argument, and it is worth stating precisely because “put it in the system prompt” is the default answer and it is wrong in three separate ways.
| Rule in a prompt | Policy in code | |
|---|---|---|
| Who decides | The model, probabilistically | Your policy engine, deterministically |
| Can it be argued with | Yes — that is what prompt injection is | No. Text in the context cannot reach the evaluator |
| Is there a record | No. You know it acted, not why it was permitted | Yes — the rule that fired is part of the decision |
| Testable | Only statistically, by sampling behaviour | Yes — unit tests over inputs |
“Anything expressed only as instruction is subject to negotiation, and the attacker gets to make the closing argument.”
None of which means the prompt is useless. Instructions shape behaviour and reduce how often the policy has to say no, which matters for usability. But the prompt is the guidance layer and the policy is the enforcement layer, and conflating them is how teams end up believing they have controls they do not have.
An agent is not a principal
Watch what happens when you try. You create a service identity for the agent. It needs to read customer records, so you grant that. It needs to issue refunds, so you grant that. Now the agent holds the union of every permission any task might need, permanently, independent of who asked or why. You have built a highly capable identity whose behaviour is decided by a language model reading untrusted text.
The three properties that make a delegate different from a principal are exactly the three that a service account cannot express:
RBAC, ABAC, and why capabilities fit
| Model | The question it asks | Where it breaks for agents |
|---|---|---|
| RBAC | What role does this identity hold? | The agent's own role is meaningless; it inherits everything |
| ABAC | What do this request's attributes permit? | Workable — but easy to forget the delegation chain |
| Capability | What rights were handed over for this task? | Requires you to issue and revoke grants deliberately |
- Capability-based authorisation
- A model in which the right to act is carried by an explicit, narrow, revocable grant held by the actor, rather than derived by looking the actor up in an access-control list. For agents it is the natural fit because it makes authority enumerable — you can state exactly what a run may do, and the absence of a grant is a denial rather than an oversight.
In practice you implement capabilities with an ABAC engine: the capability set is data, the request is input, and the policy is the function that intersects them. That is precisely the shape OPA is built for, and it is why the rest of this article is Rego rather than a homegrown check.
The intersection rule
One line does most of the work, and it is the line teams omit. The effective authority of an agent run is:
effective = capabilities_granted_to_this_run
∩ rights_of_the_requesting_user
∩ policy_permits(action_properties)Drop the first term and the agent can do anything the user can, forever. Drop the second and the agent becomes a privilege-escalation route for every user who can talk to it. Drop the third and you have no way to require a human for the consequential subset.
The decision input
Getting this document right is more than half the work. A policy can only be as good as the facts it is handed, and the facts most often missing are the action properties — because they have to be declared by whoever wrote the tool, not inferred at the call site.
{
"principal": { // the human this is ultimately for
"id": "user_8842",
"tenant": "acme",
"roles": ["support_agent"],
"permissions": ["order:read", "refund:create:under:5000"]
},
"agent": {
"id": "agent_support_v7",
"run_id": "run_01JT8...",
"capabilities": ["order:read", "refund:create"] // granted for THIS run
},
"tool": {
"name": "issue_refund",
"arguments": { "order_id": "ord_1234", "line_id": "line_2", "amount_cents": 4200 }
},
"resource": {
"type": "order",
"id": "ord_1234",
"tenant": "acme" // must match principal.tenant
},
"action": { // declared by the tool author, not guessed
"reversible": false,
"value_cents": 4200,
"blast_radius": "single_record",
"model_confidence": 0.71
}
}The policy
true/falsecannot express “permitted, but a person must confirm” — which is the outcome most consequential agent actions should have.Start with the skeleton. default gives you deny-by-default, and a partial set of deny reasons means every refusal arrives explained rather than as a bare rejection.
package agent.authority
# Deny by default. Everything below has to earn an allow.
default decision := {"allow": false, "requires_approval": false, "reasons": ["no rule matched"]}
decision := {
"allow": count(deny) == 0,
"requires_approval": count(gate) > 0,
"reasons": array.concat(sort(deny), sort(gate)),
} if {
# Only produce a decision at all for a well-formed request.
valid_request
}
valid_request if {
input.principal.id
input.agent.run_id
input.tool.name
is_boolean(input.action.reversible) # presence AND type, in one check
}Then the deny rules. These are hard refusals — no approval can rescue them, because they represent a request that should not have been made.
# 0. An unmapped tool is denied. This rule MUST exist — see the note below.
deny contains sprintf("tool %q has no capability mapping", [input.tool.name]) if {
not data.tool_capabilities[input.tool.name]
}
# 1. The capability was never granted for this run.
deny contains sprintf("tool %q not in run capabilities", [input.tool.name]) if {
some cap := data.tool_capabilities[input.tool.name]
not cap in input.agent.capabilities
}
# 2. The intersection rule: the agent may not exceed the requester.
deny contains sprintf("principal lacks %q", [tool_capability]) if {
tool_capability # only meaningful for a mapped tool
not permits_principal
}
# 3. Cross-tenant access. Always a bug, never a judgement call.
deny contains "resource tenant does not match principal tenant" if {
input.resource.tenant != input.principal.tenant
}
# --- helpers ---
# Map a tool to the capability it consumes. Data, not code, so it is reviewable.
tool_capability := data.tool_capabilities[input.tool.name]
# Exact grant.
permits_principal if tool_capability in input.principal.permissions
# Scoped grant: "refund:create:under:5000" satisfies "refund:create" only
# while the action's value stays under the stated limit. Note the four-part
# format — a two-part base plus an explicit "under" marker plus the number.
permits_principal if {
some p in input.principal.permissions
parts := split(p, ":")
count(parts) == 4
parts[2] == "under"
tool_capability == concat(":", [parts[0], parts[1]])
input.action.value_cents < to_number(parts[3])
}Note that tool_capability reads from data rather than being hard-coded. The tool-to-capability mapping is configuration a security reviewer can read without reading Rego, and it means adding a tool is a data change with an obvious review surface.
The footgun that makes a policy silently permissive
Rule 0 above is not defensive padding — without it the whole policy fails open, and the reason is the single most important thing to understand about Rego. An undefined value does not make a rule false; it makes the rule not fire at all.
Trace it. If a tool has no entry in data.tool_capabilities, then tool_capability is undefined. An expression referencing an undefined value is itself undefined, so the body of rule 1 is not satisfied, so no deny is added to the set. And because the decision is count(deny) == 0, an empty deny set means allow. A tool nobody mapped — a tool added last Tuesday by someone who did not know about the policy — is therefore permitted.
The generalisable rule: in a deny-by-default policy built from a deny set, every fact the deny rules depend on needs its own presence check. Deny-by-default protects you when no rule matches; it does not protect you when a rule fails to evaluate. Those are different failure modes and only the first is handled by default.
Gate on properties, not names
This is the difference between a policy that decays and one that holds. A deny-list of dangerous tools is out of date at the next deploy. A rule that says anything irreversible above £50 needs a person covers the tool nobody has written yet.
# Irreversible actions above a threshold need a person.
gate contains sprintf("irreversible action worth %d needs approval", [input.action.value_cents]) if {
not input.action.reversible
input.action.value_cents >= data.thresholds.approval_value_cents
}
# Anything that touches more than one record, regardless of value.
gate contains "blast radius exceeds single record" if {
input.action.blast_radius != "single_record"
}
# The model itself is unsure. Low confidence plus irreversibility is the worst pair.
gate contains "model confidence below threshold" if {
input.action.model_confidence < data.thresholds.min_confidence
not input.action.reversible
}Gate discipline
A gate that fires on everything is not a control — it is a formality that produces a record implying review that did not happen. The thresholds above live in data precisely so they can be tuned against observed approval volume without editing policy logic. If your reviewers are approving more than they can genuinely read, the threshold is wrong and the honest fix is to raise it rather than to keep the appearance.
The delegation chain
The failure mode is delegation that resets the chain: a subagent is spawned, gets its own service identity, and the connection to the human who started it is lost. Now an action is taken that nobody authorised and the trail stops at a machine.
# A subagent's capabilities must be a subset of its parent's.
deny contains "subagent capabilities exceed parent" if {
input.agent.parent_capabilities # present only for subagents
some c in input.agent.capabilities
not c in input.agent.parent_capabilities
}
# The principal travels the whole chain. It is never replaced by an agent identity.
deny contains "delegation chain lost its principal" if {
input.agent.parent_run_id
not input.principal.id
}This matters in practice because harnesses differ on whether subagents inherit the parent’s permission posture, and some make the parent’s most permissive mode un-overridable by the child. When the harness will not narrow authority for you, the policy layer is where you do it — see how one SDK handles subagent inheritance for a concrete case.
Testing policy
input and mocked data using the with keyword. Every authority rule needs a test that it allows what it should and — more importantly — denies what it should, because a silently permissive policy is indistinguishable from a working one until an incident.package agent.authority_test
import data.agent.authority
tool_capabilities := {"issue_refund": "refund:create"}
thresholds := {"approval_value_cents": 5000, "min_confidence": 0.8}
base_input := {
"principal": {"id": "u1", "tenant": "acme", "permissions": ["refund:create"]},
"agent": {"id": "a1", "run_id": "r1", "capabilities": ["refund:create"]},
"tool": {"name": "issue_refund", "arguments": {"amount_cents": 100}},
"resource": {"type": "order", "id": "o1", "tenant": "acme"},
"action": {"reversible": true, "value_cents": 100,
"blast_radius": "single_record", "model_confidence": 0.95},
}
# The happy path is the least interesting test.
test_allows_small_reversible_refund if {
d := authority.decision with input as base_input
with data.tool_capabilities as tool_capabilities
with data.thresholds as thresholds
d.allow
not d.requires_approval
}
# This is the test that earns its keep.
test_denies_when_principal_lacks_permission if {
d := authority.decision
with input as object.union(base_input, {"principal": {"id": "u1", "tenant": "acme", "permissions": []}})
with data.tool_capabilities as tool_capabilities
with data.thresholds as thresholds
not d.allow
}
test_gates_irreversible_above_threshold if {
d := authority.decision
with input as object.union(base_input,
{"action": {"reversible": false, "value_cents": 9000,
"blast_radius": "single_record", "model_confidence": 0.95}})
with data.tool_capabilities as tool_capabilities
with data.thresholds as thresholds
d.requires_approval
}
test_denies_cross_tenant if {
d := authority.decision
with input as object.union(base_input, {"resource": {"type": "order", "id": "o1", "tenant": "globex"}})
with data.tool_capabilities as tool_capabilities
with data.thresholds as thresholds
not d.allow
}
# The regression test for the fail-open bug. Without rule 0 this passes as ALLOW.
test_denies_unmapped_tool if {
d := authority.decision
with input as object.union(base_input, {"tool": {"name": "delete_account", "arguments": {}}})
with data.tool_capabilities as tool_capabilities # no entry for delete_account
with data.thresholds as thresholds
not d.allow
}
# Scoped permission: allowed under the limit, denied over it.
test_scoped_permission_respects_limit if {
scoped := {"id": "u1", "tenant": "acme", "permissions": ["refund:create:under:5000"]}
under := authority.decision
with input as object.union(base_input, {"principal": scoped})
with data.tool_capabilities as tool_capabilities
with data.thresholds as thresholds
under.allow
over := authority.decision
with input as object.union(base_input, {
"principal": scoped,
"action": {"reversible": true, "value_cents": 9000,
"blast_radius": "single_record", "model_confidence": 0.95},
})
with data.tool_capabilities as tool_capabilities
with data.thresholds as thresholds
not over.allow
}The last line of that caption is the practical point. Allow-tests pass trivially against a broken policy. It is the denial tests that tell you the boundary exists, which makes them the ones to write first and the ones to require in review.
Where the tests belong
In CI, as a gate on merging a policy change — and separately as a check that every registered tool has a capability mapping. A tool deployed without an entry in data.tool_capabilities will be denied by rule 1, which is the safe failure but a confusing one to debug at 2am. Assert the mapping exists at build time.
One source, two documents
This is the part that is genuinely under-exploited, and it is worth doing because it converts a compliance chore into a by-product. Your capability mappings, thresholds and gate rules already contain the facts an AI acceptable-use policy needs to state.
| Policy artefact | Becomes this governance statement |
|---|---|
data.tool_capabilities | The exhaustive list of actions the system can take |
gate | Which actions require human approval, and on what criteria |
data.thresholds | The value and confidence thresholds, stated numerically |
| Cross-tenant deny rule | The data-segregation guarantee, as an enforced control |
| Test suite | Evidence the controls are verified, with a CI record |
The claim you get to make from this is unusually strong, and most vendors cannot make it: not “we have a policy” but here is the enforced list of actions this system can take, here are the ones that require a person, here are the thresholds, and here is the test run proving it. That is a different conversation with a security reviewer.
What this does not give you
Two honest limits. A policy engine decides whether an action is permitted; it cannot tell you whether the action was correct — an agent authorised to issue a refund can still refund the wrong order, and that is an evaluation problem rather than an authorisation one. And the decision record is only evidence if you keep it: the rule that fired has to be written to the trace, or you have enforcement without proof. For that second half see what a trace has to contain, and for where this layer sits overall, the seven-layer stack.
The through-line: authority is a property of a system, not a paragraph in a prompt. Once it is data and code, it can be reviewed, tested, versioned and shown to someone who needs convincing — and none of those are things you can do to a sentence.
Frequently asked questions
Why can't an AI agent's rules just be written in its prompt?
Because a prompt is a preference, not a constraint. The model decides whether to follow it, attacker-controlled content in the context can argue against it, and there is no record of which rule applied to a given action. A policy evaluated in code on the tool call produces a decision the model cannot negotiate and an auditor can inspect.
Should AI agents use RBAC or ABAC?
Neither is a good fit on its own. RBAC assumes a stable principal with a durable role, and an agent has no role of its own — it acts for someone else. ABAC gets closer by letting the decision depend on request attributes. What actually fits is capability-based authorisation: the agent holds narrow, revocable permissions for a specific task, scoped to the requester's own rights.
What is capability-based security?
A model where the right to perform an action is carried by an unforgeable token of authority held by the actor, rather than derived by looking up the actor's identity in an access-control list. It suits agents because it makes authority explicit, narrow, delegable and revocable — an agent can be handed exactly the capabilities one task needs and nothing more.
How do you use Open Policy Agent with an AI agent?
Treat every tool call as an authorisation request. Build an input document describing the requester, the agent, the tool, the resolved arguments and the action's properties, then evaluate a Rego policy that returns a decision plus reasons. The harness enforces the decision at the tool boundary, before the tool runs.
What should an agent authority policy gate on?
Properties of the action rather than names of tools: whether it is reversible, its value, its blast radius, and the model's confidence. Names go stale as tools are added, whereas properties stay meaningful — and a rule about irreversibility keeps working for a tool that did not exist when the rule was written.
How do you prevent an agent from acting beyond the requesting user's permissions?
Carry the requesting user's identity through to the policy decision and authorise the request, not the agent. If the agent can do something the current user could not do directly, that gap is a confused-deputy vulnerability regardless of what the prompt says. The policy should intersect the agent's capabilities with the user's own rights.
Do agent authority policies need tests?
Yes, and they are unusually cheap to write because Rego has a native test framework — you assert a decision with a mocked input and mocked context. Every authority rule should have a test that it allows what it should and, more importantly, denies what it should, since a silently permissive policy looks identical to a working one until an incident.
Sources
- 01Rego policy language reference — Open Policy Agent, 2026
- 02Policy testing — Open Policy Agent, 2026