An agent is a language model, a loop, and a set of tools holding real credentials. The model is not a security boundary and will not become one: it has no dependable way to separate an instruction from a sentence it happened to read. The tool layer is the boundary, and it is the part you fully control.
The confused deputy, restated for agents
A confused deputy is a privileged program tricked by a less-privileged party into misusing authority it holds on someone else's behalf. Your agent is exactly that: it carries a database session, a ticketing token, an SMTP relay, a repo checkout. Untrusted content it reads - a support ticket, a fetched page, an attached PDF - can supply the intent. The attacker never needs a credential of their own. They borrow the agent's.
That reframing is the useful part. "Could the model be tricked?" is unanswerable. "What can happen if it is?" is a design question with a written answer.
Give permissions to tools, not to the agent
Model authority per tool and per call, and enforce it in the tool implementation rather than in the system prompt. A rule the model can be talked out of is not a control.
{
"agent": "support-triage",
"default": "deny",
"tools": {
"search_tickets": { "effect": "read", "scope": "tickets:own_queue", "approval": "none" },
"post_comment": { "effect": "write", "scope": "tickets:own_queue", "approval": "none" },
"http_get": { "effect": "read", "scope": "allowlist:docs.internal", "approval": "none" },
"refund_payment": { "effect": "write", "scope": "orders:amount_lte_50", "approval": "human" }
}
}
Three properties do the work. Default deny, so a newly added tool is unreachable until someone scopes it. A scope narrower than the credential behind it - the service account can read every ticket, the agent may read one queue. A human gate on anything irreversible: money, mail to outsiders, deletions, deploys, permission grants. The approval screen has to show what the model saw, or the human is a rubber stamp with a job title.
Sandbox execution, and keep credentials outside the sandbox
If the agent runs code or renders files, run that in a container per session: non-root user, no host mount, read-only root filesystem where the workload allows it, and CPU, memory and wall-clock limits.
The part teams miss is credential placement. Hold the tokens in a tool proxy that runs outside the sandbox, so generated code can invoke a tool but cannot read the secret the tool uses. Then default-deny egress and allowlist domains at that proxy. Data leaving an agent rarely looks exotic: it is usually a plain outbound request with the data in the query string, or a remote image URL that the chat client dutifully fetches while rendering the reply. Blocking unknown egress and refusing to auto-load remote images closes both paths.
Track provenance, not just prompts
Tag every span of context with its origin - system, authenticated user, or retrieved. A tool call whose justification traces back to retrieved content deserves a stricter policy than the identical call the user typed.
The structural version is a plan-and-execute split: fix the sequence of steps before untrusted content enters the context, then let that content fill in values but never add steps. It costs flexibility and buys a bounded action space, which is the right trade for any agent touching production.
Log tool calls the way you log process creation
{"ts":"2026-08-30T09:14:02Z","agent":"support-triage","session":"s-4471","tool":"http_get",
"args":{"url":"https://docs.internal/runbooks/refunds"},"decision":"allow",
"policy":"allowlist:docs.internal","trigger":"retrieved:ticket-88231","latency_ms":142}
Insist on five fields: the tool, the arguments with secrets redacted, the allow-or-deny decision, the policy rule that produced it, and the provenance of whatever triggered the call. With those, the detections look like ones you already write:
- a burst of denials inside one session - something is steering the agent
- a tool sequence never before seen for that agent, which is stack counting applied to a new log source
- any human-gated tool reached from retrieved content rather than from a user turn
- output containing secret-shaped strings that appear in no user turn
What good looks like
Write down the answer to "assume the model is fully attacker-controlled for this session" and make it boring: it can read one queue, comment on one queue, and fetch one documentation host. That sentence, not a cleverer system prompt, is what makes an agent safe to deploy.
