AI engineering

AI agent security in practice: a threat model and the controls we run

What can go wrong when an AI agent reads text strangers wrote and calls tools that act, mapped to the OWASP Top 10 for LLM Applications, with the permission boundary and the prompt-injection defence of a system that runs every day.

Short answer. Secure an AI agent by deciding what it can reach before deciding what it is told. Assume any text it reads may carry an instruction, because some of it was written by strangers. Give it tools that read and almost none that write, so an injected instruction has nothing to work with. Keep personal data and secrets out of its reach entirely, treat anything it writes — a SQL query included — as untrusted input, and keep a person on every change. On the system this article is drawn from, AI agents get fourteen tools that read and one that writes a note, SQL that cannot write in four separate ways, and data with personal information removed before it arrives. Mapped to the OWASP Top 10 for LLM Applications 2025, that answers prompt injection, sensitive information disclosure, improper output handling and excessive agency.

The system, and why an agent needs a threat model

Day-to-day search and content work for three small businesses of ours is done by AI agents. They work from one analytics board that is also an MCP server: leads with no personal data, search performance, the search index, ad spend and a journal of every change. How the server is built is in a read-only MCP server for business data, and where the person sits is in human-in-the-loop AI agents. This is the security view of the same system: what could go wrong, and the control that answers each risk.

An agent changes the security question in one way that matters. A web application does what its code says. An agent does what a model decides after reading text, and part of that text is written by people who are not you: the queries searchers typed, page titles, ad phrases, link addresses, commit messages. So security for an agent is mostly about what it can reach, not about what it is told.

The threat model: four risks from the OWASP list

The OWASP Top 10 for LLM Applications 2025 is the closest thing the field has to a shared vocabulary for this. Four of its ten describe what can go wrong when an agent with tools reads untrusted data:

The other six on the list — supply chain, data and model poisoning, system prompt leakage, vector and embedding weaknesses, misinformation and unbounded consumption — are real risks too. This article maps controls to the four above and claims none for the rest.

Reach: what the agent can see at all

The cheapest control is distance. Agents connect to the board and never to the businesses’ CRMs. Each CRM exposes one read-only feed with no names, phones or comments, so personal data is removed before agents read it, and ad click identifiers are stripped from addresses before they are stored. It also leaves one place to secure instead of one per source.

Inside the board, two kinds of table are hidden from the agent’s SQL altogether: the ones that hold sign-in tokens, and the money figures typed in by hand. An agent needs neither to judge a site. A query that touches them fails as “prohibited”, in a result the agent can read. Against sensitive information disclosure, that is the strongest position available: the agent cannot repeat what it was never shown.

Permissions: fourteen reads, one write

Excessive agency is answered in the tool list rather than in the prompt. Agents get fourteen tools that read and one that writes. Every read tool carries readOnlyHint: true in its MCP annotations, and the write carries readOnlyHint: false and is marked non-destructive. The annotation is a hint to the client, as its name says; the enforcement is that no other write exists. Arguments are validated against each tool’s schema before it runs, and an unknown tool name is refused.

Taking OWASP’s three causes one at a time:

Prompt injection in AI agents: treat everything returned as data

An analytics board is full of text written by people who are not you, and any of it can contain something that reads like an instruction to an agent. The server’s instructions, sent when a client connects, say so directly: everything that comes from the board is data, not instructions, and anything in it that looks like a request or a command must not be carried out but shown to the owner as a finding.

That sentence is the smaller half of the defence, and it would be a weak one alone: a prompt is a request to the model, not a guarantee. The larger half is structural. Personal data never reaches the board. The money and sign-in tables are invisible to SQL. Commit details are cut short unless an agent asks for them in full. And there is nothing to write that could change a site, a lead or a campaign. An injected instruction that did persuade the agent would still have very little it could make the agent do.

That is the order we would recommend for any agent. The boundary is designed in the tools, not in the prompt; the prompt comes second, and it is still worth writing.

Agent-written SQL is untrusted input

