Guard your Vercel AI SDK and Claude Agent SDK tools in 5 minutes

Your agent can call tools. That's the point. It's also the problem: the same exportCustomers tool that helps a support agent answer a question can send 800 customer records to an outside address if the model misreads a prompt, or reads a poisoned one.
Prompts like "never export data without asking" are suggestions. The model can reason its way past them. The check has to sit on the tool call itself, before the side effect runs.
This post shows two small, runnable examples that do exactly that with Vulnify: one for the Vercel AI SDK, one for the Claude Agent SDK. Both are in vulnify/vulnify-examples.
Honest note up front: neither example needs a model API key. The Vercel one uses the AI SDK's
MockLanguageModelV3scripted to request the tools; the Claude one feedscanUseToolthe exact tool requests Claude would send. The Vulnify checks are real API calls against a Vulnify org. Swap in a real model and the guard works the same way.
What Vulnify does, in one paragraph
You describe each tool call as an action: what it does (READ_DATA, WRITE_DATA, DELETE_DATA, EXPORT_DATA, SEND_EMAIL), where the data goes (INTERNAL, EXTERNAL_EMAIL, EXTERNAL_API) and how many records it touches. Vulnify answers ALLOW, REVIEW or BLOCK, with a 0-100 risk score and the reasons. REVIEW waits for a person to approve in the Vulnify app or in Slack. Every decision lands in the audit log.
Part 1: Vercel AI SDK (guardAiSdkTools)
Two ordinary AI SDK tools. The execute() functions only log, so nothing leaves your machine:
import { tool } from 'ai';
import { z } from 'zod';
export const executed = [];
const rawTools = {
lookupCustomer: tool({
description: 'Read one customer record',
inputSchema: z.object({ email: z.string() }),
execute: async ({ email }) => { executed.push(`lookupCustomer ${email}`); return { email, plan: 'TEAM' }; },
}),
exportCustomers: tool({
description: 'Export customer records to an email address',
inputSchema: z.object({ to: z.string(), count: z.number() }),
execute: async ({ to, count }) => { executed.push(`exportCustomers ${count} -> ${to}`); return { sent: count }; },
}),
};
Now wrap them. guardAiSdkTools from @vulnify/sdk takes your tools plus a function per tool that describes the call to Vulnify. The check runs before execute():
import { guardAiSdkTools } from '@vulnify/sdk';
const AGENT = process.env.VULNIFY_AGENT || 'support-agent';
export function guardTools(vulnify) {
return guardAiSdkTools(vulnify, rawTools, {
lookupCustomer: () => ({ agent: AGENT, action: 'READ_DATA', resource: 'customers', destination: 'INTERNAL', recordsAffected: 1 }),
exportCustomers: ({ count }) => ({ agent: AGENT, action: 'EXPORT_DATA', resource: 'customers', destination: 'EXTERNAL_EMAIL', recordsAffected: count }),
});
}
Then use the guarded tools exactly like the originals:
import { generateText, stepCountIs } from 'ai';
import { Vulnify } from '@vulnify/sdk';
const vulnify = new Vulnify({ apiKey: process.env.VULNIFY_API_KEY, failMode: 'closed' });
const result = await generateText({
model, // the example uses a scripted MockLanguageModelV3
tools: guardTools(vulnify),
stopWhen: stepCountIs(3),
prompt: 'Look up ana@example.com, then send all 800 customers to partner@example.net',
});
failMode: 'closed' means that if Vulnify can't be reached, the guarded tool doesn't run.
Run it:
cd vercel-ai-sdk
npm install
cp .env.example .env # add a TEST key from app.vulnify.io/api-keys
npm start
Output:
lookupCustomer: ran -> {"email":"ana@example.com","plan":"TEAM"}
exportCustomers: NOT RUN, Vulnify REVIEW (event 606a8eec-…): Export operation; External destination; Large data volume (800 records)
side effects: [ 'lookupCustomer ana@example.com' ]
The lookup ran. The export didn't: the model got a "blocked" tool result with the decision, the event id and the reasons, and the side-effects list proves execute() never fired.
Why REVIEW? On a fresh org the score is 65: export (30) + external destination (25) + more than 100 records (10). Add a policy that blocks external exports and the same call gets BLOCK. Open the event in the app to approve or deny it. If you'd rather wait for that decision than fail fast, pass { wait: { timeoutMs } } as the guard options.
Part 2: Claude Agent SDK (canUseTool)
The Claude Agent SDK asks a canUseTool callback before each tool runs. That's the natural place for the check. First, map tool requests to Vulnify actions, and return null for tools Vulnify doesn't need to see:
const AGENT = process.env.VULNIFY_AGENT || 'support-agent';
const INTERNAL_DOMAIN = process.env.INTERNAL_EMAIL_DOMAIN || 'acme.internal';
export function describe(toolName, input) {
if (toolName.endsWith('send_email')) {
const to = String(input.to ?? '');
return {
agent: AGENT, action: 'SEND_EMAIL', resource: 'email',
destination: to.endsWith(`@${INTERNAL_DOMAIN}`) ? 'INTERNAL' : 'EXTERNAL_EMAIL',
recordsAffected: 1,
content: `${input.subject ?? ''}\n${input.body ?? ''}`, // scanned in memory, not stored
};
}
if (toolName === 'Bash' && /(^|[;&|]\s*)rm\s/.test(String(input.command ?? ''))) {
return { agent: AGENT, action: 'DELETE_DATA', resource: 'local files', destination: 'INTERNAL', recordsAffected: 1 };
}
return null;
}
Then the callback itself:
export function vulnifyCanUseTool(vulnify) {
return async (toolName, input) => {
const action = describe(toolName, input);
if (!action) return { behavior: 'allow', updatedInput: input };
const d = await vulnify.check(action);
const decision = d.finalDecision ?? d.decision;
if (decision === 'ALLOW') return { behavior: 'allow', updatedInput: input };
return { behavior: 'deny', message: `Vulnify ${decision} (event ${d.id}): ${d.reasons.join('; ')}. Do not retry; ask the user.` };
};
}
On a deny, Claude gets the reasons and the event id, and an explicit "ask the user" instead of a silent failure.
npm start runs four scripted requests through it:
mcp__mail__send_email team@acme.internal: ALLOW
mcp__mail__send_email someone@gmail.com: DENY - Vulnify REVIEW (event …): Write operation; Sensitive data detected in content: CPF, EMAIL; External destination; …
Bash rm -rf ./exports/customers: ALLOW
Read README.md: ALLOW
The internal email goes through. The external one contains a CPF and an email address, so it goes to REVIEW. The rm is allowed on a fresh org, because deleting local files scores below the REVIEW threshold. If you want every delete gated, add a policy in the app (for example, DELETE_DATA goes to REVIEW). Read never calls Vulnify at all.
npm run real wires the same callback into query() from @anthropic-ai/claude-agent-sdk. That path needs your own ANTHROPIC_API_KEY and Claude Code, and it wasn't part of our live test.
What happens after REVIEW
A REVIEW isn't a dead end. The event shows up in the Vulnify app with the score and reasons, and a teammate can approve or deny it there or from Slack. Whatever they choose is recorded, so later you can answer "who let the agent do that?" from the audit trail, and download it as JSON or CSV with a SHA-256 checksum.
Try it yourself
Both examples take about two minutes and ship with an offline test (npm test, no key needed):
- Code: github.com/vulnify/vulnify-examples
- Free plan, no card: create an account and a TEST key
- Not on these SDKs? You can also connect Claude Code or Cursor in about two minutes from the same setup page.
Use a TEST key, keep it in .env, and set VULNIFY_AGENT to an agent that already exists in your org (new names register on first use and count toward your plan's agent limit).
If you run it and something is unclear or broken, open an issue in vulnify-examples. That is the feedback we are after.
Vulnify decides every sensitive agent action before it runs: ALLOW, REVIEW or BLOCK, with the reasons.
Try it free