Chapter 5 of 7 · 13 min read
Signing in does not authorise the tool call three steps later
On this page
The previous chapters put a bounded credential in the agent's hands, the human principal on the wire, and an entitlement check in the service that spends its own privilege. All three answer questions that were settled before the run began. This chapter is about the fourth question, which cannot be: whether this operation, on this object, is permitted now. Answering it means computing a decision per tool call rather than per session, and the reason that matters is a property of agent runs rather than of authorisation theory.
The offboarding ticket said the contractor's access was removed at 14:02:11, and the identity team could prove it: the role assignment was deleted, the SCIM push — System for Cross-domain Identity Management, the protocol that propagates user and role changes to connected applications — reached four downstream systems by 14:02:40, and the contractor's browser session was invalidated at 14:02:12. At 14:06:03 a document in the finance workspace was deleted, and the audit line attributed it to that contractor.
The run had started at 13:58. The assistant was working through a request to tidy up a project — twelve tool calls, four writes, spread over eight minutes because two of the reads went to a slow reporting endpoint. Authorisation had been computed once, at the start, when the app server verified a session that was valid at the time and loaded the user's roles into the run's context. Every tool call afterwards consulted that snapshot. The delegation claim was correct, the token's audience and scope were correct, the workspace service checked the originator exactly as the previous chapter requires, and the entitlement it checked came from a copy of the world made four minutes before that world changed.
Session-scoped authorisation is not wrong so much as it is answering a question from a different era, and the difference is a matter of elapsed time between the check and the act. A web request that lands 40 milliseconds after a page load can reasonably treat the session's snapshot as current, because almost nothing can change in that interval and the request performs one operation. An agent run compresses a working session's worth of privileged actions into one authenticated moment, and then spends them over minutes, so the interval between the check and the last act is measured in the same units as the interval in which roles change, sessions end and documents are put under hold. That stretches the gap between "who arrived" and "what is being done" until things fit inside it.
The four inputs a decision actually needs
Authorising per action means each tool call produces a decision, and the decision takes four inputs rather than one. The principal is the human from the sub claim, which chapter three put on the wire. The action is the operation the tool performs, named specifically enough to be denied on its own — document.delete rather than documents:write, because a rule that can only speak about writes cannot forbid deletion while permitting edits. The resource is the object as it exists now, including the attributes that policy cares about, such as which workspace it belongs to and whether it is under legal hold, a flag that forbids deletion while litigation is pending. The context is everything true at this moment: the time, the user's current roles, whether this is the fourteenth deletion in ninety seconds, whether the tool arguments descended from content the agent read out of an untrusted source.
Reading those four together shows why the session model cannot be patched into sufficiency. A session carries a version of the first input and nothing at all of the other three: it knows who arrived, it does not know which operation is about to run, it has never seen the resource, and it has no view of the run. Every one of the three missing inputs is produced at the moment of the call, which is the moment the session model has already passed.
That last input is the one that has no analogue in a normal web application, and it is why policy for agents does not reduce to the policy you already have. A request in a browser was typed by a person. A tool call may have been composed from an email body the agent read two steps earlier, and a policy engine that receives that provenance can express rules the session model cannot: allow document.delete when the originating turn was a direct user instruction, require approval when the argument's provenance is untrusted content.
{
"principal": {"type": "User", "id": "u_2291"},
"action": {"type": "Action", "id": "document.delete"},
"resource": {
"type": "Document",
"id": "doc_71a4",
"workspace": "ws_finance",
"legal_hold": false,
"owner": "u_1002"
},
"context": {
"now": "2026-03-11T14:06:03Z",
"actor": "agent:workspace-assistant@v2026-03-04",
"run_id": "run_01HQ8ZK9PP",
"step": 9,
"writes_this_run": 4,
"arg_provenance": "user_turn",
"auth_time": "2026-03-11T13:40:12Z"
}
}{
"decision": "Deny",
"reasons": ["u_2291 has no role in ws_finance as of 2026-03-11T14:06:03Z"],
"policy_id": "workspace.member_required",
"decision_id": "dec_01HQ8ZM3TT",
"evaluated_in_ms": 3
}Run the offboarding incident through that and the deletion at 14:06:03 returns Deny, because the roles are read at evaluation time and the contractor has none. The step that changes the outcome is a small one and it is the whole chapter: the roles are fetched when the decision is made rather than carried from when the session was verified, so the four minutes that elapsed are four minutes of fresh data rather than four minutes of staleness. The assistant receives a tool error rather than a success, reports that it could not delete the document, and the run ends with a partial result — which is the correct outcome and, as an aside, one that agent frameworks handle badly by default, because a denial reads to the model as a transient failure worth retrying. Denials should be returned as terminal errors with an explanation the model can relay, or the loop will burn its remaining steps trying variations.
Wrapping the loop rather than the tools
The place to put the call is the executor that dispatches tool calls, because putting it inside each tool means every new tool is a new opportunity to forget. The pdp in the code below is the policy decision point, the component that takes those four inputs and answers allow or deny.
async def dispatch(call: ToolCall, run: RunContext) -> ToolResult:
tool = REGISTRY[call.name]
decision = await pdp.authorize(
principal=run.claims["sub"],
action=tool.action_id,
resource=tool.resource_from(call.arguments),
context={
"actor": run.claims["act"]["sub"],
"run_id": run.id,
"step": run.step,
"writes_this_run": run.write_count,
"arg_provenance": run.provenance_of(call),
},
)
if decision.effect != "Allow":
return ToolResult.error(f"Not permitted: {decision.reasons[0]}", terminal=True)
run.record(decision) # decision_id goes into the audit record
return await tool.run(call.arguments, run.token)This is a second check, and it does not replace the one inside the workspace service from the previous chapter. The executor's check is early and cheap and can see the run's context; the service's check is authoritative and cannot be bypassed. If you can afford only one, keep the one in the service, and understand that you lose every context input that only the run knows about.
None of this displaces the bounded credential from chapter one, and the reason to keep both is that they fail independently. Work through the two failure directions to see it. The credential constrains what a stolen string can reach when the policy engine is healthy and when it is not; a policy service that fails open under load, answering allow whenever it cannot reach a decision in time, leaves the credential's bounds standing, so the damage is capped at what that credential permits. The policy decision constrains what a legitimate credential may be spent on right now, so a credential that was minted too broadly by a buggy mint path is still met with a denial at the action. Neither failure takes the other control with it. Systems that collapse the two — usually by minting an unbounded token on the grounds that policy will sort it out at the edge — have one control with two names, and its single failure mode removes both.
Rules that can name the agent as well as the person
Once the actor is an input, policy can express things that were previously impossible to say, and the most useful of them is a ceiling: a set of operations no agent may perform on anyone's behalf, however entitled the human is. The rules below are written in Cedar, an open-source policy language in which each rule permits or forbids a combination of principal, action and resource, and any forbid overrides every permit.
// deny wins over any allow
forbid (
principal, action in [Action::"billing.plan.change", Action::"member.remove_all"], resource
)
when { context.actor like "agent:*" };
permit (
principal, action == Action::"document.delete", resource
)
when {
principal in resource.workspace.editors &&
context.arg_provenance == "user_turn" &&
context.writes_this_run < 25
};The first rule is a statement about the class of caller and it holds regardless of the human's role, which means an administrator using the assistant cannot change a billing plan through it even though the same administrator can change it in the console in four clicks. The mechanism is that forbid outranks permit, so the rule cannot be undone by any entitlement the administrator has; the only input it consults is context.actor. That is a deliberate asymmetry: the console requires a person to look at a confirmation screen, and the agent path does not, so the two are not equivalent operations even though they hit the same endpoint. Deciding which operations sit above the ceiling is a product conversation more than a security one, and the list tends to be short — irreversible actions, actions affecting money, and actions affecting other people's access.
The second rule shows the ordinary case, where the human's entitlement is the substance and the agent-specific inputs are qualifiers. All three conditions have to hold together, so the entitlement alone is not enough. Provenance is what makes it interesting: the same deletion is permitted when the user asked for it in their turn and denied when the argument was assembled from a document the agent read, which is a rule you cannot write in a session-scoped model because there is no request-level fact to hang it on. Computing provenance is work — it means tracking, for each tool argument, which messages in the run it plausibly derives from — and a coarse version that marks anything downstream of an untrusted read as untrusted is enough to be useful.
Versioning the actor pays for itself the first time a prompt change causes a behavioural regression. With agent:workspace-assistant@v2026-03-04 in the context, a rule can withhold the riskiest actions from a build that has not been through review, and a rollout can start by permitting a new version to do everything the old one could except three operations, widening once the numbers look right.
Paying for a decision at every step
Agent runs make between 4 and 12 tool calls in typical interactive use, and the policy call is on the critical path of each one. The arithmetic depends almost entirely on where the decision point lives.
| Placement | p50 per call | p99 per call | Added to a 10-step run (p99) | Staleness |
|---|---|---|---|---|
| In-process engine, policy and data embedded | 0.2 ms | 1 ms | 10 ms | As stale as the data push |
| Sidecar on the same pod | 3 ms | 8 ms | 80 ms | Seconds |
| Central policy service, same region | 12 ms | 45 ms | 450 ms | None |
| Central service, cross-region | 60 ms | 140 ms | 1.4 s | None |
The fourth column is the third multiplied by ten, which is what "on the critical path of each call" means once a run makes ten of them. Read the last column against it as a gap rather than a grade: the two cheap rows buy their speed with staleness, and the two expensive rows buy freshness with latency, so the table is a price list for one trade rather than a ranking of four products. Against a run that spends 3 to 8 seconds in model inference, the first three rows are invisible and the fourth is noticeable. Most teams land on a sidecar — a separate policy process deployed alongside the application, on the same host, reached over the loopback interface — with policy data pushed to it, which keeps the decision local and moves the freshness problem into the push pipeline, where it is a measurable lag rather than an unbounded one.
Caching is where this gets undone. The tempting cache is the one keyed on the run: evaluate once at the first tool call, reuse the decision for the rest, and the latency disappears. That is the session model with a shorter name, and it fails the offboarding incident exactly as the original did — the roles are read once and carried, which is the property this chapter exists to remove. The cache that preserves the property is keyed on the tuple that identifies the decision — principal, action, resource, and the policy version — with a TTL short enough that the staleness window is a number you can defend, typically 5 to 30 seconds. Including the policy version in the key is what makes a policy change take effect immediately rather than after the TTL, since a new version produces a key that has never been seen. Decisions that depend on run context such as writes_this_run should not be cached at all, since the key changes on every call and the cache would only ever miss.
Revocation deserves its own path regardless of the TTL. A push channel from the identity system that invalidates cached decisions for a principal within a second or two turns the offboarding case from "up to 30 seconds of exposure" into "up to two", and it is a small amount of machinery compared with rebuilding trust after an incident where a departed contractor deleted something.
Where per-action policy is the wrong instrument
A policy decision answers whether an action is permitted. It does not answer whether the action is wise, and the two get conflated in design documents that push judgement calls into policy until the rules encode business preferences nobody can explain six months later. Approvals belong to the human-in-the-loop machinery from topic 10.7; policy's role there is to decide that approval is required, and then to get out of the way.
Rate and volume limits sit awkwardly here too. writes_this_run in the context above is a real input and it is also a blunt one, because a legitimate bulk operation and a runaway loop look identical at step nine — both are a series of permitted writes by an entitled principal, and the only difference between them is whether the human wanted this many, which is not a fact any of the four inputs contains. Policy can cap it, and what it cannot do is tell you which of the two it just stopped.
The larger limit is that everything in this chapter assumes a live principal whose entitlements can be read at the moment of the action, and whose approval can be requested when policy asks for it. Both halves of that assumption are doing work: the first makes the freshness argument possible, and the second is what a RequireApproval outcome routes to. Take the human away — a job that runs at 02:00 across twelve hundred users, sending declines to calendar invitations — and per-action evaluation still works, while the question of what the user consented to has no one to answer it. That grant has to have been made in advance and bound to specific operations, which is the next chapter's design problem.
Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/signing-in-does-not-authorise-the-tool-call-three-steps-later