An LLM that hallucinates is annoying. An agent that hallucinates calls the
wrong tool — and if that tool is deleteUser, the hallucination is a deleted
production row.
The moment you hand a model tools, you've granted it agency: it decides which
function to run and with what arguments. The OWASP LLM Top 10 calls the failure
mode LLM06: Excessive Agency. eslint-plugin-vercel-ai-security is SDK-aware
(it understands generateText/streamText and tool definitions), so it can
check, at write-time, that every tool call is gated. Five rules do it.
// ships to production more often than you'd think
const result = await generateText({
model: openai("gpt-4o"),
tools: {
deleteUser: {
execute: async ({ userId }) => {
await db.users.delete(userId); // no confirmation, no schema, no step bound
},
},
},
});
Four things are missing in this snippet (a fifth, the abort signal, applies only to streaming calls). The linter names three of them inline; the fourth it flags separately:
src/agent.ts
3:5 warning ⚠️ CWE-862 OWASP:A01-Broken CVSS:7 | Tool "deleteUser" performs destructive operation "delete" without requiring confirmation. | HIGH [SOC2]
Fix: Add requiresConfirmation: true or implement confirmation logic in the tool
3:5 error 🔒 CWE-20 OWASP:A03-Injection CVSS:7.5 | Tool "deleteUser" is missing inputSchema. Unvalidated tool parameters can lead to injection attacks. | HIGH [SOC2]
Fix: Add inputSchema using Zod: tool({ inputSchema: z.object({ ... }), execute: ... })
2:18 warning ⚠️ CWE-834 OWASP:A05-Security CVSS:6.5 | generateText with tools is missing maxSteps. Without a limit, tool calls can loop indefinitely. | MEDIUM [SOC2]
Fix: Add maxSteps option: generateText({ ..., maxSteps: 5 })
(A fourth rule, require-error-handling (CWE-755), flags the un-try/catch'd
call separately — an agent step that throws shouldn't cascade.)
| Rule | CWE | What it forces |
|---|---|---|
require-tool-confirmation | CWE-862 | a destructive tool (delete/transfer/execute…) must carry a confirmation gate |
require-tool-schema | CWE-20 | every tool declares an inputSchema (Zod) — the model can't pass arbitrary args |
require-max-steps | CWE-834 | a tool-calling loop is bounded by maxSteps — no infinite agent loop |
require-error-handling | CWE-755 | the SDK call is wrapped in try/catch — a failed step doesn't cascade |
require-abort-signal | CWE-404 | streaming calls take an abortSignal — a user can cancel a runaway stream |
These are the operational half of agent safety. The input half — prompt injection, system-prompt leakage — is the prompt-injection deep-dive; the full OWASP LLM map (8 of 10, honestly) is here.
One honest limitation.
require-tool-confirmationinspects tool object literals declared inline intools: { … }. If you wrap a tool in thetool()helper or extract it to a variable, the rule currently treats it as "may be handled elsewhere" and skips it — a documented false-negative. Gate those manually. (require-tool-schemadoes read insidetool({ … }).) The hardened pattern below uses the inline form so every rule fires.
import { z } from "zod";
try {
const result = await generateText({
model: openai("gpt-4o"),
maxSteps: 5, // require-max-steps — bound the loop
tools: {
deleteUser: {
description: "Delete a user account",
inputSchema: z.object({ userId: z.string().uuid() }), // require-tool-schema
requiresConfirmation: true, // require-tool-confirmation
execute: async ({ userId }) => {
await db.users.delete(userId);
},
},
},
});
} catch (err) {
// require-error-handling — a failed step is contained, not cascaded
logger.error("agent step failed", { err });
}
requiresConfirmation: true is the flag the rule looks for; the actual
human-in-the-loop gate (a UI prompt, an approval queue) is yours to wire — the
linter enforces that the decision point exists, not that your implementation
is correct.
# npm
npm install --save-dev eslint-plugin-vercel-ai-security
# yarn
yarn add -D eslint-plugin-vercel-ai-security
# pnpm
pnpm add -D eslint-plugin-vercel-ai-security
# bun
bun add -d eslint-plugin-vercel-ai-security
// eslint.config.js — `configs` is a NAMED export (the default export is the plugin)
import { configs } from "eslint-plugin-vercel-ai-security";
export default [
configs.recommended, // balanced
// configs.strict, // maximum agency hardening for agent code
];
Name the file
eslint.config.mjsif yourpackage.jsonisn't"type": "module". The plugin is CommonJS and loads either way.
# CI — block the PR on a new ungated tool
- run: npx eslint . --max-warnings 0
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | ^8.0.0 || ^9.0.0 || ^10.0.0, flat config |
| Vercel AI SDK | optional peer — AST-based; lints whether or not ai is installed |
| Module system | CommonJS — eslint.config.js or .mjs |
| Oxlint | flagship rule (no-unsafe-output-handling) wired + parity-checked; full set ESLint-first |
This is the agency view of eslint-plugin-vercel-ai-security — the tool-call
surface where a model stops talking and starts acting. The companion pieces:
- Prompt injection, in 1 of 3 places — the input surface
- The OWASP LLM Top 10, mapped honestly — 8 of 10, and the 2 it can't
- All 19 rules, end to end — the full plugin
- 📦 npm: eslint-plugin-vercel-ai-security
- 📖 Full rule docs (per-rule CWE + examples)
- 🔐 OWASP LLM06: Excessive Agency
- 💻 Source on GitHub
⭐ Star on GitHub if a deleteUser tool is one hallucination away from running in your app.
I'm Ofri Peretz, a security engineering leader and the author of the Interlace ESLint ecosystem — domain-specific static analysis for security, reliability, and performance on the Node.js stack.