A small agent that can actually do things, safely. Least-privilege tools, a hard step cap, an approval gate before anything irreversible, and an injected document that tries to make it misbehave, and fails.
What you'll build
A ReAct agent with two or three narrow tools (a read-only lookup, a write action with a policy cap), a loop that cannot run forever, validation on every tool call, and a human-approval gate before any write. Then you attack it and watch it hold.
Give the brilliant, unpredictable contractor a keycard that only opens the supply closet, a spending limit, and a rule that the safe needs your signature too. Now it can help without being able to rob you.
The shape
flowchart LR
subgraph UNTRUSTED["UNTRUSTED"]
U["User text"]
D["Retrieved docs"]
end
U --> M{"Agent loop
step cap"}
D --> M
M --> GATE["Trusted code
validate + approval"]
GATE --> ACT["Tool action"]
classDef un fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
class U,D un; class GATE,ACT ok;
Starter code
const TOOLS = {
lookupOrder: { args: { orderId: "string" }, write: false },
refundOrder: { args: { orderId: "string" }, write: true }, // needs approval
};
async function runAgent(messages, maxSteps = 6) {
for (let step = 0; step < maxSteps; step++) { // hard cap: never loops forever
const res = await client.chat.completions.create({ model: "gpt-4o-mini", messages, tools });
const msg = res.choices[0].message;
messages.push(msg);
if (!msg.tool_calls) return msg.content; // final answer
for (const call of msg.tool_calls) {
const name = call.function.name;
if (!(name in TOOLS)) continue; // reject unknown tool
const args = JSON.parse(call.function.arguments);
if (!validArgs(name, args)) continue; // validate every arg
if (TOOLS[name].write && !humanApproves(call)) { // approval gate on writes
messages.push(deny(call, "needs approval"));
continue;
}
const result = execute(name, args); // server re-checks policy inside
messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
}
return "stopped: step cap reached";
}
Do this, in order
You're done when
Go further
lookupOrder can only read orders the current user owns.Wrapping up
Hand in your agent, the refused-injection trace, and your one-paragraph threat model. This becomes your Project 3 (Agent Architecture Decision + Threat Model).
The whole lab lives or dies on step 5. Provide the injected document yourself so everyone attacks the same payload, then have them show the trace where the agent refuses. The students whose agents comply learn the real lesson: the fix was never a better prompt, it was the approval gate and the least-privilege tool.
Emphasise that this is defensive: they are hardening their own agent, not attacking anyone. The threat model is the deliverable that turns "it seems fine" into "here is the worst case and here is why it is survivable".