The Tool Hardening Pass
A repeatable two-day audit you run over an existing agent’s toolset before it goes anywhere near production: inventory what every tool can reach, classify by reversibility and blast radius, tighten the schemas, rewrite the descriptions to say when not to use the tool, make errors readable by a model, then decide gate or notify or free — one tool at a time.
Someone built the agent in six weeks and it works. It has eleven tools, because every time it got stuck someone added a tool. Now it is two weeks from a production launch and you have been asked to make it safe.
Do not start by writing a policy document. Start by reading the eleven tool definitions, because that is where the actual permissions live. A tool definition is the agent’s attack surface written down — the schema is the contract, the description is the only instruction the model reliably reads, and the credential behind it is the real blast radius. Everything else you might do to secure this agent is downstream of getting those three right.
The hardening pass is a fixed sequence you run over the whole toolset, tool by tool, in one or two focused days. It is deliberately boring and deliberately complete: nine steps, one spreadsheet, one pull request per tool. You are not redesigning the agent. You are removing the ways it can hurt someone by accident.
The running example is an internal customer-support agent at a subscription company. It reads tickets, looks up orders and accounts, drafts replies, and — because someone made it so in week four — issues refunds. Two of its tools carry this note: db_query, a read that takes raw SQL, and issue_refund, a write that takes a dollar amount. Both are recognisable. Both are wrong in the ways real tools are wrong.
The theory under this pass is taught elsewhere and this note assumes it: the containment ladder and the "make the dangerous thing impossible" argument from Tool Scoping and Least Privilege: Making the Dangerous Thing Impossible, the wire format and schema mechanics from Function Calling Deep Dive: The Wire Under Tool Use, the runtime checks from The Guardrail Catalog: Runtime Checks, Mapped Per Cloud, credentials from Agent Identity, Auth, and Secrets, and third-party tool review from Building with MCP: Servers, Clients, and the Install Review. This is the build order, not the reasoning.
Key terms: tool contract, parameter schema, blast radius, least privilege, scoped credentials, egress control
The pass — nine steps, in this order, no skipping ahead
- 1. Inventory — every tool, and what it can actually reach
One row per tool. Columns: name, what the model sees (description + schema), the code path it calls, the credential it uses, the systems that credential can reach, and whether the effect is reversible. The last two columns are the ones nobody has written down before, and they are the ones that produce the surprises. Get the list from the code that registers the tools, not from the design doc.
- Does the inventory match what is deployed?
Diff your list against the tool list the running agent actually advertises. Typical findings: two debug tools nobody removed, a tool registered twice under different names, an MCP server contributing four tools the team could not name. If the lists differ, fix the list before hardening anything — you cannot harden a tool you did not know you had.
- 2. Classify by reversibility and blast radius
Two questions per tool: if this fires wrongly, can I undo it, and how many rows, accounts, tenants, or humans does it touch? The answers put each tool in a class, and the class decides how much of the rest of the pass applies. This is where you spend the arguing time; the matrix below is the artifact.
- 3. Tighten the schema — enums, patterns, bounds, required
Replace free-text with enums. Replace numbers with bounded numbers. Add regex patterns to ids. Mark every field required that the code assumes. Delete every parameter the model should never choose — tenant id, actor id, environment — and inject those server-side. A parameter the model cannot express is a mistake it cannot make.
- 4. Rewrite descriptions — including when NOT to use it
Most descriptions say what the tool does and stop. Add the negative half: when to prefer a different tool, what this tool is not for, what it will refuse. Then read the whole toolset as one document and hunt for overlap — two tools that plausibly answer the same request is a coin flip you have shipped.
- 5. Compact results, actionable errors, no throws
Trim results to the fields the model needs to decide, cap the payload, and make every error a message the model can act on. Empty result sets return a structured "no results", never an exception. This step buys you accuracy and cost as well as safety, which is why it is the easiest one to get merged.
- 6. Decide gate / notify / free, per action
Per action, not per tool and not per agent.
issue_refundunder 20 dollars can be free with a notification; over 200 it needs a human; a refund on an account flagged for fraud review needs a human regardless of amount. The decision tree below is the version you can hand to a product owner. - 7. One credential per tool, scoped to that tool
The shared service account with database write is the single largest finding in most passes. Split it: a read-only role for the read tools, a role that can write exactly the refund table for the refund tool, short-lived where the platform supports it. Taught properly in Agent Identity, Auth, and Secrets.
- 8. Set the egress allowlist and turn on default-deny
List the hosts the tools legitimately need, deny everything else at the network layer, and log the denials. This is the step that turns a data-exfiltration bug into a log line. If you cannot enumerate the hosts, that is the finding.
- 9. Prove it — red tests that must fail, and a rerun date
For each tightening, a test that tries the thing you just made impossible and asserts a clean refusal. Then put the pass in the calendar: every new tool triggers a mini-pass, and the whole toolset gets re-run each quarter, because tools accumulate the way this one accumulated eleven.
| Class | Reversibility | Blast radius | What the pass requires |
|---|---|---|---|
Scoped read — one record the caller already has a right to | Nothing to undo. The risk is disclosure, not damage. | One record, one tenant. Bounded by construction if the tenant filter is server-side. | Tighten the id pattern, inject the tenant server-side, cap the result size. No gate. Cheapest row in the table and where most of your tools should end up. |
Broad read — search, list, or anything that takes a query | Nothing to undo, and that is exactly what makes it feel safe when it is not. | Potentially every row in the store. A read that can select across tenants is a breach waiting for one bad filter. | Enumerate the queries as an enum of named reports; never accept a query language. Hard row limit, hard column allowlist, tenant scoping enforced by the credential and not the argument. This is the |
Reversible write — status changes, tags, drafts, notes | Undoable by a person in under a minute, and you can point at the undo procedure. | One record, occasionally a handful. Wrong but recoverable. | Bounded enums for status values, idempotency key on the call, an audit trail entry that names the agent run. Notify, do not gate — gating these is how you teach humans to rubber-stamp. |
Irreversible write, small — refunds, payments, cancellations | Money moved. You can compensate but you cannot undo, and the customer saw it. | One customer per call — but a loop turns "one customer" into four hundred in ninety seconds. | Amount bounds in the schema, idempotency key, per-run and per-day rate limits, and a threshold gate. Plus the rate limit the model cannot see, because bounds in a schema do not stop repetition. |
Irreversible write, broad — bulk update, delete, migrate, deploy | No. This is the row where the incident write-up gets written. | Every row matching a predicate the model wrote. Unbounded by default. | Default answer: the agent does not get this tool. If it must, the tool takes an explicit list of ids (never a predicate), caps the list length, and gates every call. Soft-delete rather than delete, so there is something to restore. |
Outbound communication — email, chat, webhook, ticket comment | Unsendable. A retracted message is a message that was read. | Reputational and unbounded — one confused agent, one distribution list, several thousand humans. | Recipient allowlists over recipient validation, template-constrained bodies where you can, rate limits per run, and the egress allowlist. This is also the leg that completes the lethal trifecta, so read it alongside the untrusted-input question, not on its own. |
Code or command execution — shell, eval, notebook, browser | Depends entirely on the container, which means: assume no. | Whatever the process can reach — filesystem, network, credentials in the environment. | Not a schema problem. This one needs isolation: ephemeral environment, no ambient credentials, egress denied by default. See Sandboxing and Computer Use: Isolation for What You Cannot Pre-Approve; tightening the schema on a shell tool is theatre. |
db_query — before
Written in week two by someone who needed the agent to answer questions about orders and did not yet know which questions.
{
"name": "db_query",
"description": "Run a SQL query against the support database to look up customer data.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "The SQL query to run" }
},
"required": ["query"]
}
}
Five findings in nine lines.
The parameter is a programming language. The model can express any read the credential permits, and the credential is a shared service account with SELECT on every table including payment_methods and internal_notes. There is no tenant filter, so the only thing standing between customer A and customer B’s order history is the model writing a WHERE clause correctly every single time.
The result is the whole result set. One SELECT * FROM orders WHERE created_at > ... fills the context window, costs real money, and pushes the actual ticket out of the model’s attention.
Errors are exceptions. A syntax error raises, the harness turns it into "Tool execution failed", and the model retries the same broken query three times because nothing told it what was wrong.
No results is also an exception in this implementation — an empty rowset trips an IndexError two lines later. The model reads a crash where the truth was "that order does not exist", and it improvises.
And the description invites all of it. "Look up customer data" is an instruction to use this tool for everything, including the four narrow lookups that already have their own safe tools.
db_query — after
The same capability, expressed as the five reads the agent actually needs. This took an afternoon, most of it spent reading traces to find out which queries the model had been writing.
{
"name": "lookup_orders",
"description": "Fetch orders for the customer in the current conversation, newest first. Returns at most 20. Use this for order history, shipping status, and refund eligibility questions. Do NOT use it to find a customer — the customer is already fixed by the session. Do NOT use it for payment card details, which this tool never returns: escalate those to a human instead.",
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["any", "open", "shipped", "delivered", "cancelled", "refunded"],
"default": "any"
},
"since": { "type": "string", "format": "date", "description": "ISO date; defaults to 12 months ago" },
"limit": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 }
},
"required": [],
"additionalProperties": false
}
}
The customer id is gone from the schema. It comes from the authenticated session on the server side, so cross-customer reads are not something the model can ask for. That single deletion closes the whole class.
status is an enum and limit has a ceiling. The maximum blast radius of this tool is now twenty rows belonging to one customer.
The credential is a read-only role with SELECT on four views — not tables — and the views omit payment_methods entirely. The schema is the polite constraint; the credential is the enforced one.
The result is nine fields per order, dates as ISO strings, money as integer cents with a currency code, and a truncated: true flag when there are more. Down from an average of 4,100 tokens per call to about 380.
The remaining four reads became lookup_account, lookup_shipment, search_help_articles, and get_refund_history. db_query was deleted. The analytics team kept their own copy behind their own credential, which is where it belonged.
What that bought
Enumerating the reads made the security question tractable. Nobody can review a tool that accepts SQL — the review is "is the model good at writing WHERE clauses", and that is not a security control, it is a hope. Five named reads can each be reasoned about in a sentence, and each one is a row in the classification matrix instead of a permanent unknown.
It got more accurate, not less capable. This is the part teams do not expect. The model had been writing subtly wrong SQL — joining on the wrong key, missing the soft-delete filter, mixing up created_at and updated_at — and confidently reporting the wrong answer. Every one of those bugs became impossible when the join lived in a view written by a person who knows the schema.
Two questions genuinely could not be answered any more, both from the analytics-flavoured tail: "how many customers in Ontario had a delayed order last month" and one variant of it. That is the honest cost. Neither was in the agent’s job description, and both were better served by the dashboard that already existed.
The negative half of the description did measurable work. Before, when a customer asked to update their card, the model tried db_query and returned a masked number it should never have seen. After, the "Do NOT use it for payment card details — escalate to a human" clause routed those to a person. That behaviour change came from prose, not from code — which is exactly why the prose gets reviewed like code.
One caution: a description is guidance, not enforcement. It shifts the distribution of the model’s behaviour and it is worth every minute you spend on it, but a model reading an injected instruction in a ticket body will happily ignore it. Descriptions make the right thing likely; schemas and credentials make the wrong thing impossible. You need both, in that order of trust.
issue_refund — before
The tool that made this pass urgent. It shipped in week four to close a support backlog.
{
"name": "issue_refund",
"description": "Issue a refund to the customer.",
"inputSchema": {
"type": "object",
"properties": {
"order_id": { "type": "string" },
"amount": { "type": "number" },
"reason": { "type": "string" }
},
"required": ["order_id", "amount"]
}
}
amount is an unbounded float. 89.99 is fine. So is 8999, if the model reads cents as dollars — which is exactly the mistake a currency-formatting change upstream will cause. So is 0.001. Nothing in the contract says a refund cannot exceed what the customer paid.
There is no idempotency key. The harness retries on timeout. The payment gateway succeeded and the response was lost. The customer gets two refunds, and you find out at month end.
Nothing constrains repetition. Even a perfectly bounded amount is unbounded in aggregate: an agent in a retry loop issues four hundred correct-looking 40-dollar refunds before anyone notices. This is excessive agency in the most literal sense — the OWASP LLM Top 10 lists it as LLM06:2025 Excessive Agency, and the agentic list promotes it to ASI02 Tool Misuse, citing the Amazon Q incident as the real-world example.
The gate lives in the prompt. The system prompt said "always confirm with a human before refunding over 100 dollars", and for weeks it worked, which is the dangerous part. A prompt instruction is a preference. Under an injected instruction in a ticket body, or a long conversation, or a model upgrade, it is nothing.
reason is free text and optional, so half the refunds in the ledger have no reason at all and finance cannot reconcile them.
issue_refund — after
{
"name": "issue_refund",
"description": "Refund part or all of a delivered or shipped order, in cents, up to the amount the customer actually paid. Requires the order to be at least 24 hours old. Do NOT use this for: cancellations before shipping (use cancel_order, which is reversible), duplicate charges (escalate — these need a payments human), goodwill credits (use issue_account_credit), or any order already refunded (check get_refund_history first). Refunds over 20000 cents are held for human approval and this tool returns status "pending_approval" — do not retry, and tell the customer a person is reviewing it.",
"inputSchema": {
"type": "object",
"properties": {
"order_id": { "type": "string", "pattern": "^ord_[0-9A-Za-z]{12}quot; },
"amount_cents": { "type": "integer", "minimum": 100, "maximum": 50000 },
"currency": { "type": "string", "enum": ["USD", "CAD", "EUR", "GBP"] },
"reason_code": {
"type": "string",
"enum": ["damaged", "not_delivered", "late_delivery", "wrong_item", "quality", "billing_error", "goodwill"]
},
"note": { "type": "string", "maxLength": 280 },
"idempotency_key": { "type": "string", "pattern": "^[0-9a-f]{32}quot; }
},
"required": ["order_id", "amount_cents", "currency", "reason_code", "idempotency_key"],
"additionalProperties": false
}
}
Integer cents with a floor and a ceiling. amount_cents cannot be a float, cannot be negative, cannot exceed 500 dollars. The server then checks it against what the order was actually paid — a bound the schema cannot express, so it lives in code, and violating it returns an error rather than raising one.
reason_code is a required enum, so the ledger reconciles and you get a distribution to look at. Finance found two miscategorised refund patterns in the first week from that column alone.
idempotency_key is required and passed through to the gateway, derived from the run id plus the order id. Retries are now free.
Three limits the model cannot see, enforced in the tool wrapper: 5 refunds per run, 100 per hour across all runs, and a circuit breaker that trips the kill switch on the whole refund capability at 200 per hour. The model is not told these numbers. It is told, when it hits one, that it hit one.
The gate, and the errors
The threshold moved out of the prompt and into the runtime. Over 20,000 cents, or any order on a fraud-flagged account, and the tool writes a pending record, posts it to the support queue, and returns status: "pending_approval" to the model. Nothing is refunded. The gate is a branch in code that the model cannot reach past, which is the only kind of gate that survives an injection. The description tells the model what will happen so it can set the customer’s expectation instead of retrying — description and enforcement pointing the same direction, with only one of them load-bearing.
Every error is a sentence the model can act on. Compare the two vocabularies:
before: Tool execution failed: HTTPError 422
after: REFUND_EXCEEDS_PAID: order ord_8f2b91a04c7d was paid 4999 cents;
you requested 8999. The maximum refundable amount is 4999.
Retry with amount_cents <= 4999, or escalate to a human.
before: Tool execution failed: IndexError
after: NO_RESULTS: no refunds found for order ord_8f2b91a04c7d.
This order has not been refunded. You may proceed.
before: Tool execution failed: HTTPError 429
after: RATE_LIMITED: this run has issued 5 refunds, which is the per-run
limit. Do not retry. Summarise the remaining cases for a human
and end your turn.
Three properties do the work. A stable machine-readable code the model can pattern-match and you can alert on. The specific values — what it asked for, what was allowed — because "invalid amount" gives the model nothing to correct toward. And an explicit instruction about retrying, because a model with no guidance retries, and a retry loop against a payment API is how a bug becomes an incident.
Empty is a result, not an exception. NO_RESULTS with a plain statement of what that means is the single highest-value error rewrite in this whole pass. Exceptions on empty sets are the most common cause of the failure where an agent invents a plausible answer: it read a crash, concluded the tool was broken, and fell back on what it "knew".
One thing errors must never contain: stack traces, SQL fragments, internal hostnames, or row counts from tables the model has no business knowing about. Errors go into the context, and anything in the context can end up in a reply to a customer or, if the run is compromised, in an attacker’s hands. Log the detail with the trace id; return the sentence.
"We will just tell the model the rules in the system prompt."
You already did, and it already worked for six weeks, which is why this objection is so persuasive. The problem is what a prompt instruction is: a strong prior over the model’s next tokens. It bends behaviour, it does not bound it.
Three things break it. A long conversation pushes the instruction far from the decision. A model upgrade changes how much weight it carries. And content the model reads — a ticket body, a PDF, a webpage — can contradict it with instructions that arrive later and more specifically, which is a losing position to defend from.
The test to apply: if an attacker could write text that the agent will read, could that text talk it out of this rule? If yes, the rule belongs in code. Keep the prompt version too — it makes the right behaviour likely, and likely is worth having. Just never call it a control.
"The model needs raw SQL / shell / a generic HTTP tool for flexibility."
Sometimes true. Usually it means nobody has enumerated the use cases yet, and the generic tool is standing in for that work.
Do the enumeration from traces, not from a meeting. Pull every call to the generic tool from the last month, cluster them, and count. In the support agent, 94% of db_query calls collapsed into five shapes. The tail was analytics that belonged to a different team and a different credential.
If a genuinely open-ended capability is required — a research agent that must fetch arbitrary URLs, a coding agent that must run tests — you have not failed the pass. You have identified the tool that needs containment instead of constraint: ephemeral environment, no ambient credentials, egress allowlist, results treated as untrusted input. Different mitigation, same audit. What you must not do is leave a generic tool sitting on a production credential because tightening it felt like a limitation.
How compact should a tool result be?
Small enough to fit in a decision, and no smaller. The working rule: return the fields the model needs to choose its next action, plus an id it can use to fetch more.
For the order lookup that meant nine fields, not the forty-one the ORM produced. Concretely: 4,100 tokens down to 380 per call, and a tool that stopped burying the customer’s actual question under JSON.
Also do these. Format dates as ISO strings, not epoch integers — models reason badly about epochs. Represent money as integer cents plus a currency code, never a float. Flatten one level of nesting where you can. Add truncated: true and a count when you cut a list, because a model that cannot tell truncation from completeness will confidently tell a customer they have three orders when they have thirty.
And keep the compaction on the server side of the tool. Trimming in the prompt ("only mention the relevant fields") leaves the tokens in the context and the cost on the bill.
What makes an error message actually usable by a model?
Four properties, and you can check each one in review.
A stable code. REFUND_EXCEEDS_PAID, not prose that changes with every refactor. The model can pattern-match it and you can alert on it.
The values. What was requested, what is permitted, which record. "Invalid amount" gives the model nothing to correct toward, so it guesses; "you requested 8999, the maximum is 4999" gets a correct retry on the first attempt.
A retry verdict. Explicitly: retry with a different argument, retry later, or do not retry. This is the field that prevents loops, and loops against a paid API are the most expensive class of agent bug — see Cost and Latency Budgets You Can Defend.
Nothing sensitive. No stack traces, no SQL, no internal hostnames, no other customers’ data. The error lands in the context window and the context window ends up in replies.
One more, easy to miss: errors must be reachable. If your harness swallows tool errors and substitutes "an error occurred", none of this work reaches the model. Check the actual transcript, not the tool’s return statement.
Why per-action gates rather than one approval step for the whole agent?
Because a gate on everything is a gate on nothing. Route every action through a human and you get an approval queue of thirty items an hour, a human clicking approve at a rate that precludes reading, and no protection at all when the one dangerous call arrives — the OWASP agentic threat taxonomy names this pattern directly as overwhelming the human in the loop.
The scarce resource is human attention, so spend it where reversibility is low and the amount is high. In the support agent that meant: reversible writes fire freely and post to a channel; refunds under 200 dollars fire and notify; refunds over 200 dollars, or on fraud-flagged accounts, wait for a person. Roughly 6% of actions gated, which is a rate at which the approver still reads the request.
Then measure it. If approvers say yes more than about 95% of the time, your threshold is too low and you are training them to rubber-stamp. If they say no more than a quarter of the time, the agent should not be attempting that action yet. Both numbers come out of the same log, and reviewing them quarterly is part of the pass.
Do I really need a separate credential per tool?
Per tool is the target; per class is the version that ships this quarter. The finding that matters is the one you almost certainly have: one service account, used by every tool, holding the union of every permission any tool ever needed. That account is the agent’s true blast radius, and it makes the whole classification exercise decorative — a "scoped read" tool running on a credential with delete rights is one prompt-injected SQL string away from being an irreversible broad write.
Order of work, cheapest first. Split read from write, which is usually an afternoon and eliminates the worst case. Then give the money-moving tools their own principal with rights to exactly their own tables. Then adopt short-lived credentials where the platform issues them, so a leaked token expires on its own.
Two things to check while you are in there. Nothing in the tool’s environment should hold a credential the tool does not use — an agent that can read env reads everything in it. And the credential should carry the run id into your logs, so the audit trail answers "which run did this" without a human correlating timestamps at 2am.
Step 6 — gate, notify, or free? Walk one action, not one tool
Interactive decision tree — outcomes:
- Free — fire it, log it, post it somewhere humans see
Reversible, narrow, capped: no approval. Gating this teaches your approvers to click yes without reading, which is how you lose the gate you actually needed.
What "free" still owes you: an audit entry naming the run and the actor, a line in a channel someone skims, and a daily count on a dashboard so a change in volume is visible. Freedom without visibility is not the same trade.
- Not ready — cap the blast radius before you decide the gate
A reversible action with unbounded reach is not a reversible action. Four hundred wrong status changes are undoable in theory and an afternoon of manual work in practice.
Fix the tool first: take an explicit list of ids instead of a predicate, cap the list length, add a per-run call limit. Then come back and walk this tree again — it will almost certainly land on free with notification, and you will have removed the need for a gate rather than adding one.
- Free with a strong audit trail — but check the "internal only" claim
Irreversible internal changes are usually fine to let run, on one condition: the change is diffable and attributable after the fact.
Be suspicious of "internal only". A write to a config table that a customer-facing service reads at request time is not internal. A note added to an account that a human later reads and acts on is not internal either — it is durable influence on a future decision. If either applies, treat the effect as external and walk the right-hand branch.
- Threshold gate — enforced in the runtime, not in the prompt
This is the answer you want for refunds, payments, and outbound mail. Below the line, fire and notify. Above it, write a pending record, post it to a queue, and return
status: "pending_approval"to the model with an instruction not to retry.Three rules. The threshold is a branch in code — a prompt cannot be a gate. The approval carries the rendered request (this order, this amount, this customer) so the approver is judging the action and not the agent’s summary of it. And review the approve rate quarterly: above ~95% yes, your threshold is too low; below ~75%, the agent is not ready for that action.
- Gate every call — and treat that as a design smell
Sometimes correct: bulk deletes, production deploys, anything touching a legal record. Gate all of it, and require the approver to see the full effect before approving.
But if an action is uniformly serious and frequent, the agent is holding a capability that should be split. Look for the narrow safe subset — instead of
update_account, achange_shipping_addressthat touches one field. That refactor usually moves 90% of the traffic to a free tool and leaves a genuinely rare gated one. If the capability cannot be split, consider whether the agent should draft the action for a human to execute rather than execute it under approval. - Your gate will collapse — fix the volume, not the threshold
Dozens of approvals an hour produces one behaviour: a human clicking approve at a speed that precludes reading. You now have the cost of a gate, the latency of a gate, and none of the protection, plus a written record showing a human approved the incident. The OWASP agentic threat taxonomy names this as overwhelming the human in the loop.
Two real fixes. Raise the threshold until the queue is small enough to read — a gate on the top 5% that is genuinely read beats a gate on 60% that is not. Or make the common case unnecessary: auto-approve the well-understood pattern (full refund on a not-delivered order under 30 dollars with a matching carrier record) under a rate limit and a daily budget, and reserve the human for everything outside it.
And if you do not know the volume, that is the finding. Run the gate in shadow mode for a week, count what would have queued, then set the number.
Step 8, the allowlist you can write on one page
List the hosts your tools legitimately talk to. For the support agent that was five: the payments gateway, the order service, the ticket system, the model endpoint, and an internal object store. Deny everything else at the network layer, log the denials, and alert on the first one.
The reason this step is worth a whole afternoon is that it is the only control that catches the failure you did not predict. Every other step in this pass hardens a tool you know about. The allowlist bounds the tools you got wrong — a poisoned dependency, a rewritten description, a fetch tool that follows a redirect. Egress control is what turns exfiltration into a log line, and it is why Meta’s Agents Rule of Two (Meta AI blog, 2025-10-31) treats "can communicate externally" as one of the three properties you refuse to combine: an agent that processes untrusted input and holds sensitive access should not also be able to talk to arbitrary hosts.
If you cannot enumerate the hosts, do not skip the step — that inability is the finding. Run in log-only mode for a week, read what actually got dialled, and you will find one or two you would never have guessed.
Step 9, how you know it worked
Not "the security review passed". These, and each one is a test or a number you can produce on demand:
A red test per tightening, in CI. For every constraint you added, an automated test that tries the thing and asserts a clean refusal — a refund over the ceiling, a negative amount, a cross-customer read, a query for a column outside the view, a duplicate idempotency key. Twelve tightenings, twelve tests, and they run on every prompt and schema change. Regression Suites in CI: Evals That Run On You is where this gets wired up properly.
Errors the model recovers from. Take the ten most common tool errors from last month, replay each one, and check the transcript: did the model correct on the first retry, or loop? A tool error the model cannot act on is a bug in your error message, not in the model.
Tokens per tool call, before and after. The support agent’s per-call average dropped from about 4,100 to 380 on the read path. This is the number that gets the pass funded next quarter, so measure it.
Approve rate on the gate, reviewed quarterly. Above ~95% yes and your threshold is too low; below ~75% and the agent should not be doing that action yet.
Egress denials. Should be zero in steady state. The first one is either a legitimate host you missed or the most valuable alert you will get all year — treat both as urgent.
One number nobody can compute yet. Ask: what is the worst thing this agent can do in one hour with no human intervention? Before the pass on the support agent, the honest answer was "unbounded refunds and a full read of the customer table". After, it was "100 refunds of at most 500 dollars each, all reversible on the ledger, all attributable to a run id, and a read confined to one customer’s last twenty orders". Both are numbers. Only one of them can be signed off.
Keep it a pass, not an event
Toolsets grow the way this one grew to eleven: one tool at a time, each one obviously reasonable, none of them reviewed as an addition to a permission surface. So make the pass structural. A new tool cannot merge without its inventory row, its class, its credential, and its gate decision — a five-line PR template does it. Re-run the whole thing quarterly and after every model swap, because a new model uses your tools differently and the descriptions you tuned may no longer land the same way.
Two days of this beats a policy document nobody reads, and it produces the one artifact an auditor, a CFO, and an on-call engineer all want for different reasons: a list of exactly what your agent can do, and what it cannot.