AI Agents
How to apply least privilege to AI agents: narrow tools instead of generic ones, scoped credentials, tenant checks in code, allowlists and limits that cap the worst case.
When people talk about AI agent security, they usually talk about prompt injection: a malicious instruction hidden in a web page or an email that convinces the agent to do something it should not. That threat is real, and we cover it in our article on agent security and prompt injection.
But defending against every possible manipulation is not a winnable game. Models misread instructions. Users phrase things ambiguously. New injection techniques appear constantly. The more reliable defence is to assume the agent will sometimes do the wrong thing, and make sure the wrong thing cannot be very bad.
That is the principle of least privilege: give the agent exactly the access its task requires and nothing more.
For every agent, ask: if this agent did the worst thing its tools and credentials allow, what would happen?
An agent with a read-only search tool over public documentation has a tiny blast radius. An agent with a database connection string, a shell and an email API has an enormous one. The second agent might be just as well-behaved as the first in testing. The difference only shows on the bad day.
Least privilege is about shrinking that worst case before it happens, through the tools you give the agent, the credentials those tools use, and the limits around them.
The single biggest decision is the shape of your tools. Compare two ways of giving a support agent access to orders.
The generic way is one tool: run_sql(query). It is quick to build and the model can answer any question with it. It can also drop a table, read every customer's data, or update prices, because SQL can do all of those things.
The narrow way is several small tools: get_order(orderId), list_recent_orders(limit), get_shipping_status(orderId). Each runs one fixed, parameterised query. The agent can do everything the support task needs, and nothing else, no matter what it is told.
Narrow tools have other benefits too. They are easier for the model to use correctly, because each has a clear purpose. They are easier to test. And they are easier to audit, because the log says "get_order 1042" rather than a 40-line query.
Generic tools like a shell or raw SQL have their place, but that place is a sandbox: an isolated container with no production credentials, where any command is acceptable because nothing important can be reached.
A system prompt that says "only access orders belonging to the current customer" is a request. The model will usually follow it. Usually is not good enough for data isolation.
The rule has to live in the tool. In particular, never take the identity of the user or tenant from the model's arguments. Take it from the authenticated session that started the run, and apply it inside every query.
// The model controls orderId. It does not control who is asking.
async function getOrder(input: { orderId: string }, ctx: RunContext) {
const order = await db.orders.findOne({
id: input.orderId,
customerId: ctx.customerId, // from the session, not from the model
});
if (!order) {
// Same answer for "does not exist" and "belongs to someone else",
// so the tool cannot be used to probe for other customers' orders.
return { error: 'No order with that id on this account.' };
}
return summarise(order);
}
If the model is tricked into asking for order 9999, which belongs to someone else, the tool simply finds nothing. There is no instruction to obey or disobey. The capability does not exist.
In a multi-tenant system, put this check in one place that every tool passes through, rather than repeating it in each tool. A single choke point is much harder to forget when someone adds a new tool next year.
Tools are code, and code has bugs. The credentials behind each tool are a second line of defence.
Our article on MCP server authentication covers how to apply these ideas when your tools are served over the Model Context Protocol.
When a tool must accept open-ended input, such as a URL to fetch, a file path to read or a recipient to email, define what is allowed rather than what is forbidden.
../ cannot escape it.Blocklists fail open: anything you did not think of is allowed. Allowlists fail closed: anything you did not think of is refused, and someone has to make a decision to add it.
The fetch tool deserves particular care. An agent that can request any URL can be used to reach internal services that are not exposed to the internet, or to send data out to an attacker's server inside the query string. Restricting outbound destinations closes both doors.
Even a perfectly scoped tool can do harm if it runs a thousand times. Put numeric limits on anything with side effects:
Limits turn a potential disaster into an incident. An agent that sends 20 wrong emails before hitting a cap is a bad afternoon. One that sends 20,000 is a much worse week.
It is tempting to build one capable agent with every tool. Splitting work across agents with different permissions is usually safer.
A common pattern is to separate reading from acting. A research agent with broad read access, including untrusted web pages and incoming email, gathers information and writes a summary. A second agent with write access works only from that summary, never from the raw untrusted content. An injection hidden in a web page can influence the summary, but it never reaches an agent that holds the power to act on it directly. Our article on the supervisor pattern covers how to structure agents that delegate to each other.
Before giving an agent a new tool, ask:
The model will keep getting better at following instructions. Least privilege means you do not have to rely on that. It holds even on the day the instructions fail.