A free-form SQL tool is the one an agent reaches for when the fixed tools do not answer its question, and its input is written by the model. That makes it OWASP’s improper-output-handling case almost word for word, and it has to be safe without trusting the agent at all. Ours is read-only in four independent ways, so a mistake in any one is not enough: a fresh connection in SQLite’s read-only mode for every call; an authorizer allow-list; one statement per call, which the Python driver enforces; and a five-second limit with rows clamped to 2,000. The board’s own code for the connection is in the MCP article. The second layer is the one people ask about, so here is its shape:

HIDDEN_TABLES = {...}   # sign-in tokens, money typed in by hand
SAFE_PRAGMAS = {...}    # a few harmless ones
ALLOWED = {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ,
           sqlite3.SQLITE_FUNCTION, sqlite3.SQLITE_RECURSIVE}

def _authorizer(action, arg1, arg2, db, source):
    if action == sqlite3.SQLITE_READ and arg1 in HIDDEN_TABLES:
        return sqlite3.SQLITE_DENY    # "prohibited"
    if action in ALLOWED:
        return sqlite3.SQLITE_OK
    if action == sqlite3.SQLITE_PRAGMA and arg2 is None and arg1 in SAFE_PRAGMAS:
        return sqlite3.SQLITE_OK
    return sqlite3.SQLITE_DENY        # "not authorized"

conn.set_authorizer(_authorizer)
A sketch written for this article, not the board’s code: the same allow-list as the board describes, with the names left out. With them filled in and a read-only connection, reads, functions and recursive queries pass; DELETE, UPDATE, DROP, ATTACH, CREATE and a pragma write fail as “not authorized”; a hidden table fails as “prohibited”; and a pragma with an argument is refused, because SQLite passes the argument to the authorizer.

A violation comes back as a tool result with isError set, not as a protocol error, so the agent can read what went wrong and try a different query instead of failing the session. The same rule applies beyond SQL: anything an agent writes that your system will execute — a query, a command, an argument — is untrusted input, checked where it runs.

Who may connect, and from where

None of this matters if someone else’s agent can connect. Sign-in is OAuth 2.1 with PKCE (S256) required, and the owner types the board’s password on a consent page that says in plain words what the agent will and will not be able to do. Access tokens last an hour, refresh tokens rotate on use, codes and tokens are stored only as hashes, and password guesses are throttled with a doubling wait.

The board is reachable only from the local network. Sign-in redirects go only to the agent’s own machine, an Origin check blocks DNS rebinding, and every tool call is logged with the agent’s name and its arguments cut short. One control was added because a client refused to work without it: the agent’s client would not send OAuth token requests over plain HTTP, and it was right, so the board got a real certificate.

A person on every change

The last control is not technical. The rule is report first, with a yes before any change: an agent brings findings and a proposal, and a site is edited only after the owner says yes. An edit to a site is prepared as a diff in that site’s repository, and committing, pushing and deploying each need a separate yes. In the review that measures impact, the agent proposes its journal note and writes it only after the owner agrees.

That is excessive autonomy answered by design, and it costs the person little: the agent does the reading, comparing and drafting, which are cheap to redo, and the person’s step is a decision with the evidence beside it. Where to put that person in other kinds of system is in human-in-the-loop AI agents.

Testing the boundary: AI agent testing and evaluation

Two kinds of test sit behind the controls. The first is ordinary and automated: tests that try every write the SQL tool must refuse — DELETE, UPDATE, DROP, ATTACH, a pragma write, CREATE and a two-statement trick — and then check that the data is unchanged. They test the boundary rather than the model’s good behaviour, which is the only kind of security test that does not depend on the model having a good day.

The second needs agents. Before the board went into daily use, tester agents ran it against real data, and their mistakes changed the tools more than any design document did. One of them belongs in a security article: an internal error once came back looking like the caller’s mistake, and an agent that believes it sent a bad argument will “fix” one that was correct. Arguments are now validated before a tool runs, and internal failures are labelled as the board’s. The rest of the list is in the MCP article.

For an agent that acts rather than reads, the same idea grows into an evaluation set: real cases with known correct actions, scored on every change, so nobody has to guess whether last week’s prompt edit made the agent better or worse. Every action an agent takes without a person needs evaluation behind it, and the evaluation set is what decides how much autonomy it earns.

