Skip to content

Stop an AI agent from exporting customer data

An agent that can export records or send email can send customer data to the wrong place. A prompt injection in a ticket asks for “a copy of the customer list”. A sales agent attaches a full export when one row was enough. The data leaves before anyone looks. That is data exfiltration, even when nobody intended it.

Most of what the agent does is fine and should not wait for anyone. The risk is concentrated in a few actions: exports, and email to addresses outside the company.

This guide checks those actions before they run. An external export waits for a person to approve it. A bulk external export is blocked. Email outside the company domain waits for approval. Internal email and everything else keep running.

Before the export or the email, your code asks Vulnify about the action: the agent, EXPORT_DATA or SEND_EMAIL, the resource, the destination, and the record count. Vulnify answers ALLOW, REVIEW, or BLOCK. The action runs only when finalDecision is ALLOW. See Decisions.

destination is INTERNAL when the address is on your company domain and EXTERNAL_EMAIL otherwise. Use EXTERNAL_API when the data goes to another service. See Actions.

You need an API key from app.vulnify.io. Use a TEST key while you try this. Register the agent (SalesBot) and the resources (Customer Database, Customer Contacts) in the app under those names, or change the names in the code.

The export tool waits up to 2 minutes for a person to approve a REVIEW, then runs only if it was approved. The email tool does not wait: a REVIEW stops the send and the model is told a person must approve. content is optional. Vulnify scans it for sensitive data and does not store 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 });
const COMPANY_DOMAIN = '@yourcompany.com';
function destinationFor(address: string): 'INTERNAL' | 'EXTERNAL_EMAIL' {
return address.toLowerCase().endsWith(COMPANY_DOMAIN) ? 'INTERNAL' : 'EXTERNAL_EMAIL';
}
/** Replace the bodies with your real export and email calls. */
async function exportCustomers(rows: number, to: string): Promise<void> {
console.log(`exporting ${rows} customer records to ${to}`);
}
async function sendEmail(to: string, subject: string, body: string): Promise<void> {
console.log(`sending "${subject}" to ${to} (${body.length} chars)`);
}
/** Export tool: on REVIEW, wait up to 2 minutes for a person to approve (in the app or in Slack). */
async function exportCustomersTool(rows: number, to: string): Promise<string> {
try {
await vulnify.guard(
{
agent: 'SalesBot',
action: 'EXPORT_DATA',
resource: 'Customer Database',
destination: destinationFor(to),
recordsAffected: rows,
},
() => exportCustomers(rows, to),
{ timeoutMs: 120_000, pollMs: 2000 },
);
return `Exported ${rows} customer records to ${to}.`;
} catch (error) {
if (error instanceof VulnifyBlockedError) {
// BLOCK, or a review that was denied, expired, or not answered in time.
return `Export not run. ${error.message} (event ${error.result.id ?? 'none'})`;
}
throw error;
}
}
/** Email tool: no wait. REVIEW stops the send and tells the model a person must approve. */
async function emailCustomerTool(to: string, subject: string, body: string): Promise<string> {
try {
await vulnify.guard(
{
agent: 'SalesBot',
action: 'SEND_EMAIL',
resource: 'Customer Contacts',
destination: destinationFor(to),
recordsAffected: 1,
content: body, // optional: scanned for sensitive data, not stored
},
() => sendEmail(to, subject, body),
);
return `Email sent to ${to}.`;
} catch (error) {
if (error instanceof VulnifyBlockedError) {
return `Email not sent. ${error.message} (event ${error.result.id ?? 'none'})`;
}
throw error;
}
}
console.log(await emailCustomerTool('ana@example.com', 'Your renewal', 'Hi Ana, your plan renews next week.'));
console.log(await exportCustomersTool(800, 'partner@example.com'));

Three rules. External customer exports of up to 1000 records go to a person. External customer exports of more than 1000 records are blocked. Email to an external address goes to a person. The record ranges do not overlap, so only one export rule matches a given action.

resource: CUSTOMER_PII matches resources whose type is customer PII. Use ANY if you have not set resource types. SEND_EMAIL has no action family of its own, so that rule uses action: ANY and puts SEND_EMAIL in the condition. The format is on Policy files.

apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: review-external-customer-exports
spec:
description: A person approves customer exports that leave the company, up to 1000 records
enabled: true
action: EXPORT
resource: CUSTOMER_PII
condition:
destination: EXTERNAL
maxRecords: 1000
decision: REVIEW
mode: ENFORCE
approverRoles:
- OWNER
- ADMIN
---
apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: block-bulk-external-customer-exports
spec:
description: Customer exports of more than 1000 records never leave the company
enabled: true
action: EXPORT
resource: CUSTOMER_PII
condition:
destination: EXTERNAL
minRecords: 1001
decision: BLOCK
mode: ENFORCE
---
apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: review-external-customer-email
spec:
description: A person approves agent email to addresses outside the company
enabled: true
action: ANY
resource: ANY
condition:
action: SEND_EMAIL
destination: EXTERNAL
decision: REVIEW
mode: ENFORCE

Save it as vulnify/policies/exports.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 rules in the app under Policies. Start with mode: MONITOR if you want to see what would be reviewed before you enforce it.

These are real results from test runs, not made-up values.

An export of 800 records to an external email address, on a test resource, came back as REVIEW (fields trimmed):

{
"decision": "REVIEW",
"finalDecision": "REVIEW",
"riskLevel": "HIGH",
"riskScore": 65,
"reasons": ["Export operation", "External destination", "Large data volume (800 records)"],
"dlpFindings": []
}

The export did not run. The Slack alert showed Approve and Deny. After Approve, get_decision returned finalDecision ALLOW, and only then could the export run. The stored decision stays REVIEW. See Reviews and Slack.

An export of 12,000 records from Customer Database to an external email address, with a TEST key and no policy, came back as BLOCK from the risk engine:

{
"decision": "BLOCK",
"finalDecision": "BLOCK",
"riskLevel": "CRITICAL",
"riskScore": 100,
"reasons": ["Export operation", "Customer PII", "External destination", "Large data volume (12,000 records)"],
"policy": null
}

With the policy above applied, that export matches block-bulk-external-customer-exports and policy.name names it. Your scores and reasons depend on your resources and policies.

In the code above, the tool returns a string such as Export not run. Vulnify BLOCK: … to the model. The model can tell the user why. If Vulnify cannot be reached, the SDK fails closed by default and nothing is exported.