Read our operating thesis — Machines work better in tandem
JOURNALGOVERNANCE · JULY 2026

Agent authority as code: writing the boundary in Rego

An agent is not a principal — it is a delegate. That single fact makes role-based access control the wrong model, and it is why authority has to be expressed as policy evaluated on the tool call rather than as a sentence in a prompt.

WRITTEN BYTandem Machines
Coordinated Intelligence
PUBLISHED
READING TIME19 minutes
SHARE
LinkedInX
KEY TAKEAWAYS
  • 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

A rule in a system prompt is a preference: the model decides whether to follow it, attacker-controlled content in the context can argue against it, and afterwards there is no record of which rule applied to a given action. A policy evaluated in code at the tool boundary produces a decision the model cannot negotiate and a reviewer can inspect.

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 promptPolicy in code
Who decidesThe model, probabilisticallyYour policy engine, deterministically
Can it be argued withYes — that is what prompt injection isNo. Text in the context cannot reach the evaluator
Is there a recordNo. You know it acted, not why it was permittedYes — the rule that fired is part of the decision
TestableOnly statistically, by sampling behaviourYes — unit tests over inputs
THE THIRD ROW IS THE ONE THAT MATTERS TO AN AUDITOR

“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

Access control assumes a principal: a durable identity with rights of its own. An agent has no rights of its own — it acts for a person, on a specific task, for a bounded time. It is a delegate, and modelling a delegate as a principal is the root cause of most agent authorisation mistakes.

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:

WHAT A DELEGATE NEEDS THAT AN IDENTITY CANNOT PROVIDE
01Derived authorityIt can never exceed the rights of whoever it acts for
02Task scopeAuthority is for this task, not for the agent's lifetime
03RevocabilityThe grant ends when the task ends, or on demand
A SERVICE ACCOUNT IS PERMANENT, BROAD AND UNATTRIBUTED — THE OPPOSITE OF ALL THREE

RBAC, ABAC, and why capabilities fit

RBAC asks “what role does this identity have?” — the wrong question, since the agent’s role is not the interesting fact. ABAC asks “what do the attributes of this request permit?” — much closer, and workable. Capability-based authorisation asks “what specific rights was this agent handed for this task?” — which is the question that actually matches how agents are used.
ModelThe question it asksWhere it breaks for agents
RBACWhat role does this identity hold?The agent's own role is meaningless; it inherits everything
ABACWhat do this request's attributes permit?Workable — but easy to forget the delegation chain
CapabilityWhat rights were handed over for this task?Requires you to issue and revoke grants deliberately
ABAC IS THE PRACTICAL FLOOR; CAPABILITIES ARE THE MODEL WORTH DESIGNING TOWARD
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:

The rule everything else serves
effective = capabilities_granted_to_this_run
          ∩ rights_of_the_requesting_user
          ∩ policy_permits(action_properties)
OMIT THE SECOND TERM AND YOU HAVE BUILT A CONFUSED DEPUTY

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

Treat every tool call as an authorisation request. The input document needs five things: who the request is ultimately for, which agent and run is asking, which tool with which resolved arguments, which resource is affected, and the properties of the action — reversibility, value, blast radius, model confidence.

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.

Authorisation input for one tool call
{
  "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
  }
}
ACTION PROPERTIES ARE TOOL METADATA — IF THE TOOL DOESN'T DECLARE THEM, THE POLICY IS GUESSING

The policy

Return a decision object rather than a boolean: whether the call is allowed, whether it requires human approval, and the reasons. A bare 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.

agent/authority.rego — the shape
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.

Hard denials
# 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])
}
RULE 2 IS THE CONFUSED-DEPUTY DEFENCE. RULE 0 IS WHY THE OTHERS CAN BE TRUSTED

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 FAIL-OPEN PATTERN, AND HOW TO AVOID IT
01The trapUndefined input makes a deny rule skip, and a skipped deny reads as allow
02Guard the inputsAn explicit deny for every field the policy depends on existing
03Bind before usesome cap := data...[key] fails loudly rather than propagating undefined
04Assert in CIEvery registered tool has a mapping — check it at build time, not at 2am
THIS IS WHY DENIAL TESTS MATTER MORE THAN ALLOW TESTS

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

Approval gates should trigger on properties of the action — irreversibility, value above a threshold, wide blast radius, low model confidence — rather than on lists of tool names. Names go stale the moment someone adds a tool; a rule about irreversibility keeps working for tools that did not exist when it was written.

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.

Approval gates
# 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
}
THREE PROPERTIES, NOT THREE TOOL NAMES — THIS IS WHAT MAKES THE POLICY DURABLE

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

When an agent spawns a subagent, authority must narrow rather than persist. A subagent should receive a subset of the parent’s capabilities, still intersected with the original requester’s rights — so the chain can only ever lose authority as it lengthens.

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.

Delegation must be monotonically narrowing
# 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
}
AUTHORITY ONLY EVER SHRINKS DOWN THE CHAIN — NEVER GROWS, NEVER RESETS

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

Rego has a native test framework: define a test rule, assert the decision with a mocked 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.
agent/authority_test.rego
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
}
WRITE THE DENIAL TESTS FIRST — A POLICY THAT ALLOWS EVERYTHING PASSES EVERY ALLOW TEST

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

The policy and the governance document a buyer’s legal team asks for should be generated from the same source. When the acceptable-use document is written separately, it describes intentions; when it is rendered from the policy that actually runs, it describes behaviour — and the two can never drift.

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 artefactBecomes this governance statement
data.tool_capabilitiesThe exhaustive list of actions the system can take
gateWhich actions require human approval, and on what criteria
data.thresholdsThe value and confidence thresholds, stated numerically
Cross-tenant deny ruleThe data-segregation guarantee, as an enforced control
Test suiteEvidence the controls are verified, with a CI record
EVERY ROW ON THE RIGHT IS DERIVABLE FROM POLICY DATA YOU ALREADY MAINTAIN

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

  1. 01Rego policy language reference Open Policy Agent, 2026
  2. 02Policy testing Open Policy Agent, 2026
  3. 03OWASP Top 10 for Large Language Model Applications OWASP, 2025
Tandem Machines
Notes on coordinated intelligence — one essay a month
Subscribe
PREVIOUS

After-hours answering: the unit economics of never missing a call