Chapter 3 of 7 · 14 min read
Operation, resource and lifetime scoping
On this page
A tool that holds a credential broader than its job is a confused deputy: a component acting with its own authority on a target somebody else named. The pattern is not new — topic 6.12, on a proxy that holds one OAuth client id for every user of an MCP server, works the same failure from the identity side — and what is new is how easy agents make it to build one. The previous chapter constrained what the agent's own identity can reach; none of that touches a tool that authenticates as itself, because the agent never presents a credential to such a tool. It presents an argument.
So the question for each tool is not what the agent may call but how much authority stands behind the string it passes, and that authority has three separate dimensions rather than one.
The deputy holds authority the caller never had
The shape of the failure is older than agents by about forty years and has a name from Norm Hardy's 1988 note The Confused Deputy. On a Tenex-derived system, a shared Fortran compiler ran with the authority to write two things: whatever output file the user named, and a billing file in its own directory that recorded per-user charges. A user invoked the compiler and passed the billing file's path as the output path. The compiler wrote there, because it was permitted to, and the billing records were destroyed. The user had no write access to that file and never acquired any. They supplied a name, and a program with more authority than they had did the writing.
Hardy's diagnosis was that designation and authority had been separated. The user designated the target with a string; the authority to act on it came from somewhere else entirely — the compiler's own identity. Any time those two arrive through different channels, whoever controls the string is borrowing whoever holds the authority.
The modern version is an egress proxy that attaches credentials by destination host. It is a good design for ordinary services: the proxy sits between the pods and the internet, matches the destination host, and adds the right secret, so no application code ever touches a credential and nothing is ever logged with a key in it. Put a general http_request(method, url, body) tool behind it and the agent designates the target while the proxy supplies the authority, and the proxy is structurally incapable of noticing anything wrong, because a proxy that authenticates by hostname has been given exactly one bit of information about the request and it is the wrong bit. A billing agent whose only intended operation is POST /v1/invoices can be induced to issue POST https://api.kestrel-billing.com/v1/payouts with a body naming a bank account, and the key goes on the request because the hostname matched. The agent designated POST /v1/payouts; the proxy supplied the authority; neither of them was in a position to notice the mismatch.
The phrase "structurally incapable" is doing work there and can be turned into a rule that predicts the next case. A component can enforce only those constraints whose inputs it actually receives, because a decision is a function and a function cannot depend on a value that was never passed to it. Work out what this proxy receives. The destination host, which it matches. Everything that distinguishes an invoice from a payout lives elsewhere: the operation is in the method and the path, and the resource — whose money, which account — is in the request body, which the proxy forwards without reading. So the finest distinction the proxy can make is between hosts, and every endpoint under a matched host is, to it, the same request. Attaching a credential at that layer therefore authorises the whole host by construction, and no amount of care in configuring it changes that, because the missing capability is an input rather than a rule. The rule generalises to any credential-attaching layer: find the coarsest field its decision is computed from, and that field's granularity is the granularity of the authority it grants. It also tells you where the fix has to go — to a component that does receive the method, the path and the body.
Agents make confused deputies unusually easy to build for a reason that is worth being explicit about. Ordinary services designate their targets in code that a human wrote and a reviewer read, so the set of strings that can reach a deputy is bounded at build time. An agent's tool arguments are generated at run time from text that, per the lethal trifecta, an attacker frequently authors. Every deputy in the system therefore has an attacker-controlled caller by default, and the question is never whether the strings can be hostile but only how much authority is behind each one.
Three axes, and most designs only think about one
Reviews tend to ask which operations a tool can perform and stop there. That is one axis of three, and the other two are where the interesting failures live.
| Axis | The question | How it is expressed | What its absence permits |
|---|---|---|---|
| Operation | Which verbs can this credential invoke? | IAM actions, OAuth scopes, GitHub fine-grained permissions, an allowlist of endpoints | The invoice key issues payouts, the "read" token also deletes |
| Resource | Which objects can those verbs touch? | ARN patterns, repository lists, per-tenant API keys, an id resolved against your own records | A permitted send_invoice bills a customer this run has nothing to do with |
| Lifetime | For how long does it work, and how many times? | Token TTL, one-shot capability, an idempotency key (a caller-supplied id that makes a repeated request return the first one's result instead of acting again), single-use signed request | A credential lifted from one turn is replayable for 90 days from anywhere |
Lifetime is the one that gets skipped, because a long-lived key feels like an operational convenience rather than a permission. It is a permission — a key valid for 90 days grants 90 days of use to anyone who obtains it, including a process that read it out of /proc/self/environ in a single compromised turn and posted it to a webhook. A credential that expires in 600 seconds converts "the attacker now has our billing key" into "the attacker had our billing key for the rest of that request", which is a different incident with a different remediation path.
Putting the two lifetimes in the same unit shows how large the difference is, and the conversion is arithmetic anyone can check. Six hundred seconds is ten minutes, since a minute is sixty seconds. Ninety days is ninety lots of twenty-four hours, each of sixty minutes, which comes to a hundred and twenty-nine thousand six hundred minutes. Divide that by ten and the long-lived key is worth just under thirteen thousand times as much window as the short-lived one — same key, same permissions, same everything else. That factor is the whole of what the lifetime axis contributes, and it is why a lifetime is a permission rather than an operational setting: the value of a stolen credential is the authority it holds multiplied by the time it holds it, and only one of those two factors ever appears on a review slide.
Resource scoping is the axis that most often has no vendor support at all. A payments vendor whose smallest unit of authority is an account-wide key gives you no way to say "this credential may invoice ACME and no one else", and /v1/payouts sits in the same account scope as /v1/invoices. That gap is the case the rest of this chapter is about, because it is the common one.
When the vendor only sells one key, build the capability yourself
If the vendor's smallest unit of authority is larger than the job, the capability the agent holds has to be manufactured on your side of the boundary. A small service of your own holds the vendor key, exposes exactly the operation the agent needs, and enforces the resource and lifetime constraints the vendor does not offer. The agent's capability then stops being "whatever the vendor sells" and becomes "whatever your service permits", which is a set you wrote down.
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field, conlist
import httpx, os
app = FastAPI()
KESTREL_KEY = os.environ["KESTREL_ACCOUNT_KEY"] # never leaves this process
class LineItem(BaseModel):
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
quantity: int = Field(ge=1, le=500)
unit_price_cents: int = Field(ge=0, le=1_000_000)
class InvoiceRequest(BaseModel):
customer_id: str = Field(pattern=r"^CUST-\d{6}$")
line_items: conlist(LineItem, min_length=1, max_length=50)
@app.post("/invoices")
def issue_invoice(req: InvoiceRequest,
x_task_ticket: str = Header(...),
idempotency_key: str = Header(...)):
task = verify_ticket(x_task_ticket) # signed by the gateway, not the agent
if task.customer_id != req.customer_id:
raise HTTPException(403, "invoice customer does not match task customer")
total = sum(li.quantity * li.unit_price_cents for li in req.line_items)
if total > 250_000: # 2,500 EUR ceiling for this path
raise HTTPException(422, "amount exceeds automated ceiling")
return httpx.post(
"https://api.kestrel-billing.com/v1/invoices",
headers={"Authorization": f"Bearer {KESTREL_KEY}",
"Idempotency-Key": idempotency_key},
json=req.model_dump(),
timeout=10.0,
).json()Four constraints are doing work in those thirty lines, and it is worth naming which axis each one serves. The route itself is the operation constraint: there is no /payouts handler, so no string an agent can produce reaches that endpoint. The task.customer_id comparison is the resource constraint, and it is the reason the ticket has to be signed by something upstream of the agent — a customer_id the agent supplies twice is not a check. The idempotency header and the facade's own short-lived auth token cover lifetime. The 2,500-euro ceiling is not a permission axis at all but a blast-radius bound, and it belongs here because this is the last place in the system that knows the amount before money moves.
That ceiling repays being read closely, because the comment beside it is a unit conversion and the units are where the mistake would be. Amounts on this path are carried in unit_price_cents, so the sum the handler computes is in cents, and a euro is a hundred cents; two hundred and fifty thousand cents divided by a hundred is two thousand five hundred euros, which is exactly the figure the comment names. Nothing is left over, and the check would still compile with the comparison written against two thousand five hundred — at which point the path would refuse anything above twenty-five euros and nobody would find out until a real invoice bounced.
The more interesting arithmetic is what the field validators permit without that line, because it is the strongest available answer to an engineer who says the schema already bounds the request. Take the widest object the model can produce that still validates. A line item may carry up to five hundred units at up to a million cents each, and a million cents is ten thousand euros, so one line item can legitimately reach five million euros. The list may hold up to fifty of them, so the widest schema-valid invoice is two hundred and fifty million euros. Divide that by the automated ceiling and the ratio is a hundred thousand to one. Every individual field in that object is inside its declared bound; the object as a whole is five orders of magnitude outside the policy the business actually has. Per-field bounds constrain fields, and the quantity that matters here is a product of three of them, which no field-level rule can see. That is why the total is computed and compared explicitly, and it is the same lesson the confused deputy taught one section up — a check can only bind a value that is an input to it.
The agent's egress rules change to match. The proxy no longer authenticates anything for api.kestrel-billing.com; that host is removed from the agent's allowlist entirely, so an injected http_request at the payouts endpoint fails at the network layer with a 403 from the proxy before the vendor ever sees it.
# egress-policy/billing-agent.yaml
default: deny
allow:
- host: billing-facade.internal
methods: [POST]
paths: ["/invoices"]
auth: spiffe://acme/billing-agent # mTLS identity, no shared secret
- host: portal-a.supplier.example
methods: [GET]That the facade is reachable only over mTLS — mutual TLS, where the caller presents a certificate of its own instead of only verifying the server's — with the agent's own workload identity matters as much as the route list. A facade protected by a shared bearer token stored in the agent's environment has moved the confused deputy one hop rather than removing it, because the token is again a thing any code in the pod can pick up and use against any route the facade exposes.
The objection that this is just a wrapper
Facades attract a specific criticism, which is that they add a network hop, a deployment, an on-call surface and a second place where the invoice schema lives, in exchange for something a code review could have caught. The cost is real: measured on this path, the facade added 8 ms at p50 and about 200 lines including tests, and it introduced a genuine failure mode where a vendor schema change breaks the facade rather than surfacing directly.
What it buys is that the constraint is no longer a property of the agent's code. Everything on the agent side — the tool definition, the system prompt, the argument validation in the tool handler — is inside the blast radius of an injection, because it is all reachable by the same process that the attacker is steering. The facade is outside it, and the check it performs holds even if the agent is wholly under someone else's control. That is the same reason the credential broker in the previous chapter lives outside the agent process, and it is the general rule: a check that an attacker-influenced process performs on itself is a lint, and a check that a separate principal performs on the request is a control.
The facade also becomes the natural home for the things nobody knew they wanted until an incident. Per-customer invoice rate limits, an audit record of every issued invoice keyed by task ticket, a kill switch that returns 503 for all automated issuance without touching the agent deployment — all of these are one-line additions to a service that already exists and are awkward to bolt onto a proxy that authenticates by hostname.
Where scoping runs out
Scoping does not make a capability safe; it makes it small. A send_invoice narrowed to one customer, one amount ceiling and one endpoint can still be invoked when it should not be, and issuing a wrong 2,400-euro invoice to a real customer is a real event with a real recovery cost. Nothing in this chapter prevents that, because the agent is doing precisely what its remaining capability permits.
The relationship between that figure and the ceiling above it is not a coincidence, and seeing it is what keeps a threshold from being oversold. Two thousand four hundred euros is under two thousand five hundred, so the wrong invoice passes the check cleanly. It has to: the ceiling was set above the ordinary invoice so that ordinary invoices are not blocked, which means the band of amounts a threshold permits is, by construction, the band the business actually operates in. Every harmful action whose size is indistinguishable from legitimate work is therefore inside the control, and what the ceiling removes is only the tail — the single large transfer, the amount nobody could mistake for routine. That is a real reduction and it is a bound on magnitude rather than on correctness, so the residue it leaves is handled by rate limits and reconciliation rather than by lowering the number.
Nor is narrowing a substitute for the trifecta analysis. A facade that exposes exactly one read operation is still an outbound channel if its response is observable by an attacker, and a beautifully scoped tool that returns another tenant's data because your own resource check has an off-by-one is worse than a broad one, since it carries an assurance nobody will re-examine.
The honest limit is that some operations cannot be narrowed into harmlessness because their consequence is the point — a deployment to production, a refund, a mass email, the deletion of a branch. You can bound the resource and the lifetime and you still end up holding a capability whose correct invocation is consequential and whose incorrect invocation is expensive.
At that boundary the only remaining reduction is to put a person in the path, and that turns out to be a placement problem rather than an approval problem: the same gate is worth almost nothing at one point in a sequence and almost everything one step later.
Architectural mitigations — read the current version at https://daviddominguez.dev/learn/en/architectural-mitigations/operation-resource-and-lifetime-scoping