ACTION-LEVEL RBAC

Agent permissions

Deny by default, explicit deny wins, and delegation intersects — so an agent cannot borrow authority it was never granted.

Token Observe decides every model call and every tool call that passes through it against action-level permissions that are deny-by-default: a support agent may hold an allow on tool:orderdb/get_details and model:gpt-5o-mini while tool:payments/issue_refund is simply absent, and therefore denied. An explicit deny beats every allow wherever it is written — inside the same role or in another one — so a narrow guardrail cannot be outvoted by a broad grant. When one agent delegates to another the chain intersects rather than unions: every hop must allow the action, so a low-privileged agent gains nothing by routing work through a higher-privileged one. The same intersection is what the on-behalf-of header does once you switch it on, appending the roles mapped from a named human’s directory groups as one more link that can only subtract. Until you switch it on, that header is attribution and nothing else.
Default
Deny. An action no role names is refused
Precedence
An explicit deny beats every allow, in any role
Resources
model:gpt-5o-mini and tool:orderdb/* with case-insensitive wildcards
Delegation
Every hop must allow; the chain intersects, never unions
On-behalf-of maskOff by default; only enforce refuses anything
On this page
the problem

An agent holds whatever you forgot to withhold

The question you are answering here is narrower than it first looks: not what this agent is, but which exact actions it may take. An agent authenticates with an opaque bearer token — the credential the product issues by default — and any process holding that string is the agent: there is no cryptographic binding to a workload, and the product carries that as a stated residual risk rather than hiding it. That makes the permission set the load-bearing control. Whatever the token can reach is whatever the roles name, and the difference between a contained incident and a bad week is whether those roles were written as an allowlist or as a list of exceptions. An install that has configured workload identity can exchange a signed assertion for a short-lived, audience-bound capability instead of the static key, and that path refuses a caller-supplied delegation chain outright — but it is opt-in and unset until an operator wires an issuer up, so the static key is what an evaluation will meet first.

Conventional role models fail agents in two specific ways. The first is granularity: a role that grants a system rather than an action hands over the whole surface of that system, so a support agent that needs one read against the order database also gets the refund endpoint sitting next to it. The second is composition: agents call other agents, and a permission model with nothing to say about the second hop lets a low-privileged agent ask a higher-privileged one to do the thing it was just refused. Nobody has to escalate anything; the escalation is the architecture.

And an agent acting for a person is not the same as the person. A copilot running inside Alice’s authority should not be able to read what Alice cannot read — but the header that names Alice is a string the caller chose, with no signed claim behind it. Anything built on top of it has to stay safe when that string is a lie, which is a much stronger requirement than making it work when the string is true.

the mechanism

How it actually works

Steps 6 and 6b of the request path. The order is load-bearing rather than incidental: it is encoded in the evaluator, and the reason each step sits where it does is the reason the guarantee holds.

  1. 01

    Name the resource

    The thing being authorised is a string: model:<name> for a model call, tool:<server>/<tool> for a tool call. One vocabulary covers both surfaces, so tool:orderdb/* scopes an entire MCP server and model:gpt-5* scopes a model family in a single line.
  2. 02

    Collect the agent’s roles

    Roles are resolved once at authentication from the agent’s role ids and reused for the whole request, including for any tool call the model later proposes. A role is a flat list of permissions; there is no inheritance to walk.
  3. 03

    Refuse on any explicit deny

    Every role and every permission is scanned, and the first permission whose effect is deny and whose resource and action both match returns immediately, naming the role and the pattern. Order is irrelevant: a deny wins whether it was written before the allow, after it, or inside the same role.
  4. 04

    Require at least one allow

    With no matching deny, the first matching allow decides and its role is recorded. With no matching allow either, the request is refused and the reason ends in deny by default — an agent with no roles, or a role with an empty permission list, grants exactly nothing.
  5. 05

    Intersect the delegation chain

    Where the request names upstream agents, each one’s role set becomes a link and every link must allow independently. The refusal names the earliest failing link and how many links there were, so an operator is told which agent in the chain is short of the grant.
  6. 06

    Intersect the named human

    Once on-behalf-of enforcement is switched on, the roles mapped from the named person’s directory groups are appended as the last link of that same chain. This runs after the verdict and before the approval branch, so nobody is asked to approve something the intersection forbids.
  7. 07

    Record the decision either way

    Allowed or refused, the outcome and its reason land on the trace with the trace id returned in a response header. A blocked call is evidence: the trace opens before the refusal, so an agent probing for grants it does not hold is visible afterwards.

Deny by default, and an explicit deny always wins

An action is allowed only when at least one permission on at least one of the agent’s roles explicitly allows it, and none denies it. There is no implicit grant anywhere in the evaluator. An agent holding no roles is refused, a role carrying an empty permission list grants nothing, and a resource that no permission names produces a refusal whose recorded reason ends in deny by default — which is the string an operator will search the flight recorder for when an agent starts failing.

The deny path returns before any allow is settled on, which is what makes precedence independent of ordering. A guardrail role that denies one refund tool wins whether it is listed before or after the broad role that grants the payments namespace, and it wins when both permissions sit inside the same role. That is what makes a subtractive guardrail a usable pattern rather than a race: you can grant tool:payments/* to a team and remove one action from one agent without rewriting the broad grant or duplicating it.

Matching is deliberately dull. A resource pattern is a literal string in which * matches any run of characters, slashes included; every other regex metacharacter is 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 permission on tool:orderdb/get_details does not match tool:orderdbXget_details. Actions are compared case-insensitively, and a permission listing * as its action matches any verb.

Namespace wildcards do not leak
tool:orderdb/* matches every tool on that server and nothing on any other, and model:gpt-5* matches a model family without reaching a different vendor’s. The bare wildcard permission — * on * — allows everything, and it is the one grant a reviewer should be able to find in seconds.
Roles are flat
A role is a name, a description and a list of permissions. There is no parent role and no inheritance, so a role intended as a superset of another has to restate those permissions in its own list. That is more typing and less to reason about when somebody asks what an agent can actually do.
One verb is in use
Every permission is evaluated as invoke today. The action field is genuinely matched rather than ignored — a permission scoped to read denies an invoke on the same resource — and the wildcard is honoured, but a finer verb set is reserved rather than issued.
Seats are decided by the same evaluator
A subscription seat on a developer’s laptop carries role ids and is decided by the same governance function against the same role rows. Widening the subject was the refactor that avoided a second permission language, so nothing about this vocabulary is duplicated for seats.
One role, two answers
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 intersects, so agent-to-agent calls cannot escalate

When agent A delegates to agent B, B’s effective permissions on that request are the intersection of every agent in the chain. The evaluator walks the links in order and refuses at the first one that does not allow the action, recording which link it was and out of how many, followed by the ordinary reason that link produced. A three-hop chain whose middle agent has been quarantined with an explicit deny is refused at link two, even though the agents on either side of it both hold the grant.

The consequence worth stating plainly is that the union is never taken. 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. That is inconvenient by design. The failure mode of an intersection is under-privilege, which surfaces as a support ticket somebody investigates; the failure mode of a union is a privilege escalation with an audit trail that looks entirely legitimate.

The chain arrives as a request header, and the architecture decision record on agent identity says exactly what that means: delegation is asserted, not proven, and the chain is only as trustworthy as the calling agent’s own authentication. The intersection is what makes that acceptable rather than alarming. 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 at all.

Both gateways fail closed on a hop they cannot resolve, by different routes. On the model gateway, a chain naming an agent the registry does not know — or one that is not currently active — refuses the whole request with a delegation denial that names the offending agent id, because a dormant upstream cannot lend authority it does not presently hold. On the MCP gateway an unresolvable hop contributes an empty role set, which denies through the ordinary intersection rather than silently dropping a link, and the chain is truncated at eight hops. The model gateway rejects any entry in the header that is not shaped like an agent id, and accepts at most 32 of them.

The refusal names the link
The earliest failing link is reported, not merely the fact that the chain failed. An operator debugging a refused multi-agent workflow is told which agent is short of the grant, which is the difference between a five-minute fix and an afternoon.
A verified workload refuses the header outright
Where a caller authenticates with an exchanged workload capability rather than a static key, a caller-supplied delegation chain is rejected rather than recorded as evidence, and the on-behalf-of value is bound to the human identity the exchange verified. A header may not contradict a claim the product actually checked.
Approvals bind the chain they were granted for
The payload hash that binds a human approval covers the ordered delegation identities and their effective role grants alongside the action itself, so an approval obtained under one chain cannot be replayed under another.
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)

A named human can only narrow what the agent already had

The on-behalf-of header names the person an agent is acting for, and it is recorded on the trace in every configuration — that much is unconditional, and it is what makes a request attributable to somebody. Whether it also narrows authority is a separate decision, staged in three settings, and it is off by default. Under off nothing is looked up at all: no principal lookup, no group lookup, no trace event, and the request costs what it cost before the feature existed. Under shadow the intersection is computed and written to the trace as a decision recording what it would have refused, while the request proceeds; the event’s status is never blocked, so a dry run stays distinguishable from an outage.

Under enforce, the roles that the named person’s directory groups map to are appended as the final link of the same delegation chain and run through the same evaluator, whose whole 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 is a no-op rather than an escalation, because that wildcard satisfies its own link and cannot satisfy anyone else’s. It is the footgun an operator would otherwise reach for on day one, and the design removes it rather than documenting it.

Order is load-bearing in the other direction too. The agent’s own decision is derived before any field of the named human is read, and a deny short-circuits everything below it — no principal is resolved, no failure code is raised, and no event claims the human was the reason. A request the agent could never have made is refused for the reason the agent produced, so an intersection failure can never overwrite the real cause of a refusal in the record.

Failing to establish the human’s authority produces five distinct 403s rather than one shrug: the principal is not a known user; the principal is disabled; no group claims have ever been captured for them; the capture is older than the configured window; or their groups map to no role at all. The distinctions exist because the remedies differ — in one case that person needs to sign in through single sign-on, in another an administrator needs to add a group mapping — and an operator mid-rollout has to be able to tell those apart. A rollout can be rehearsed without committing: an administrator can preview a named person’s answer as though the instance were set to enforce, whatever it is actually set to.

A snapshot, not a live directory read
The groups are the ones that person’s last single sign-on asserted. Token Observe holds no refresh token and requests no offline scope on purpose, so there is nothing to re-read a directory with between sign-ins: a revoked group keeps granting until they next sign in or the capture ages past the limit, 24 hours by default, after which the request is refused rather than decided on stale evidence. Each sign-in replaces the snapshot rather than merging into it, because a merge would make revocation impossible.
Entra group overage denies rather than degrades
Past roughly 200 groups, Entra ID stops emitting the group claim and sends a directory-API link instead. Token Observe will not follow it — that would mean a new credential to hold, a new egress host to allow, a directory-read permission and a network dependency inside a login, all wrong for something that ships air-gapped — so such a token captures no groups and that person’s on-behalf-of requests are refused until an administrator narrows the group claim at the identity provider. The link is checked before the claim, because an overage token may still carry a partial group array and authorising against a fragment of the truth is worse than refusing.
Okta needs a scope; Google Workspace cannot do this at all
Okta needs a groups claim added to the authorisation server and the groups scope granted to the application; without it the claim is absent and every on-behalf-of request fails closed. Google Workspace emits no group claim on an OIDC ID token at all, so the intersection is unavailable there whatever is configured.
Two role universes that never meet
Console rank — admin, operator, auditor, viewer — is set only by an administrator, and the sign-in path reads no role, group or scope claim. The roles derived from group claims are a mask used on the agent path alone, so no directory group can promote anyone in the console.
A seat is the person, so nothing is intersected
For a subscription seat the header is ignored and the seat holder is read off the registry instead. Honouring it would let a developer nominate a colleague and pick up their group grants; and an empty intersection against a person who is already the subject would deny every seat call under enforce.
Omitting the header skips the mask
An attacker does not have to forge a principal — they can send none. A per-agent setting refuses a request that names nobody, but it is off by default because switching it on breaks every unattended batch job that agent runs, and it only bites where the mask is switched on at all: requiring a header that changes no decision would be theatre.
The three enforcement settings
off      (default)  nothing is read: no principal lookup, no group
                    lookup, no event. The header is attribution only.

shadow              the intersection is computed and recorded on the
                    trace as an obo_intersection decision saying what
                    it would have refused. The request proceeds.

enforce             the human's mapped roles become the last link of
                    the delegation chain. A request the agent may make
                    and the human may not is refused 403.

Permissions decide what the agent can see, not only what it can do

The model list a client SDK receives is filtered to the models the calling agent’s roles permit. This is where the permission model becomes visible to an SDK that knows nothing about Token Observe: a model the agent may not invoke is simply not in the catalogue it is handed, so a framework that picks a model off the list cannot pick one that will be refused three lines later. The MCP tool surface takes the same line — the tool list is the union of the upstream catalogues, namespaced as server.tool and filtered to the agent’s grants, and the filter applies the delegation intersection as well as the agent’s own roles.

Asking directly for a model that exists but is not granted returns not found rather than forbidden, deliberately: telling an agent which models exist but are off-limits is free reconnaissance for whatever is driving it. A tool call naming something that was never in the filtered list is refused with existence and visibility collapsed into the same answer, for the same reason.

Refusing invisibly is not the same as refusing silently. A tool call denied for want of a grant still opens a trace, records the denial with a note of whether the tool actually existed, and closes the trace as blocked. An agent probing for tool names it was never granted is precisely the pattern an investigation needs to be able to see afterwards, and it would be invisible if refusals were dropped on the floor.

Filtering a list is not the enforcement point, and the product does not treat it as one. A tool call re-enforces the grant rather than trusting that the tool came off a filtered list, so a client that remembers a stale catalogue gains nothing. The Anthropic token-counting endpoint applies the model permission without opening a trace at all — it executes nothing, and opening a trace for a pre-flight size check would pollute the evidence record rather than protect anything. The kill switch and the agent’s lifecycle status are still checked on that route and on the catalogue routes, which is a fix the product’s own known-issues log records: an emergency stop a frozen agent can still price prompts around is not a stop.

Grants drift, and the record is built to notice

Permissions are static grants: they stand until somebody edits the role. Nothing expires on its own, there is no just-in-time elevation, and agent credentials are long-lived bearer tokens rotated by hand — which the architecture decision record names as exactly the standing-privilege pattern the engineering handbook warns about. The controls that exist here are for noticing accumulation, not for preventing it, and saying so is more useful than implying otherwise.

Recertification is an append-only attestation that one named human reviewed one exact agent configuration, and what it binds is the part that matters for permissions: not merely the role ids an agent holds, but each referenced role’s name, its permission count and a digest of its normalised permissions. Editing a role therefore makes every affected review stale rather than letting an agent quietly inherit a review of the grant that role used to be. Ordering is normalised where ordering grants no different authority, so shuffling a permission list invalidates nothing, and a reviewer has to submit the exact snapshot digest they inspected — a review of a configuration that has since changed is refused rather than recorded.

A role cannot be deleted while anything still references it. The reference check runs unbounded inside the same transaction as the deletion and is serialised against the writers that assign roles to agents, to seats and to directory-group mappings, and the refusal reports how many of each still hold it along with a sample of the ids. Deleting a role out from under a running agent would be a silent permission change, and silent permission changes are the thing this part of the system exists to prevent.

Every role creation, edit and deletion is written to the hash-chained audit log with the acting user, and an edit records the previous permission list beside the new one. What a role granted last Tuesday therefore has an answer that does not depend on anybody’s memory, and the chain means an edit to that answer breaks verification at a known sequence number.

the limits

What this does not do

Stated here rather than discovered during an evaluation. Every line below closes off a reasonable assumption a reader would otherwise carry into a proof of concept.

  • There is no role inheritance. Roles are flat permission lists, and a role meant to be a superset of another has to restate it.
  • There are no time-bound or just-in-time grants. A permission stands until somebody edits the role, and nothing expires on its own.
  • Permissions do not read arguments. A rule such as refunds over £200 need approval is a policy, not a permission — this layer knows the tool, not the amount.
  • Only one verb is issued. Everything is evaluated as invoke; the action field is matched and the wildcard honoured, but a finer verb set is reserved rather than shipped.
  • The on-behalf-of header carries no signed claim. It is a narrowing control and never a granting one, and an agent that omits it skips the mask unless that agent is configured to require a principal.

If one of those limits is the thing that decides it for you, say so and you will get a straight answer about whether it is on the roadmap or out of scope.

Talk it through

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

The deny wins, and it wins regardless of order. The evaluator returns at the first matching deny it finds, whether that deny sits in the same role as the allow, in a role listed before it, or in one listed after, and the refusal names the role and the resource pattern that produced it so the trace explains itself. This is what makes a subtractive guardrail role a pattern 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 low-privileged agent gain access by asking a higher-privileged agent to do the work?

No. When one agent delegates to another, the effective permission set is the intersection of every agent in the chain, so the request is refused at the first link that does not hold the grant, and the refusal records which link that was out of how many. The union is never taken: an orders agent and a payments agent that delegate to each other can jointly do neither the order lookup nor the refund. The chain arrives as a request header and is asserted rather than proven, but because it can only add links and every link must allow, forging it buys an attacker strictly less than sending none.

Does naming a human in the on-behalf-of header actually restrict what an agent can do?

Only once you switch enforcement on, and the default is off, under which nothing is looked up at all. Under shadow the intersection is computed and recorded on the trace as a decision saying what it would have refused, while the request proceeds. Under enforce the roles mapped from that person’s directory groups are appended to the delegation chain, so they can only narrow what the agent could already do. Two of the three settings refuse nothing, which the product’s own threat model states plainly: an install that has not reached enforce should not describe the intersection as a control it holds.

What happens if the named person’s directory groups cannot be read?

The request is refused, with a different 403 for each cause: the principal is not a known user, the principal is disabled, no group claims have ever been captured for them, the capture is older than the configured window, or their groups map to no role at all. The distinctions exist because the remedies differ — one person needs to sign in through single sign-on, another needs an administrator to add a group mapping. The groups are a snapshot taken at last sign-in rather than a live directory read, bounded to 24 hours by default, so a stale capture is refused rather than trusted.

Can a permission depend on the value of an argument, such as a refund over £200?

No. A permission carries an effect, a resource pattern and a set of actions, and nothing else. The layer that reads argument values is the policy engine, which matches on agent, tool, data class and payload shape and can block, require a human approval, redact, warn or suspend the agent. Keeping the two apart is deliberate: permissions answer 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 a recertification.

How does an agent find out it is not permitted to use a model or a tool?

Mostly by never being shown it. The model list returned to a client SDK contains only the models the agent’s roles permit, and the MCP tool list only the tools it has been granted, so a framework picking off the list cannot pick something that will be refused. Asking directly for a model that exists but is not granted returns not found rather than forbidden, because telling an agent what exists but is off-limits is free reconnaissance. The refusal is still recorded: a denied tool call opens a trace, records the denial and closes as blocked.

Ask about this capability
Ask how this one actually works, where it sits in the request path, or what it will not do. Answers stay inside what this page claims.

Prefer to ask a person? Write to us →

get in touch

Bring us the agent you are least comfortable with.

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