How should you model what an AI agent is allowed to do?

The agent permissions model

Model an agent’s authority as a deny-by-default allowlist of actions rather than a grant of systems, make an explicit deny beat every allow regardless of where it is written, and make delegation between agents intersect rather than accumulate. Those three rules do most of the work. The first is granularity: a role that grants a system hands over the whole surface of that system, so a support agent that needs one read against the order database also receives the refund endpoint sitting next to it. The second is composition: an explicit deny that wins independently of ordering is what makes a subtractive guardrail a pattern you can rely on rather than a race with whatever role happens to be listed first. The third is the one most models miss — agents call other agents, and if the effective permission set is the union of the chain then a low-privileged agent escalates simply by asking a higher-privileged one to do the work it was just refused. Nobody has to attack anything; the escalation is the architecture. And when a human is named in the call, treat that as a mask that can only narrow what the agent already had, never as a source of authority, because the header naming them is a string the caller chose.
The unit
One action on one resource — a named model, or a named tool on a named server
The default
Deny. An action no permission names is refused, and an agent with no roles grants nothing
Precedence
An explicit deny beats every allow, in the same role or any other
Composition
Every hop of a delegation chain must allow; the chain intersects, never unions
What this layer cannot seeArgument values. Refunds over £200 is a policy, not a permission
On this page
LEAST PRIVILEGE

The unit of authorisation is the action, not the system

An agent authenticates with a bearer token, and any process holding that string is the agent. There is no cryptographic binding to a workload in most deployments — Token Observe carries that as a stated residual risk rather than hiding it — which makes the permission set the load-bearing control. Whatever the token can reach is whatever the roles name.

So name actions. The resource being authorised should be a string that identifies one callable thing: a model by name, or a tool by server and tool name. One vocabulary covering both surfaces is worth more than two, because it lets a single line scope an entire tool server or a model family, and because it means a reviewer reading a role sees one kind of thing rather than two.

The reason to resist system-level roles is that they are the source of almost every over-grant. A role called order-management sounds like a description of a job and is actually a description of an API surface, and API surfaces contain the dangerous endpoint next to the harmless one because that is how APIs are organised. An agent that needs to look up an order does not need to refund it, and the only way to express that is to name both.

Keep the matching dull. A resource pattern should be a literal string in which a wildcard matches any run of characters, with every other regular-expression metacharacter escaped before the pattern compiles — so an operator who types a full stop into a tool name gets a full stop rather than an accidental wildcard, and a grant on one tool does not match a differently-punctuated neighbour. Clever matching in a permission language is a bug that reads as a policy decision.

Namespace wildcards must not leak
A grant on every tool of one server should match nothing on any other, and a grant on a model family should not reach a different vendor’s. The bare wildcard — everything on everything — is the one grant a reviewer should be able to find in seconds.
Flat roles beat inheritance
A role that is a list of permissions can be read in one screen. A role hierarchy has to be walked before anybody can answer what this agent can actually do, and that question gets asked under time pressure during an incident.
Case sensitivity is a decision, so make it deliberately
Token Observe matches resource patterns and actions case-insensitively and honours a wildcard action; policy scope matches team names case-insensitively and tags case-sensitively. The asymmetry is defensible and it has to be documented, because it is the difference between a rule biting and silently selecting nothing.

Deny by default, and an explicit deny that wins wherever it is written

An action should be allowed only when at least one permission explicitly allows it and none denies it. No implicit grants anywhere: an agent holding no roles is refused, a role carrying an empty permission list grants nothing, and a resource no permission names produces a refusal whose recorded reason says so in words an operator can search for. That last detail matters more than it looks — the string that appears in the record when nothing matched is the string somebody will grep the flight recorder for when an agent starts failing.

Make the deny path return before any allow is settled on. That is what makes precedence independent of ordering, and ordering-independence is what makes a subtractive guardrail usable: you can grant a whole tool namespace to a team and remove one action from one agent, and the removal wins whether it was written before the grant, after it, or inside the same role. Without that property, every guardrail is a race with whatever order the roles happen to be resolved in, and nobody can reason about the result.

