Skip to content

Stop an AI agent from deleting production data

An agent with a delete tool can remove the wrong rows. The model misreads a ticket, matches too broadly, or retries a cleanup that already ran. The delete is the one step you cannot take back.

Prompt instructions such as “never delete more than a few rows” are not enforcement. The model can ignore them, and nothing records that it tried. A check that runs in your code, before the delete, is enforcement.

This guide puts that check in front of a delete tool. Small deletes wait for a person to approve. Bulk deletes do not run. Everything else your agent does keeps running.

Before the tool deletes anything, it asks Vulnify about the action: which agent, DELETE_DATA, which resource, and how many records. Vulnify answers ALLOW, REVIEW, or BLOCK. The delete runs only when finalDecision is ALLOW. See Decisions.

You need an API key from app.vulnify.io. Use a TEST key while you try this. Register the agent (OpsBot) and the resource (Production Database) in the app under those names, or change the names in the code. An unknown agent or resource raises an error. See Agents and Actions.

guard() runs your function only when Vulnify allows the action. On REVIEW and BLOCK it throws VulnifyBlockedError, and the delete does not run. The tool turns that into a message the model can relay. Always pass recordsAffected: the policies below match on it.

Terminal window
npm install @vulnify/sdk
import { Vulnify, VulnifyBlockedError } from '@vulnify/sdk';
const apiKey = process.env.VULNIFY_API_KEY;
if (!apiKey) {
throw new Error('Set VULNIFY_API_KEY');
}
const vulnify = new Vulnify({ apiKey, baseUrl: process.env.VULNIFY_BASE_URL });
/** Replace the body with your real delete. It runs only after ALLOW. */
async function deleteCustomerRows(ids: string[]): Promise<number> {
console.log(`deleting ${ids.length} rows from production`);
return ids.length;
}
/** The tool your agent calls. Vulnify decides before the delete runs. */
async function deleteCustomersTool(ids: string[]): Promise<string> {
try {
const deleted = await vulnify.guard(
{
agent: 'OpsBot',
action: 'DELETE_DATA',
resource: 'Production Database',
recordsAffected: ids.length,
},
() => deleteCustomerRows(ids),
);
return `Deleted ${deleted} rows.`;
} catch (error) {
if (error instanceof VulnifyBlockedError) {
const d = error.result;
// REVIEW or BLOCK: nothing was deleted. Tell the model why.
return `Not deleted. Vulnify ${d.finalDecision}: ${d.reasons.join('; ')} (event ${d.id ?? 'none'})`;
}
throw error; // bad key, unknown agent or resource: fix the setup
}
}
console.log(await deleteCustomersTool(['cus_101', 'cus_102', 'cus_103']));

Run it with VULNIFY_API_KEY set. Without a policy, the risk engine decides. The next step makes the rule explicit.

Two rules, with record ranges that do not overlap. A delete of 1 to 100 records goes to a person. A delete of more than 100 records is blocked. action: DELETE is the action family. The event action DELETE_DATA is what the code sends. The format is on Policy files.

apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: review-small-deletes
spec:
description: A person approves any delete of 1 to 100 records
enabled: true
action: DELETE
resource: ANY
condition:
minRecords: 1
maxRecords: 100
decision: REVIEW
mode: ENFORCE
approverRoles:
- OWNER
- ADMIN
---
apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: block-bulk-deletes
spec:
description: Agents never delete more than 100 records in one action
enabled: true
action: DELETE
resource: ANY
condition:
minRecords: 101
decision: BLOCK
mode: ENFORCE

Save it as vulnify/policies/deletes.yaml, then check and apply it with the CLI:

Terminal window
npx -p @vulnify/sdk vulnify policies validate
npx -p @vulnify/sdk vulnify policies apply --dry-run
npx -p @vulnify/sdk vulnify policies apply

apply needs an org-wide LIVE key. You can also create the same two rules in the app under Policies.

To test the rules before you rely on them, add vulnify/tests/deletes.test.yaml and run vulnify test --local. It writes no security event.

apiVersion: vulnify.io/v1
kind: PolicyTest
metadata:
name: delete-rules
cases:
- name: a small delete waits for a person
input:
agent: OpsBot
action: DELETE_DATA
resource: Production Database
recordsAffected: 3
expect:
decision: REVIEW
policy: review-small-deletes
- name: a bulk delete is blocked
input:
agent: OpsBot
action: DELETE_DATA
resource: Production Database
recordsAffected: 5000
expect:
decision: BLOCK
policy: block-bulk-deletes

With the policy applied, a 3-row delete comes back as REVIEW. The rows are not deleted. The tool returns:

Not deleted. Vulnify REVIEW: <reasons from Vulnify> (event <event id>)

The decision has this shape. The score and reasons depend on your data, so they are left out here.

decision: REVIEW
finalDecision: REVIEW
riskLevel: LOW | MEDIUM | HIGH | CRITICAL
riskScore: integer 0-100
reasons: strings that explain the score
policy: { id, name: "review-small-deletes" }
review: { status: "PENDING", ... }

An approver resolves the review in the app or from Slack with Approve and Deny. See Reviews and Slack. Approval sets finalDecision to ALLOW. The stored decision stays REVIEW. To have the tool wait for the answer instead of stopping, pass a wait option: { timeoutMs: 120_000 } as the third argument of guard() on Node.js, or wait={"timeout": 120} on Python.

A 5000-row delete comes back as BLOCK, with policy.name block-bulk-deletes. Nothing runs and nobody is asked:

Not deleted. Vulnify BLOCK: <reasons from Vulnify> (event <event id>)

If Vulnify cannot be reached, the SDK fails closed by default: the result is BLOCK with degraded set, and the delete does not run. See Decisions.

Every decision is appended to the audit log, including the ones that were blocked.