Chapter 120 · Guardrails and security
The confused deputy problem, and the agent acting as itself
A service account that can reach everything, invoked by someone who can reach a little. Identity propagation, the three places it gets dropped, and what to do when a backend has no concept of your users.
8 min read
A confused deputy is a program with more authority than its caller, tricked into using that authority on the caller's behalf. It is a 1988 idea and an agent is close to its purest modern form: the assistant holds credentials for the document store, the orders database and the ticketing API, and every user who can type a question can invoke all three.
Chapter 119 solved it for retrieval. This chapter is the general version, and the general version is harder because tools talk to systems that were not designed with this in mind.
The shape
// The code that creates the problem, and it is the obvious code to write.
const db = new Pool({ connectionString: process.env.ORDERS_DB_URL }) // service account
export const lookupOrder = {
name: 'lookup_order',
async execute({ orderId }: { orderId: string }) {
return db.query('select * from orders where id = $1', [orderId]) // as the service
},
}The tool runs as the service. The service can read every order. A rep who can only see western-region orders asks about an eastern one, the model calls the tool, and the data comes back — not because anything was bypassed, but because nothing ever asked who was asking.
// Note what this is NOT. There is no injection here, no jailbreak, no
// clever phrasing. A reasonable question produced a privilege escalation
// because identity stopped at the edge of the application.Three ways identity gets dropped
// 1. It was never threaded. The tool signature takes arguments and no
// caller identity, so there is nothing to check.
async function execute(args: Args) // nothing to check
async function execute(args: Args, ctx: CallerContext) // something to check// 2. It was threaded and then not used. The context arrives and the query
// ignores it, which passes review because the parameter is present.
async function execute(args: Args, ctx: CallerContext) {
return db.query('select * from orders where id = $1', [args.orderId]) // ctx unused
}// 3. It was lost at an async boundary. A queued job, a retry, a
// chapter 87 resumed run, a chapter 84 subagent. The work continues
// and the caller is gone.
await queue.enqueue({ tool: 'lookup_order', args }) // no identity in the payloadThe third is the one that survives review, because the synchronous path is correct and the asynchronous path is written months later by someone reading the synchronous one. Atlas found it in the resumption path: a run suspended for confirmation and resumed the next morning rebuilt its tool context from the stored run and the stored run had no identity field.
Identity is not metadata about a request. It is part of the work, and anything that persists the work has to persist it.
Threading it
// The context is constructed once, at the edge, from the authenticated
// request. Nothing downstream can widen it.
export interface CallerContext {
readonly userId: string
readonly groups: readonly string[]
readonly sessionId: string
readonly runId: string
readonly onBehalfOf?: never // deliberately impossible: no impersonation path
}// Every tool takes it, and the type system makes omission a compile error
// rather than a review finding.
type Tool<A, R> = {
name: string
schema: Schema<A>
execute(args: A, ctx: CallerContext): Promise<R>
}// And the serialised form, so a resumed or queued run carries it.
interface StoredRun {
runId: string
caller: CallerContext // ⚠️ re-validated on resume, never trusted as stored
steps: Step[]
}
// On resume: the user may have left the company, changed teams, or lost
// access since the run was suspended. The stored identity says WHO; the
// current directory says WHAT THEY MAY DO.
const groups = await identity.groupsFor(stored.caller.userId) // freshThat last line closed a real gap. A run suspended on Friday and resumed on Monday used Friday's group list, which is a small window and the wrong behaviour — and it is the same class as chapter 90's state crossing a lifecycle boundary.
Enforcing it at the backend
Threading identity is necessary and not sufficient. The check has to happen where the data is.
| Backend capability | Approach | Strength |
|---|---|---|
| Native per-user auth | Pass the user's token through | Strongest |
| Row-level security | Set the user in the transaction | Strong |
| A permission table you own | Join against it in the query | Strong |
| No concept of your users | A policy wrapper in front | Weak, and often all there is |
// Row-level security, the same pattern chapter 94 used for memory. The
// setting is transaction-scoped, which is the one-character detail that
// separates a control from a cross-user leak on a pooled connection.
export async function asCaller<T>(ctx: CallerContext, fn: (tx: Tx) => Promise<T>) {
return db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.user_id', ${ctx.userId}, true)`)
await tx.execute(sql`select set_config('app.groups', ${ctx.groups.join(',')}, true)`)
return fn(tx)
})
}When the backend has no idea who your users are
The common case with an internal API written in 2014.
// A policy wrapper. It is weaker than backend enforcement because it is
// the only thing standing there, so it gets written defensively.
export function wrap<A, R>(tool: Tool<A, R>, policy: Policy<A>): Tool<A, R> {
return {
...tool,
async execute(args, ctx) {
const decision = await policy(args, ctx)
await audit.record('tool.authorised', { tool: tool.name, ctx, args, decision })
if (!decision.allow) throw new Forbidden(decision.reason)
return tool.execute(args, ctx)
},
}
}// Three rules that make a wrapper survivable.
//
// 1. Deny by default. A policy that cannot decide returns deny, and an
// unwrapped tool cannot be registered — the registry takes wrapped
// tools only, enforced by the type.
//
// 2. The policy never sees model output as authority. It sees the
// caller's identity and the tool's typed arguments.
//
// 3. Audit the decision, allow or deny. Chapter 124 needs both; a log of
// denials alone cannot answer "what did this user reach".The honest framing to give a security reviewer: this is a compensating control for a backend that cannot authenticate your users, and the remediation is to fix the backend. Writing that down is what eventually gets it fixed.
Scoping the credential itself
// Whatever the enforcement, the service account should not be able to do
// things the assistant never needs. Atlas's audit of its own credentials
// found the orders connection had write access it never used.
const LEAST_PRIVILEGE = {
orders: 'read-only; writes go through the ticketing API with confirmation',
documents: 'read-only',
ticketing: 'create and comment; no close, no delete',
memory: 'read-write, scoped by row-level security',
}That audit is twenty minutes and it bounds every other failure in this section. A manipulated model with a read-only credential cannot write, whatever it decides to attempt.
Key points
- An agent is a near-pure confused deputy: broad credentials, invoked by anyone who can type a question.
- The failure needs no attacker. A reasonable question escalates privilege because identity stopped at the edge.
- Three ways identity is lost: never threaded, threaded and unused, or dropped at an async boundary — queues, retries, resumed runs, subagents.
- The async case survives review because the synchronous path is correct and the other is written later.
- Make the caller context a required parameter on every tool, so omission is a compile error.
- Persist identity with the work, and re-validate groups on resume. A run suspended Friday must not use Friday's permissions on Monday.
- Enforce at the backend where possible: pass-through tokens, row-level security, or a permission table you own.
- A policy wrapper is a compensating control for a backend that cannot authenticate your users. Deny by default, never treat model output as authority, audit allows as well as denies, and write down that the real fix is upstream.
- Audit the service account's own privileges. Atlas's orders credential had unused write access, and removing it bounds every other failure.
Next: preventing data leakage between users — the paths that bypass every check above.