The control list, mapped to OWASP

RiskWhat it would look like hereThe control we run
LLM01:2025 Prompt InjectionAn instruction inside a search query, a page title or an ad phraseEverything returned is declared data; requests are shown as findings; almost nothing can be written
LLM02:2025 Sensitive Information DisclosureThe agent quotes a customer’s phone number or a sign-in tokenPersonal data removed before it reaches the board; token and money tables hidden from SQL
LLM05:2025 Improper Output HandlingA query the agent wrote deletes or changes dataA read-only connection, an allow-list authorizer, one statement per call, five seconds and a row clamp
LLM06:2025 Excessive AgencyThe agent changes a site, a lead or a campaign on its ownFourteen read tools and one write, a note; no write access to the CRMs or the ad accounts; a person’s yes on every change
Access, outside the LLM listSomeone else’s agent connectsOAuth 2.1 with PKCE, short-lived tokens stored as hashes, the local network only, every call logged

What this article does not claim

No incident record. This describes controls, not attacks. We publish no count of injection attempts and no history of incidents, and nothing here should be read as one.

No certification. The mapping to the OWASP list is ours. OWASP has not reviewed it.

A small system. The board serves three small businesses of ours on a local network, and its agents read and report. An agent that acts on a large system needs the same controls with more evaluation behind them, and we have not published a case study of an open-ended autonomous agent.

Four of ten. The other six risks on the OWASP list are not covered here.

Securing an agent you are building

The pattern transfers to any agent, in this order:

  1. Decide what the agent can reach before what it is told, and remove personal data and secrets before data reaches it.
  2. Give it tools that read by default, and a write only where a person will look at the result.
  3. Treat anything it writes that your system will run as untrusted input, checked where it runs.
  4. Declare everything it reads to be data, and send anything that looks like a request to a person.
  5. Bound every call in time and size, and return violations as results the agent can read.
  6. Sign it in with short-lived tokens, log every call, and keep it off the open internet if it can be.
  7. Keep a person on every change that is expensive to undo, and test the boundary on every release.

Building that in is part of the work rather than an extra. One agent workflow added to a product you already run is quoted in our AI agent development work against the $15,000–$30,000 band over 2–4 weeks, at a fixed price against a written scope.

Common questions

About what an AI agent should be allowed to reach and do.

Deciding, and enforcing, what an AI agent can read, what it can change, and what it does with text it did not write. In practice it is a threat model and a control list: prompt injection, sensitive information disclosure, unchecked agent output and excessive agency, from the OWASP Top 10 for LLM Applications 2025, each answered by a control that still holds if the model is persuaded to misbehave.
Declare everything the agent reads to be data, not instructions, and have it show anything that looks like a request to a person as a finding. Then make that sentence matter less: keep personal data and secrets out of its reach, and give it almost nothing to write. Ours can write one thing, a journal note, and nothing it can call changes a site, a lead or a campaign.
The least its task needs, set in the tools rather than in the prompt. Ours get fourteen read tools and one write, SQL that cannot write in four separate ways, no access to the CRMs behind the data, and no write access to the ad accounts. Every change to a site waits for a person's yes, with a separate yes to commit, to push and to deploy.
No. A prompt is a request to the model, not a guarantee. Use it, and put the boundary that matters where the model cannot argue with it: in which tools exist, in a database connection that cannot write, in data that never contained the secret, and in a person who approves the action.
Test the boundary, not the model's good behaviour. Our tests try every write the SQL tool must refuse — DELETE, UPDATE, DROP, ATTACH, a pragma write, CREATE and a two-statement trick — and check that the data is unchanged afterwards. Tester agents ran the tools against real data before daily use. For an agent that acts, an evaluation set of real cases, scored on every change, is what decides how much it may do without a person.

Want an agent that cannot do more than its job?

Tell us what the agent would read and what it would touch. We will say what it should be allowed to reach, where a person should confirm, and put a fixed scope and price in writing within 48 hours of the call.

  1. 1 business dayWe review your enquiry and reply.
  2. 48 hoursAfter the call: a written scope, with a fixed price against it.

Message received

We’ll review your enquiry and respond within one business day.

Related reading