Easier to read with cream paper, a tint, more contrast or larger text? Choose your colours and text, once, for every page.

Free lesson

Confused deputy problem in AI agents

After this lesson: I can stop an agent acting with more authority than the person asking, by checking each action against the asker.

70 min lesson7 commandsAI coding agent lab: use opencode to explain and fix code, and check its work

What is the confused deputy problem?

A confused deputy is a program with authority that is talked into using it for somebody who has none. An agent is a deputy by design: it acts for people through tools and accounts.

If the agent holds one broad key and trusts every asker, anybody who can type to it holds that key.

By the end of this lesson

Note

I can stop an agent acting with more authority than the person asking, by checking each action against the asker.

Key words

WordWhat it meansExample
Confused deputya program that uses its own authority for a person who lacks it
Authoritywhat an identity is allowed to do, here as actions on resourcesread:payroll
Scopeone named permission in a token
Narrow tokena token with only the scopes the task needs, for a short time
Askerthe person on whose behalf the agent acts in this request

The cells students fill in are left blank in this preview.

Whose authority does a call carry?

Say an agent logs in to the payroll system with its own key. Ana, who may read only notes, asks it for the payroll file. The system sees the agent's key and says yes.

The agent was not tricked by a hidden line. It was only asked. The fault is that the call carried the agent's authority and not Ana's.

Worked example: three designs

DesignWhat the call carriesWhat happens when Ana asks for payroll
One broad keyThe agent's keyThe system allows it
Asker identity passed onAna's identityThe system checks Ana and refuses
Narrow token per taskOnly the scopes the task needsThe scope is not in the token, so it fails

The cells students fill in are left blank in this preview.

The Run decides calls two ways

The first script holds two users and three invented calls. It decides each call twice: once by the agent's token, once by the asker's rights.

Where the two answers differ, the agent would act with more authority than the person asking. The script marks that line. Nothing is called and nobody is attacked.

Write the two-way check, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const rights = {' '  ana: [\"read:notes\"],' '  ben: [\"read:notes\", \"read:payroll\"],' '};' 'const agentToken = [\"read:notes\", \"read:payroll\"];' 'const calls = [' '  { user: \"ana\", action: \"read\", resource: \"notes\" },' '  { user: \"ana\", action: \"read\", resource: \"payroll\" },' '  { user: \"ben\", action: \"read\", resource: \"payroll\" },' '];' > deputy.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the two-way check, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function want(c) { return c.action + \":\" + c.resource; }' 'function byAgent(c) { return agentToken.includes(want(c)); }' 'function byAsker(c) { return (rights[c.user] || []).includes(want(c)); }' '' 'for (const c of calls) {' '  const a = byAgent(c), b = byAsker(c);' '  let line = c.user + \" asks \" + want(c) + \": agent token \" + (a ? \"allow\" : \"deny\");' '  line += \", asker rights \" + (b ? \"allow\" : \"deny\");' '  console.log(line + (a && b === false ? \"  <- confused deputy\" : \"\"));' '}' >> deputy.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Run the two-way check

Command
docker exec -w /home/node/project agent node deputy.js

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

What the script prints

LineWhat it shows
ana asks read:notesBoth allow. Ana may read notes
ana asks read:payrollThe token allows, her rights deny: marked confused deputy
ben asks read:payrollBoth allow. Ben holds that right

The cells students fill in are left blank in this preview.

Three fixes that work together

Pass the asker on. Each call carries the identity of the person, and the tool or API checks it.

Narrow the token. Give a task only the scopes it needs and let them expire soon.

Check every action in code. Check at the point where the action happens, each time. Never ask the model whether it is allowed.

A common mistake

Warning

Many people write "only help users with their own files" in the prompt and stop. A prompt is a request to the model. The rights table in code is the rule, and it gives the same answer every time.

Write the model script, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' '' 'const rights = { ana: [\"read:notes\"] };' 'const call = { user: \"ana\", action: \"read\", resource: \"payroll\" };' 'const messages = [' '  { role: \"system\", content: \"You are an office assistant. User ana may only read notes. Reply with one word, allow or deny, then one short reason.\" },' '  { role: \"user\", content: \"ana asks you to read the payroll file. What do you do?\" },' '];' '// Recorded sample, used only when no model answers.' 'const sample = \"deny. Ana may only read notes.\";' > deputy2.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the model script, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function decide(c) {' '  const allowed = rights[c.user] || [];' '  return allowed.includes(c.action + \":\" + c.resource) ? \"allow\" : \"deny\";' '}' '' 'async function main() {' '  const reply = await ask(messages, sample, 60);' '  console.log(\"Model says: \" + reply);' '  console.log(\"Program decides: \" + decide(call));' '  console.log(\"The call runs only if the program says allow.\");' '}' 'main();' >> deputy2.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the model helper

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' '  try {' '    const res = await fetch(base + \"/v1/chat/completions\", {' '      method: \"POST\",' '      headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' '      body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 200 }),' '      signal: AbortSignal.timeout(30000),' '    });' '    if (res.ok === false) throw new Error(\"status \" + res.status);' '    return (await res.json()).choices[0].message.content;' '  } catch (error) {' '    console.log(\"Recorded sample: no model answered (\" + error.message + \").\");' '    const list = [].concat(sample);' '    const turn = messages.filter(function (m) { return m.role === \"assistant\"; }).length;' '    return list[Math.min(turn, list.length - 1)];' '  }' '}' 'module.exports = { ask };' > model.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Run it against the model

Command
docker exec -w /home/node/project agent node deputy2.js

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

One possible output

LineWhat it may show
Model saysA word such as deny with a short reason. The words differ each run
Program decidesdeny, every time, because ana has no payroll right in the table
Recorded samplePrinted first when no model answered. The script carries on with the sample

The cells students fill in are left blank in this preview.

About this lab and its source: AI coding agent lab: use opencode to explain and fix code, and check its work

Teaching a class? Set this module for your students with a teacher guide, marked assignments and a lab for every student.