Chapter 1 of 7 · 14 min read
Ambient authority
On this page
This topic is about architectural mitigations: constraints built into the system around the model — which identity the process runs as, which credential sits behind each tool, where a person has to press approve, which call is even allowed to see an untrusted document — rather than into the text sent to the model. They exist because prompt-level defences leak. Instructing a model to ignore commands embedded in the content it reads, or screening that content with a classifier, stops most attempts and does not stop all of them, and topic 7.3, on the combination of private data, attacker-influenced content and an outbound channel in one system, gives the reason an agent is worth the attacker's effort at all. An architectural mitigation begins from the assumption that the prompt-level defence has already failed and the model is now doing what the attacker asked, then asks a narrower question — what can the convinced model actually reach? — because the answer to that one is fixed by the design rather than by the wording.
In practice these turn up as the parts of an agent's design review that look like ordinary platform work: the IAM role the pod assumes, the scope of the API key behind a tool, the point in a deployment sequence where a human confirms, the boundary between the call that reads a document and the call that can act on what it says. This topic is five of them. The first decides how much authority the agent holds before any of the others get a chance to matter, and naming its failure precisely is harder than it looks, because it sits next to a principle most teams have already satisfied. A pod whose service account can read exactly one bucket and nothing else is a good least-privilege result and can still hand an attacker every object in that bucket. The two properties are different, they are verified differently, and fixing the wrong one produces a repair that survives only until the next deployment topology change.
Two questions that get collapsed into one
Least privilege asks how much authority a component holds, and its unit of measurement is the set of resources and operations reachable at all. Absence of ambient authority asks how that authority is acquired, and its unit of measurement is whether the code had to present something specific to the current task in order to act. Authority is ambient when it is a property of the execution environment — the process, the pod, the machine, the session — so that any code running there picks it up by existing rather than by being handed it.
An agent whose pod assumes an IAM role at start-up — via a projected service-account token, refreshed in the background by the SDK — can score well on the first question and fail the second completely. Its authority sits in the execution environment, available to every line of code in that container including the ones an attacker gets to influence. Nothing in a call to fetch a particular object has to justify itself, because the entitlement was already there before the request arrived and will still be there after it finishes. That is why a policy can be as tight as a review can make it and the agent can still hand over a different customer's file: tightening the policy answers the first question again, and the read that hurt you was inside the answer.
This distinction is old and will be familiar from ordinary systems programming even if the name is not. A Unix process that opens /var/lib/records/9931.pdf is exercising ambient authority: the kernel checks the process's uid, which the code did not have to name, obtain or prove anything about. A process that is handed an already-open file descriptor over a socket is exercising a capability: the descriptor designates one specific object, cannot be forged into a descriptor for a different object, and only the code that was given it can use it. Both processes may be able to read exactly one file. Only one of them has to be given the file to read it. That difference is the whole of what follows.
The comparison is close enough to be worth using and it stops in one place worth marking, because that is where a misconception forms. A file descriptor cannot be turned into a descriptor for a different file: the designation is welded to the object by the kernel, and a process holding one has no operation available that widens it. A request-scoped credential is weaker than that. It is a bearer token, so anything that holds the bytes can use them, and the property it guarantees is about scope rather than about possession — copying it out of the process gets an attacker exactly the entitlement it was minted with, for as long as it lasts. That is a large improvement over copying out a bucket-wide key and it is not the same as the descriptor's unforgeability. The two mechanisms agree on the part this chapter is about, which is that authority arrives with the work rather than sitting in the environment, and they part company on what happens after the authority is in the wrong hands.
What the call site does not carry
The clearest way to see ambient authority is to look at the code that performs the sensitive action and notice what is absent from it. Here is a contract-reading path in the shape most agents are first written in:
import boto3
s3 = boto3.client("s3") # credentials resolved from the pod's environment
def read_contract(key: str) -> bytes:
obj = s3.get_object(Bucket="acme-contracts-prod", Key=key)
return obj["Body"].read()Nothing in read_contract mentions who the work is for. The key argument arrives from the model's tool call, the credential arrives from the environment, and the two are joined at the point of use without anything checking that they belong together. read_contract("contracts/ACME-1042/renewal-2026.pdf") and read_contract("contracts/GLOBEX-9931/msa-2024.pdf") are the same call with different strings, and the second one succeeds. The IAM policy backing it is equally silent about the task:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "ReadContracts",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-contracts-prod/*"
}]
}Compare that with a read path where the authority arrives with the request. The agent no longer holds a bucket-wide credential; the orchestrator, which knows that this run was started by a request from customer ACME-1042, obtains a credential scoped to that customer's prefix and passes it down. AWS gives you this directly through a session policy on AssumeRole — the call to its Security Token Service, STS, which issues credentials that work for a fixed number of seconds and then stop — and that session policy intersects with the role's own permissions rather than adding to them:
import boto3
sts = boto3.client("sts")
def scoped_reader(customer_id: str, ttl_seconds: int = 900):
session_policy = {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::acme-contracts-prod/contracts/{customer_id}/*"
}]
}
creds = sts.assume_role(
RoleArn="arn:aws:iam::…:role/renewal-agent",
RoleSessionName=f"renewal-{customer_id}",
Policy=json.dumps(session_policy),
DurationSeconds=ttl_seconds,
)["Credentials"]
return boto3.client(
"s3",
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)Now the tool function takes the client as an argument instead of constructing one, so the model's chosen key is evaluated against a credential that was minted for one customer and expires in fifteen minutes. The injected read of contracts/GLOBEX-9931/msa-2024.pdf returns AccessDenied, and CloudTrail records a denial attached to the session name renewal-ACME-1042, which is both an alert you can fire on and a sentence you can read: this run, which was for ACME, tried to read Globex's file. The shared-role version cannot produce that sentence, because every call it makes carries the same principal regardless of what the work was for.
Two details in that sketch decide whether it works, and both are easy to read past. The first is the lifetime. ttl_seconds defaults to 900, and a minute is sixty seconds, so nine hundred divided by sixty is fifteen — the fifteen minutes named above is the same number in a different unit rather than a second, separately chosen figure. What that buys is that the credential's usefulness ends with the piece of work it was minted for: a run that finishes in seconds leaves behind a secret that stops working before anyone could carry it anywhere, and the window in which a leaked key is worth stealing is bounded by the design rather than by how quickly somebody notices. The second detail is the word intersects. A session policy is evaluated against the role's own permissions and the effective grant is what both allow, so it can only ever narrow. That is the property that makes the pattern safe to write and also the property that makes it useless on a role that was already too broad: intersecting a request for one customer's prefix with a role holding every customer's prefix gives one customer's prefix, and intersecting the same request with a role that holds two segments gives one customer's prefix within whichever segment was named. The narrowing is real, and it is bounded above by whatever the role holds, which is why the next chapter's insistence on one role per boundary is load-bearing rather than tidy.
Where the two principles diverge
| Least privilege | No ambient authority | |
|---|---|---|
| Question it answers | How much authority exists at all? | How is authority acquired for an action? |
| Attached to | A principal: role, service account, API key | A request: task, session, invocation |
| Typical implementation | Trimmed IAM policy, scoped OAuth grant, read-only DB user | Per-request credential minting, passed handles, session policies, capability tokens |
| What it bounds | The ceiling of what could ever be reached | What is reachable during this particular turn |
| Failure it prevents | An agent reaching an unrelated system | An agent reaching an unrelated row in a permitted system |
| Failure it does not prevent | Misuse within the permitted set | Misuse of exactly the resource the task legitimately needed |
| What the audit log shows | The component acted | Which task the action was performed on behalf of |
| Test that reveals it | Enumerate what the credential can touch | Ask what has to change for the credential to touch something else |
The last row is the one worth keeping. Least privilege is verified by enumeration — list what the principal can reach and argue that each entry is needed. Ambient authority is verified by asking whether an attacker who controls the arguments to your tools, and nothing else, gains anything. If changing a string in a tool call is sufficient to reach data belonging to someone else, the authority was ambient, no matter how small the permission set looked on the review slide.
That second test is short enough to run in a review. Take each tool the agent exposes, pick the argument the model chooses, and ask what the widest legitimate-looking value of it reaches. If the answer is "one object, because the credential was minted for this task", the authority travelled with the request. If the answer is "any object under the prefix, because the pod holds the role", it did not, and the enumeration you did last quarter has no bearing on it. The question takes a minute per tool and it is the one nobody asks, because a policy review feels like it has already covered permissions.
What that test is really measuring is the distance between the attacker's control surface and the resource set, and stating it that way makes both answers predictable. The attacker's control surface, under the assumption this topic starts from, is the arguments to tool calls and nothing else — they write text, the model reads it, the model chooses a string. Under ambient authority the string is the only thing standing between the call and any object in the bucket, so the distance is zero moves: change the argument, reach the other customer. Under a request-scoped credential the argument still changes freely, and changing it now reaches an object the credential was not minted for, so the attacker's next move would have to be to change the credential, which means changing which task the run belongs to, which is decided upstream by whoever authenticated the request. That is not a move the attacker's control surface contains. The test reduces to asking which of the two inputs to the authorization decision the attacker supplies, and least privilege never asks that question at all — it counts what the credential can reach and says nothing about who gets to aim it.
Where per-process authority is still the right answer
None of this makes environment-scoped credentials wrong everywhere. A nightly batch job that compacts one table, runs on a schedule, takes no external input and serves no principal but the system itself has nothing to gain from per-request minting; there is one request, it is the process, and the credential and the task have identical lifetimes. The cost of removing ambient authority is real — an extra identity per boundary, a minting call on the hot path, credential lifetime to reason about — and it buys nothing where a process's whole existence is a single unit of work on a single resource.
The property that makes it necessary is multiplicity: one process, more than one principal's data flowing through it over time. Agents land in that category almost by construction, since the point of them is to serve arbitrary requests, and since — as the lethal trifecta makes concrete — the content they process is frequently chosen by someone other than the person they are working for.
The incident above is that multiplicity written as arithmetic, and the arithmetic is worth doing because it shows the over-grant is structural rather than sloppy. Divide the bucket's objects by the customers in it — roughly forty-one thousand across three hundred and eighty — and each customer's share is a little over a hundred objects. One run of the agent needed one customer's share. The credential offered the whole bucket. So the ratio of what the credential granted to what the task required is the bucket divided by one customer's share, which is the number of customers, three hundred and eighty to one. That is not a coincidence about this bucket: the over-grant factor of an environment-scoped credential is the multiplicity of the process, because the ceiling is the union of every principal's data and the need is one principal's. Which is also why tightening the policy cannot fix it. A policy review reduces the union — fewer buckets, no writes, no Secrets Manager — and the factor it does not touch is the one that comes from serving many principals through one identity.
The common case a platform team hands you sits between the two: an agent runs in a pod whose service account has read access to three databases, because that is the standard backend template and nobody wanted a bespoke provisioning path for one workload. The defect there is not primarily that three is more than one. It is that all three entitlements are ambient — attached to the pod, inherited by every code path in it, requested by nothing, and tied in the audit trail to no task. Cutting it to one database improves the ceiling and leaves the mechanism intact.
It is also worth being precise about what removing ambient authority does not do. It does not stop the agent from misusing the authority it was correctly given: an agent legitimately scoped to ACME's contracts can still be talked into summarising ACME's contracts into an email to an attacker, and no amount of credential scoping addresses that. It is not sandboxing, which constrains what code can do to its host rather than what a request can reach. It is not authentication, which establishes who is asking rather than what this particular ask is allowed to touch. And it is not, on its own, a data-modelling fix — per-customer prefixes made the scoped policy above easy to express, so the two changes are complements, with the storage layout as the thing that makes the boundary sayable and the request-scoped credential as the thing that enforces it.
What the scoped-reader sketch skips over is everything that makes it work in a system with more than one agent: where the identities come from, what happens when a second agent is stood up for a different customer segment, how deny-by-default is expressed so that a new tool cannot quietly inherit an old one's reach, and what the audit trail looks like when every call carries the task it belongs to.
Architectural mitigations — read the current version at https://daviddominguez.dev/learn/en/architectural-mitigations/ambient-authority