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
| Word | What it means | Example |
|---|---|---|
| Confused deputy | a program that uses its own authority for a person who lacks it | |
| Authority | what an identity is allowed to do, here as actions on resources | read:payroll |
| Scope | one named permission in a token | |
| Narrow token | a token with only the scopes the task needs, for a short time | |
| Asker | the 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
| Design | What the call carries | What happens when Ana asks for payroll |
|---|---|---|
| One broad key | The agent's key | The system allows it |
| Asker identity passed on | Ana's identity | The system checks Ana and refuses |
| Narrow token per task | Only the scopes the task needs | The 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
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
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
docker exec -w /home/node/project agent node deputy.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
What the script prints
| Line | What it shows |
|---|---|
| ana asks read:notes | Both allow. Ana may read notes |
| ana asks read:payroll | The token allows, her rights deny: marked confused deputy |
| ben asks read:payroll | Both 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
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
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
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
docker exec -w /home/node/project agent node deputy2.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
One possible output
| Line | What it may show |
|---|---|
| Model says | A word such as deny with a short reason. The words differ each run |
| Program decides | deny, every time, because ana has no payroll right in the table |
| Recorded sample | Printed first when no model answered. The script carries on with the sample |
The cells students fill in are left blank in this preview.