require-max-steps
This rule identifies tool-calling AI SDK calls whose step limit does not bound the loop: a hasToolCall-only stopWhen, Infinity, or a limit read from the request
Prevents unbounded tool calling loops in multi-step agents.
📊 Rule Details
| Property | Value |
|---|---|
| Type | problem |
| Severity | 🟡 MEDIUM |
| OWASP LLM | LLM10: Unbounded Consumption |
| CWE | CWE-834: Excessive Iteration |
| CVSS | 6.5 |
| Config Default | off (recommended), error (strict) |
🔍 What This Rule Detects
generateText / streamText with tools run one step unless told otherwise:
AI SDK v4 defaults to maxSteps: 1, and v5+ defaults to stopWhen: stepCountIs(1).
A call with tools and no step option therefore cannot loop, and this rule does
not report it.
The rule reports a step setting that is present but does not bound the loop:
stopWhen: hasToolCall('done')— alone, or an array of onlyhasToolCall(...)conditions. It stops only if the model ever calls that tool.maxSteps: Infinity.maxSteps,stopWhen, or the count passed tostepCountIs(...)/isStepCount(...), read from the request (await req.json(),req.body,searchParams.get(...)) — the caller chooses the budget.
Changed in the 2026-10-10 FP/FN audit. Earlier versions reported every tool call without
maxSteps/stopWhen— the SDK's single-step default, the safest configuration there is — and accepted anystopWhen, including the unbounded ones above.
❌ Incorrect Code
// Stops only if the model calls finalAnswer
await generateText({ model, tools, stopWhen: hasToolCall('finalAnswer'), prompt: 'research' });
// The caller chooses the step budget
export async function POST(req: Request) {
const body = await req.json();
return streamText({ model, tools, maxSteps: body.maxSteps, prompt: 'x' });
}
export async function PUT(req: Request) {
const { steps } = await req.json();
return streamText({ model, tools, stopWhen: stepCountIs(steps) });
}✅ Correct Code
// No step option: the SDK runs a single step
await generateText({ model, tools: { getWeather }, prompt: 'Weather in Paris?' });
// AI SDK v5+
await streamText({ model, messages, tools: { getWeather }, stopWhen: stepCountIs(5) });
// hasToolCall bounded by a step count
await generateText({ model, tools, stopWhen: [hasToolCall('finalize'), stepCountIs(20)] });
// AI SDK v4
await generateText({ model, tools, maxSteps: 5 });⚙️ Options
| Option | Type | Default | Description |
|---|---|---|---|
suggestedMaxSteps | number | 5 | Default max steps limit to suggest |
🛡️ Why This Matters
Unbounded tool loops can cause:
- Infinite loops - AI keeps calling tools forever
- Cost explosion - Each tool call may trigger additional API calls
- Resource exhaustion - Downstream services overwhelmed
- Data corruption - Repeated mutations without checks
🔗 Related Rules
require-max-tokens- Limit token consumptionrequire-tool-confirmation- Require confirmation
🔄 Changes in the 2026-10-11 zero-deferral pass
- A step count or stop condition read from the request through further
same-file hops (
const n = Number(body.steps); stopWhen: stepCountIs(n)) is reported.
Known False Negatives
The following patterns are not detected due to static analysis limitations:
Custom Stop Conditions
Why: A stop condition the rule cannot read (stopWhen: myCondition) may or
may not bound the loop; the rule does not guess.
Mitigation: Combine custom conditions with stepCountIs(n) in an array.
Options from Variable, Wrappers, ToolLoopAgent
Why: Options stored in variables, wrappers that add tools internally, and
new ToolLoopAgent({ ... }) (which defaults to 20 steps) are not analyzed.
📚 References
Use this rule
npm install -D oxlint-plugin-vercel-ai-security{ "jsPlugins": ["oxlint-plugin-vercel-ai-security"], "rules": { "vercel-ai-security/require-max-steps": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-vercel-ai-security'], rules: { 'vercel-ai-security/require-max-steps': 'error', }, },});oxlint-plugin-vercel-ai-security re-exports eslint-plugin-vercel-ai-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.