noctcore-llm/no-llm-output-to-sink
Text an LLM SDK call returned must not reach
eval, a shell, raw SQL, HTML injection, afetchorigin or anfspath without being validated or sanitized first.
Recommended preset: error · Autofix: no · Suggestions: no · Type information: not needed
A model writes whatever its prompt steers it toward, and the prompt holds more than your instructions: the user’s message, a retrieved web page, a document from the index, the result of the last tool call. Any of those can carry a prompt injection. So the model’s output is untrusted input, exactly like a request body, and the damage it can do is set by where you send it. This is OWASP Top 10 for LLM Applications 2025, LLM05 “Improper Output Handling”: model output passed to a browser becomes XSS, to a shell becomes remote code execution, to SQL becomes injection, to a URL becomes SSRF, to a path becomes traversal.
async function explain(openai, messages, panel) { const res = await openai.chat.completions.create({ model: 'gpt-4o', messages }); // a summarised page that said "reply with <img src=x onerror=...>" now runs in your origin panel.innerHTML = res.choices[0].message.content ?? '';}The fix depends on what the output is for. Text for a person goes through textContent (or JSX
children, which React escapes), or through a sanitizer when it really must be HTML:
async function explain(openai, messages, panel) { const res = await openai.chat.completions.create({ model: 'gpt-4o', messages }); panel.textContent = res.choices[0].message.content ?? '';}Output meant to drive code is parsed against a schema first, so only the shapes you allow get through, and then used through an API that does not re-parse strings:
import { exec } from 'node:child_process';
async function runPlan(anthropic, messages) { const msg = await anthropic.messages.create({ model: 'claude-sonnet-5', max_tokens: 1024, messages }); for (const block of msg.content) { if (block.type === 'tool_use') exec(block.input.command); }}import { execFile } from 'node:child_process';
async function runPlan(anthropic, messages) { const msg = await anthropic.messages.create({ model: 'claude-sonnet-5', max_tokens: 1024, messages }); for (const block of msg.content) { if (block.type === 'tool_use') { const { tool, args } = AllowedCommand.parse(block.input); execFile(tool, args); } }}Why it is in recommended
Section titled “Why it is in recommended”It is precise without configuration. A report needs three things at once: an awaited call whose
method chain or import is specific to an LLM SDK, the exact response path that carries model text,
and a sink that interprets its argument as code, markup, SQL, a host or a path. Code that has all
three and is still correct is rare, and when it exists (a trusted internal model, a sandboxed
eval) a disable comment that says so is the right record.
The trade-off is coverage: output that leaves the function, or goes through a helper, is not
followed. Interlace’s
eslint-plugin-vercel-ai-security
has a broader no-unsafe-output-handling: it treats any expression whose source text contains
.text, completion or generated as model output, and any callee whose text contains exec,
query or run as a sink, in files that import the AI SDK. That reaches further and also reports
regex.exec(input.text) or db.run(completionCount). This rule takes the other side of that trade,
the shape of a real SDK response instead of a name, so it can run at error.
What it flags
Section titled “What it flags”The rule reports only when the whole path from the SDK call to the sink is visible in the source.
Sources. The result of an awaited call, followed through const bindings, destructuring,
for (const x of ...), optional chaining, ??, ||, ternaries, +, template literals, trim()
and friends, String(...) and JSON.parse(...):
| SDK | Call | Model output |
|---|---|---|
| OpenAI | <client>.chat.completions.create(...) / .parse(...) |
.choices[i].message.content |
| OpenAI | <client>.responses.create(...) |
.output_text |
| Anthropic | <client>.messages.create(...) |
.content[i].text, and a tool_use block’s .input |
| Vercel AI SDK | generateText(...) imported from ai |
.text, { text } |
| Vercel AI SDK | generateObject(...) imported from ai |
.object and everything in it |
An element is picked with [i], .at(i), .find(...) or .findLast(...), and .filter(...)
keeps the array.
Sinks.
eval(...)andnew Function(...).child_processexec/execSync(always a shell), andspawn/spawnSync/execFile/execFileSyncwithshell: true, when the function comes from animportorrequireofchild_process, directly or throughpromisify.- The SQL text of Prisma’s
$queryRawUnsafe/$executeRawUnsafe. innerHTML/outerHTMLassignment,insertAdjacentHTML,document.write, and JSXdangerouslySetInnerHTML={{ __html }}.- The URL of
fetch, when the model output sits where it can choose the host. - The path arguments of
fsfunctions (readFile,writeFile,unlink,rm,rename, …), fromfs,fs.promisesorfs/promises.
import { generateText } from 'ai';
async function lookup(model, prompt) { const { text } = await generateText({ model, prompt }); return fetch(`https://${text}/v1/status`);}import { generateText } from 'ai';
async function lookup(model, prompt) { const { text } = await generateText({ model, prompt }); return fetch(`https://status.example.com/v1/lookup?q=${encodeURIComponent(text)}`);}async function report(openai, prisma, messages) { const res = await openai.chat.completions.create({ model: 'gpt-4o', messages }); const sql = res.choices[0].message.content ?? ''; return prisma.$queryRawUnsafe(sql);}What it does not flag
Section titled “What it does not flag”Staying silent is the default. Each of these ends the chain, so none is reported:
import { writeFile } from 'node:fs/promises';
async function answer(openai, messages, panel) { const res = await openai.chat.completions.create({ model: 'gpt-4o', messages }); const text = res.choices[0].message.content ?? '';
panel.textContent = text; // not parsed as HTML panel.innerHTML = DOMPurify.sanitize(text); // a call the rule does not see through const plan = Plan.parse(JSON.parse(text)); // a schema parse await writeFile('out/answer.md', text); // file contents, not a path await fetch(`https://api.example.com/notes?q=${text}`); // the origin is already fixed await prisma.$queryRawUnsafe('SELECT 1 WHERE $1 = $1', text); // a bound parameter return { plan, text };}- Any
.contentthat no recognised SDK call produced: a CMS page, a fetched JSON body, a database row. - A value passed in as a parameter, held in a
let, or returned from any helper. The rule does not follow calls or reassignments, so a model result that crosses a function boundary is not seen. - An SDK call that is not awaited where the result is bound, and a
generateTextimported from anywhere other thanai. regex.exec(text), a local function namedexec, andspawn/execFilewithout a shell.path.join(root, text)and every other transform besides the ones listed above.
When not to use it
Section titled “When not to use it”If your code never calls an LLM SDK, it does nothing. If you deliberately execute model output inside a real sandbox (a separate process with no credentials, a WebAssembly runtime), disable it on that line and say why in the comment.