Short answer: don't give the agent a function that can drop a table or delete a record in the first place, if you can avoid it — and where you can't avoid it, block the destructive call in the layer that decides whether a tool call runs, not in the prompt, and not in the model's judgment. A system prompt saying "never drop tables" is a request, not a control. It's the difference between telling someone the rule and installing a lock.
The naive fix, and why it's often correct
The most common and, honestly, frequently sufficient answer is: don't expose the destructive tool at all. Give the agent readOrders, readInventory, searchLogs — and no dropTable, no deleteRecord, no raw SQL execution tool. If the capability doesn't exist in the tool list, no prompt injection, hallucination, or bad plan can invoke it. This is the correct starting point for any agent that doesn't have a real business reason to mutate destructively, and it costs nothing beyond the discipline of not adding the tool.
It stops being sufficient the moment there's a legitimate reason for the capability to exist somewhere in the system — a cleanup job, an admin agent, a migration assistant — and "somewhere in the system" turns out to mean "reachable, in principle, by the same agent loop that also reads untrusted content." That's most agents with any real utility: an email-triage agent that also has a deleteRecord tool for closing out duplicate tickets is one crafted email away from an attacker asking it, in-band, to delete something else.
The next naive fix, and where it breaks
The next thing people try is a guard inside the destructive function itself:
async function dropTable({ table }) {
if (table.startsWith('tmp_')) {
return db.drop(table)
}
throw new Error('refusing to drop a non-temp table')
}
This is real protection, and better than nothing. Its failure mode is that it lives inside the function that also does the dangerous thing — every new destructive tool needs its own copy of the same discipline, someone eventually writes one without the check because they were focused on the happy path, and there's no single place to look to answer "what destructive actions can this agent take" without reading every tool's source.
Moving the block outside the function
The fix that scales past one tool is the same shape as most access-control problems: stop trusting each function to protect itself, and put a single deny-by-default check in front of all of them. With nominee, that's a deny policy rule enforced by guard() — the destructive function's own code is never reached if the rule fires:
import { Nominee, allow, deny, PolicyDeniedError } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [
allow('readTable'),
deny('dropTable', { reason: 'schema-destructive calls are never automated, no exceptions' }),
],
fallback: 'deny',
},
receipts: { key: process.env.NOMINEE_RECEIPT_KEY },
agent: 'db-agent',
})
async function dropTable({ table }) { return db.drop(table) }
async function readTable({ table }) { return db.read(table) }
const tools = nominee.guard({ readTable, dropTable }, { user: 'agent_pipeline' })
Now feed it a legitimate read and an attempted drop — run against the real package, nothing mocked:
await tools.readTable({ table: 'orders' })
// { table: 'orders', rows: 42 }
try {
await tools.dropTable({ table: 'orders' })
} catch (err) {
err instanceof PolicyDeniedError // true
err.message
// 'nominee: policy denied "dropTable" for agent_pipeline (rule deny:dropTable)
// — schema-destructive calls are never automated, no exceptions'
}
// the actual db.drop() call inside dropTable() never ran — dropped === false
And the receipt chain shows exactly what was attempted and blocked, whether or not anyone's watching a dashboard when it happens:
#0 action.planned readTable 4e7673008b4c
#1 policy.decision readTable allow f2f300b23340
#2 capability.issued readTable 04b2708984a4
#3 capability.consumed readTable e6e43a8c53ae
#4 execution.started readTable 09c17ddf9539
#5 execution.succeeded readTable succeeded e79d83b60705
#6 action.planned dropTable 0d10d7252fba
#7 policy.decision dropTable deny a54b16ea5a45
Note what's missing from that list: capability.issued, execution.started. The chain stops at policy.decision … deny — the drop was refused at the planning stage, not caught after the fact. That's the property that matters: the function containing db.drop() was never called. A deny that happens to log the attempt but still runs the tool isn't a deny, it's an audit trail for damage that already happened.
What this is not
Be precise about the claim here: a deny rule stops a specific, named tool call from executing. It does not detect that an incoming message is a prompt injection, and it does not stop a model from deciding to try — a compromised or confused agent can still attempt the call. What it changes is the outcome of that attempt. That's blast-radius containment, not injection detection, and it's worth keeping those two ideas separate: the agent can be fully fooled by a malicious email, decide to act on it, call the tool — and still not be able to drop the table, because the decision of whether the call executes never belonged to the model in the first place. The supporting proof for that exact scenario — a prompt-injected agent trying to exfiltrate data and physically failing — is worth reading if this is the threat model you're defending against.
It's also worth being honest about the boundary: this only holds if the destructive capability is only reachable through the guarded path. If the agent (or the code calling it) can reach db.drop() directly, or holds a raw database credential outside of nominee's decision-bound execution, the policy layer never gets a chance to say no. Put genuinely high-impact operations behind a separate service that only accepts the decision-bound call, not a shared credential the agent process also happens to hold.
When you don't need this
If you can get away with simply not exposing the destructive tool — do that first; it's strictly stronger than any runtime check, because there's nothing to bypass. Reach for a policy layer once a destructive capability has to exist somewhere reachable by the same process that handles untrusted input, once more than one tool needs the same kind of protection, or once "prove nothing destructive ran without authorization" needs to be an answerable question after the fact, not just a hope.
See it for yourself
Watch a blocked exfiltration attempt in 10 seconds.