The practical pattern this makes possible is worth naming, because it is how large estates stay manageable. Broad grants live in team roles that change rarely. Narrow denies live in guardrail roles attached to specific agents, and reading one of those roles tells you exactly what somebody decided this agent must not do. It is the difference between rewriting a broad grant every time an exception appears and adding one line.

Report the refusal properly. A denial should name the role and the pattern that produced it, because the alternative — a generic forbidden — turns a five-minute fix into an afternoon of bisecting role assignments. And record the refusal as evidence: the trace should open before the decision, so an agent probing for grants it does not hold is visible afterwards rather than being dropped on the floor.

One role, two answers, and the ordering that does not matter
role  finance-support
  allow  tool:payments/*             actions: [invoke]
  deny   tool:payments/issue_refund  actions: [invoke]

invoke tool:payments/get_status
  -> allowed: true
     reason:  allowed by role finance-support (tool:payments/*)

invoke tool:payments/issue_refund
  -> allowed: false
     reason:  explicitly denied by role finance-support
              (tool:payments/issue_refund)

Delegation must intersect, and the reason is not subtle

When one agent hands work to another, the effective permission set for that request has to be the intersection of every agent in the chain: every hop must allow the action independently, and the request is refused at the first hop that does not.

The alternative — taking the union, or simply authorising as the final agent — is a privilege escalation with an audit trail that looks entirely legitimate. A low-privileged agent refused a refund asks the payments agent to do it, and the payments agent holds the grant. Nobody attacked anything. The escalation is the architecture, and it will be discovered the first time an injected instruction in a ticket body finds it.

Intersection is inconvenient by design, and it is worth being explicit about the inconvenience because it is the objection you will get. An orders agent and a payments agent that delegate to each other can jointly do nothing: not the order lookup, because the payments agent lacks it, and not the refund, because the orders agent lacks it. The failure mode of an intersection is under-privilege, which surfaces as a support ticket somebody investigates. The failure mode of a union is an escalation nobody notices.

The reason intersection is also the safe response to an untrusted chain is the part worth internalising. A delegation chain generally arrives as a request header and is asserted rather than proven: it is only as trustworthy as the calling agent’s own authentication. Under intersection semantics a forged chain can only add links, and every added link must also allow — so an attacker who forges the header buys strictly less than one who sends none. Under union semantics, forging the header is the attack.

Fail closed on a hop you cannot resolve. A chain naming an agent the registry does not know, or one that is not currently active, should refuse rather than skip the link; on a tool path an unresolvable hop can contribute an empty role set and deny through the ordinary intersection. Bound the chain length as well — Token Observe truncates at eight hops on the tool gateway and accepts at most 32 identifiers on the model gateway — because an unbounded header is an unbounded amount of work per request.

Three hops, and the link that refuses
chain    agt_triage    ->  agt_orders     ->  agt_payments
grants   tool:orderdb/*    tool:orderdb/*     tool:payments/*

invoke tool:payments/issue_refund
  -> allowed: false
     reason:  delegation link 1/3: no role grants invoke on
              tool:payments/issue_refund (deny by default)

// the union would have allowed this. That is the whole argument.

A named human narrows the agent; it never widens it

Agents increasingly act for a specific person, and the temptation is to model that as the agent borrowing the person’s authority. Resist it. The header naming the person is a string the caller chose, with no signed claim behind it in most deployments, and anything built on top of it has to remain safe when that string is a lie — a much stronger requirement than making it work when the string is true.

So model the human as one more link in the same intersection: the roles their directory groups map to are appended to the delegation chain, and the chain’s contract is that every link must allow. That single reuse is what makes the control safe to hand to an operator. A person whose group maps to a role granting the bare wildcard becomes a no-op rather than an escalation, because the wildcard satisfies its own link and cannot satisfy anyone else’s. It is the footgun somebody would otherwise reach for on day one, and the design removes it rather than documenting it.

Order matters in the other direction too. Compute the agent’s own verdict before reading any field of the named human, and short-circuit on a deny. A request the agent could never have made should be refused for the reason the agent produced, so an intersection failure can never overwrite the real cause of a refusal in the record — and nobody is ever asked to approve something the intersection already forbids.

Stage the rollout. Token Observe’s intersection has three settings and is off by default: under off nothing is looked up at all and the header is attribution only; under shadow the intersection is computed and written to the trace as a decision recording what it would have refused, while the request proceeds; under enforce it binds. Two of those three refuse nothing, which is the sentence an install that has not reached enforce should use about itself rather than claiming the control.

Then read the directory constraints before you promise anything, because they are where this feature meets reality. The groups are a snapshot from the person’s last single sign-on rather than a live directory read, so a revoked group keeps granting until they sign in again or the capture ages out — 24 hours by default, after which the request is refused rather than decided on stale evidence. Entra ID stops emitting the group claim once a person is in more groups than its overage limit allows, sending a directory-API link instead; Token Observe deliberately will not follow that link, because following it would mean a new credential, a new egress host and a directory-read permission inside a login, so those people capture no groups and their calls are refused until an administrator narrows the claim. Okta needs a groups claim configured on the authorisation server and the scope granted. Google Workspace emits no group claim on an OIDC ID token at all, so the intersection is simply unavailable there.

Grants drift, and the model has to make that visible

Permissions are standing grants. They stand until somebody edits the role, nothing expires on its own, and agent credentials are long-lived bearer tokens rotated by hand. That is the standing-privilege pattern security handbooks warn about, and the honest position is that the controls here are for noticing accumulation rather than for preventing it.

Access recertification is the noticing mechanism, and it only works if it binds the right thing. A review recording that agent X holds role Y certifies almost nothing, because role Y is editable afterwards and the review does not say what was in it — six months later a named reviewer’s attestation sits beside a permission set they never saw, and nothing in the record distinguishes that from a genuine review. Bind the digest instead: each referenced role’s name, its permission count and a hash over its normalised permissions. Editing a role then makes every affected review stale immediately, without rewriting the history of what was actually attested.

Normalise ordering where ordering grants no different authority — de-duplicate and sort role ids, tags and actions before hashing — so that harmless reordering does not manufacture staleness. A staleness signal that fires on cosmetic changes trains reviewers to click through, which is the outcome the whole mechanism exists to avoid.

Refuse to delete a role anything still references, and report what still holds it. Deleting a role out from under a running agent is a silent permission change, and silent permission changes are the thing this layer exists to prevent. Write every role creation, edit and deletion into a tamper-evident log with the acting person and the previous permission list beside the new one, so what a role granted last Tuesday has an answer that does not depend on anybody’s memory.

Finally, keep the boundary between permissions and policy sharp. A permission answers whether this agent may touch this tool at all, and that answer has to be readable in one line by somebody attesting to it. A rule such as refunds over £200 need a human reads argument values and belongs in the policy layer. Collapsing the two produces conditional grants that have to be simulated before anyone understands them, and a grant nobody understands is a grant nobody can honestly certify.

No time-bound or just-in-time grants
In Token Observe a permission stands until the role is edited. If you need elevation windows, they have to be operational procedure — grant, act, revoke, with all three audited — rather than an assumed platform feature.
One verb, honestly stated
Everything is evaluated as invoke. The action field is genuinely matched rather than ignored, and the wildcard is honoured, but a finer verb set is reserved rather than shipped — which is worth knowing before you design a role vocabulary around read and write.
Visibility is not enforcement
Filtering the model list and the tool list to what an agent may use stops a framework picking something that will be refused. It is a usability feature: the call itself has to re-check the grant, because a client can guess a name or remember a stale catalogue.
Approvals should bind the chain
A human approval bound to a payload hash should cover the ordered delegation identities and their effective grants as well as the action, so an approval obtained under one chain cannot be replayed under another.
in practice

How to put the agent permissions model into practice

  1. 01

    Write down what each agent actually calls

    From the recorded traffic rather than from the design document. The list of models and tools an agent has genuinely invoked over a representative period is the starting allowlist, and the gap between it and the current grant is the over-privilege you are removing.
  2. 02

    Express every grant as an action on a named resource

    A model by name or family, a tool by server and tool name. Refuse to write a grant whose scope you cannot state in one sentence, because that is the grant nobody will be able to certify later.
  3. 03

    Make deny-by-default real by testing it

    Point an agent at a tool it should not hold and confirm the refusal, that it names the reason, and that a trace exists afterwards. An untested default is an assumption.
  4. 04

    Add guardrail roles for the exceptions

    Keep broad grants in stable team roles and put explicit denies in narrow roles attached to individual agents. Verify that the deny wins regardless of the order the roles are listed in.
  5. 05

    Check the multi-agent path intersects

    Set up a two-hop delegation where the second agent holds a grant the first does not, and confirm the call is refused and that the refusal names which link failed. If it succeeds, your model unions, and that is an escalation waiting for an injected instruction to find it.
  6. 06

    Stage the on-behalf-of mask before enforcing it

    Run it in observation mode long enough to see what it would refuse, confirm your identity provider actually emits group claims for the people involved, and only then enforce. Two of the three settings refuse nothing, so say which one you are on.
  7. 07

    Recertify against a configuration digest, not a role id

    Have a named person attest the exact permission set in front of them, bound by a hash that covers each role’s normalised permissions, so editing a role marks every affected review stale rather than letting an agent inherit a review of the grant that role used to be.

Why should delegation intersect instead of taking the union?

Because the union is a privilege escalation that looks legitimate in the log. If the effective permission set is the union of every agent in a chain, a low-privileged agent gains everything the highest-privileged one holds simply by delegating to it, and no attack is required. Intersection makes every hop prove it holds the grant, so the request is refused at the first hop that does not. It is also the only safe reading of a chain that arrives as an unsigned header: under intersection a forged chain can only add links, and every added link must also allow, so forging it buys an attacker strictly less than sending none.

What happens when one role allows an action and another denies it?

The deny should win, and it should win regardless of order — whether it sits in the same role as the allow, in a role listed before it, or in one listed after. Token Observe returns at the first matching deny before any allow is settled on, and the refusal names the role and the resource pattern that produced it. That ordering-independence is what makes a subtractive guardrail role something you can rely on: you can grant a whole tool namespace to a team and remove one action from one agent without rewriting the broad grant or duplicating it into a narrower one.

Can a permission depend on an argument value, like a refund over £200?

It should not, and in Token Observe it cannot: a permission carries an effect, a resource pattern and a set of actions, and nothing else. Argument values are the policy layer’s business, where a rule can match a tool name pattern plus conditions on argument values and then block, redact, warn or require a human. Keeping them apart is deliberate rather than a limitation. A permission answers whether this agent may touch this tool at all, and that answer has to be readable by a reviewer in one line — a conditional grant that must be simulated before anyone understands it is not something a person can honestly attest to during an access review.

Should an agent inherit the permissions of the person it acts for?

No — it should be narrowed by them and never widened. Treat the named human as one more link in the delegation chain, so their mapped roles can only subtract from what the agent already held. The reason is that the header naming the person is usually a string the caller chose with no signed claim behind it, so a model in which naming somebody grants their authority is a model in which typing a name is an escalation. The reuse also removes the obvious footgun: a person whose directory group maps to a wildcard role becomes a no-op rather than a superuser, because the wildcard satisfies its own link and nobody else’s.

How do you stop granted permissions accumulating over time?

Accept that they will, and build the noticing rather than pretending you have prevented it. Permissions are standing grants with no expiry, so the controls that work are recertification bound to a configuration digest — each role’s name, permission count and a hash of its normalised permissions, so editing a role makes every affected review stale — plus a refusal to delete a role anything still references, plus an audit entry for every role change carrying the previous permission list beside the new one. Normalise ordering before hashing, or cosmetic reordering manufactures staleness and trains reviewers to click through.

Ask about this guide
Ask anything about the subject. These guides are written to be useful whether or not you ever buy anything, and this answers in the same spirit.

Prefer to ask a person? Write to us →

get in touch

Bring us the question this guide did not answer.

Write to hello@tenhaw.com with what your agents do, which providers they call and what would have to be true for you to put something in front of them. James Rooney replies. You will get a straight answer about whether Token Observe fits, including when it does not.

no form · no qualification step · no sales desk · the other three ways in