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.
How it works
Section titled “How it works”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.
1. Guard the delete tool
Section titled “1. Guard the delete tool”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.
npm install @vulnify/sdkimport { 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']));pip install vulnifyimport os
from vulnify import Vulnify, VulnifyBlockedError
api_key = os.environ.get("VULNIFY_API_KEY")if not api_key: raise SystemExit("Set VULNIFY_API_KEY")
vulnify = Vulnify(api_key=api_key)
def delete_customer_rows(ids: list) -> int: """Replace the body with your real delete. It runs only after ALLOW.""" print(f"deleting {len(ids)} rows from production") return len(ids)
def delete_customers_tool(ids: list) -> str: """The tool your agent calls. Vulnify decides before the delete runs.""" try: deleted = vulnify.guard( lambda: delete_customer_rows(ids), agent="OpsBot", action="DELETE_DATA", resource="Production Database", records_affected=len(ids), ) return f"Deleted {deleted} rows." except VulnifyBlockedError as blocked: d = blocked.result outcome = d.final_decision or d.decision # REVIEW or BLOCK: nothing was deleted. Tell the model why. return f"Not deleted. Vulnify {outcome}: {'; '.join(d.reasons)} (event {d.id or 'none'})"
print(delete_customers_tool(["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.
2. Add the policy
Section titled “2. Add the policy”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/v1kind: Policymetadata: name: review-small-deletesspec: 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/v1kind: Policymetadata: name: block-bulk-deletesspec: description: Agents never delete more than 100 records in one action enabled: true action: DELETE resource: ANY condition: minRecords: 101 decision: BLOCK mode: ENFORCESave it as vulnify/policies/deletes.yaml, then check and apply it with the CLI:
npx -p @vulnify/sdk vulnify policies validatenpx -p @vulnify/sdk vulnify policies apply --dry-runnpx -p @vulnify/sdk vulnify policies applyapply 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/v1kind: PolicyTestmetadata: name: delete-rulescases: - 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-deletes3. What REVIEW and BLOCK look like
Section titled “3. What REVIEW and BLOCK look like”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: REVIEWfinalDecision: REVIEWriskLevel: LOW | MEDIUM | HIGH | CRITICALriskScore: integer 0-100reasons: strings that explain the scorepolicy: { 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.
Next steps
Section titled “Next steps”- Run the full flow end to end in the Quickstart.
- Use a framework adapter instead of
guard(): Framework adapters and Vercel AI SDK. - Guard exports and outbound email the same way: Stop an AI agent from exporting customer data.
- Create the policy and an API key in the Vulnify app.

