I've been writing code with AI assistants for a while now. Copilot, Claude, ChatGPT — I've used them all. And for the most part, they're genuinely impressive. They save time. They help me think through problems. They write boilerplate I'd rather not write myself.
But here's the thing nobody talks about enough: AI-generated code has a specific category of bugs that normal linters don't catch.
Not syntax errors. Not style violations. I'm talking about security vulnerabilities, false-confidence patterns, and subtle logic issues that look completely correct — until they're not.
I got burned enough times that I built something about it. It's called hallint, and it's a free, open-source static analysis tool designed specifically for AI-generated code. You can find it on GitHub: github.com/Asyncinnovator/hallint
The Problem With AI Code (That ESLint Won't Help You With)
Traditional linters were designed for human-written code. They're great at catching things humans commonly get wrong — unused variables, missing semicolons, incorrect type usage.
But AI assistants fail differently.
Here are the patterns I kept seeing over and over:
1. Hardcoded secrets
AI assistants will happily write const API_KEY = "sk-abc123..." in your source code. It passes every lint check. It works perfectly in dev. And then you push to GitHub and your key is live in a public repo.
2. SQL injection
// AI generates this and it looks totally fine
const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`)
No ESLint rule will flag this. But it's a textbook SQL injection vector. Every time.
3. Missing auth on route handlers
AI is great at generating CRUD routes. It's not great at remembering to add authentication middleware. You end up with a perfectly structured Express router where half the routes are completely unprotected.
4. Permissive CORS
app.use(cors({ origin: '*' }))
This is a one-liner fix for the CORS errors you see in development. AI assistants suggest it constantly. It's also a wildly bad idea in production.
5. innerHTML with unsanitized strings
element.innerHTML = userInput // XSS waiting to happen
LLMs write this pattern a lot. It's the obvious, readable way to set content — and it's a cross-site scripting vulnerability.
What I Built
hallint is a TypeScript library and CLI tool that scans your codebase for these AI-specific failure patterns.
It uses three detection layers:
- Regex pattern matching — fast first pass for known bad patterns
- AST analysis — structural checks that understand code, not just text
- LLM review (optional) — uses Ollama or another provider to do deeper semantic analysis The idea is simple: it's a linter that knows what AI gets wrong, not just what humans get wrong.
Getting started
No install needed — just run it with npx:
npx @asyncinnovator/hallint-cli ./src
Or scan a specific glob pattern:
npx @asyncinnovator/hallint-cli "./src/**/*.ts"
Only care about serious issues? Filter by severity:
npx @asyncinnovator/hallint-cli ./src --min-severity high
It exits with code 1 on any critical or high finding, which makes it easy to use as a CI gate.
Use it as a library
If you want to integrate it into your own tooling:
npm install @asyncinnovator/hallint
import { scan } from '@asyncinnovator/hallint'
const result = await scan({
files: ['./src/**/*.ts'],
rules: 'recommended',
minSeverity: 'high',
})
result.findings.forEach(f => {
console.log(`[${f.severity}] ${f.ruleId} ${f.filePath}:${f.line}`)
console.log(` ${f.message}`)
console.log(` fix: ${f.fix}`)
})
You can also scan a string directly without touching the filesystem:
import { scanSource } from '@asyncinnovator/hallint'
const findings = scanSource(`const apiKey = "sk-abc123def456"`, 'example.ts')
The Rules Shipping
Right now hallint ships with eight rules, all targeting the patterns I described above:
| Rule | Severity | What it catches |
|---|---|---|
hardcoded-secret |
critical | API keys, tokens, passwords in source code |
sql-injection |
critical | User input interpolated into SQL queries |
unsafe-eval |
critical |
eval() or new Function() with dynamic input |
missing-auth-check |
high | Route handlers with no auth middleware |
xss-innerHTML |
high | Unsanitized strings assigned to innerHTML
|
permissive-cors |
high |
cors({ origin: '*' }) in route handlers |
async-no-catch |
medium |
async functions with no try/catch or .catch()
|
http-not-https |
medium | Hardcoded http:// URLs in fetch or axios calls |
How It's Different From Running a Regular Linter
ESLint with the right plugins will catch some of this. But there are a few important differences.
Existing linters weren't built with AI failure modes in mind. They weren't designed around the question "what does an AI assistant get wrong?" They were designed around the question "what do humans get wrong?" Those are different questions with different answers.
hallint is designed to be AI-aware. The rules target the specific intersection of "AI generates this confidently" and "this is actually a problem." The goal isn't to be comprehensive — it's to be precise about the failure modes that matter most for AI-generated code.
The LLM layer is something ESLint can't do. When you enable the optional LLM review, hallint can flag issues that don't match a known regex or AST pattern — things like "this auth flow looks structurally correct but has a logical flaw." That's a different category of analysis entirely.
A Real Example
Here's the kind of thing it catches. Say you take some AI-generated Express code:
import express from 'express'
import db from './db'
const app = express()
app.get('/users/:id', async (req, res) => {
const user = await db.query(
`SELECT * FROM users WHERE id = ${req.params.id}`
)
res.json(user)
})
hallint flags it:
[critical] sql-injection: User input interpolated directly into SQL query.
Use parameterized queries: db.query('SELECT * FROM users WHERE id = $1', [req.params.id])
→ src/routes/users.ts:6
Clean, direct, tells you exactly what to fix.
Drop It Into CI in 5 Lines
One of the things I wanted from day one was a zero-friction GitHub Actions integration. hallint exits with code 1 on critical or high findings, so this just works:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx @asyncinnovator/hallint-cli ./src --min-severity high
You can also add it as a pre-commit hook via husky if you want to catch issues before they even get pushed.
It's Open Source — Contributions Welcome
The whole thing is MIT licensed and lives at github.com/Asyncinnovator/hallint. Issues and PRs are open.
I think this kind of tooling should exist in public, not behind a paywall. If AI-generated code has specific vulnerability patterns, the entire ecosystem benefits from shared, community-maintained detection rules.
The rule-writing surface is intentionally small — each rule is a single file (~30 lines) with a bad.ts / good.ts fixture. If you've seen an AI-specific pattern that hallint doesn't catch yet, the path from "I noticed this" to "I shipped a fix" is genuinely short. Issues labeled good first issue are pre-scoped and ready to pick up.
hallint is MIT licensed. Free to use in personal and commercial projects.
Top comments (10)
Seven of your eight rules are "this is a vulnerability."
async-no-catchis the odd one out, and I'd think hard before shipping it alongside the others. Plenty of correct code has an async function with no try/catch because the caller handles it, or because it's an Express route behind an error middleware, or because throwing is the point. Medium severity won't save you there, since the first time someone runs hallint on a real codebase and gets forty of those next to one genuine hardcoded key, the signal you worked for is gone and the tool reads as noisy. The other seven all share a property that one doesn't: a human looking at the flagged line agrees it's wrong. That's the bar that makes a security linter trustworthy enough to gate CI on, and I'd protect it.The three-layer split makes sense, though the optional LLM pass reintroduces the exact non-determinism the regex and AST layers avoid, so I would pin it behind a flag that never blocks a merge on its own. The missing-auth-on-route case is the one I find most valuable, because hardcoded secrets and raw SQL have existing scanners but "structurally complete router, half the handlers unprotected" is genuinely an AST-shaped problem. Does it track auth middleware across router composition, or only flag handlers defined in the same file where the middleware is registered?
Agreed on the LLM flag — that's exactly how it's planned, opt-in only and never a merge blocker. Non-determinism in a CI gate is a dealbreaker.
On the auth tracking question: currently same-file only. If middleware is registered in
app.tsand the router is defined inroutes/users.ts, hallint doesn't connect the two yet. Cross-file composition tracking is the main reason the AST layer (tree-sitter) is on the v0.2 roadmap — regex can't follow imports.That's the honest limitation right now. github.com/Asyncinnovator/hallint
The categories split cleanly by how catchable they are. Hardcoded secrets, template-string SQL,
cors: '*',innerHTMLwith user input are all syntactic. An AST rule nails them and you get real signal with near-zero false positives.The hard one on your list is missing auth on route handlers. That is not a pattern, it is an absence, and absence is context-dependent. A health check or a public webhook route is supposed to have no auth middleware. The moment you flag every unauthed route you drown people in false positives on exactly the routes meant to be open, and they turn the rule off.
So how does hallint scope that one? Allowlist of public routes, a convention (a
// publicmarker), or does it try to infer intent from the handler body? That single rule is where AI-focused linters live or die, because it is the one class ESLint genuinely cannot touch.Great point — that rule is the hardest one for exactly the reason you described. Right now it skips routes where auth middleware is applied at the router level, which reduces noise, but intentionally public routes (health checks, webhooks) still produce false positives.
The fix planned for v0.2 is an allowlist in config plus a
// hallint-disable missing-auth-checkinline escape. Your// publicmarker idea is cleaner though — opening an issue on it.If you want to weigh in: github.com/Asyncinnovator/hallint
I gate CI on lint rules for the same reason, the agent takes a failing exit code seriously in a way it doesn't take review comments. The missing-auth false positives question below is the one I'd want answered too before wiring it in.
Exactly — exit codes are the only feedback loop agents actually respect. That's the whole reason hallint exits 1 on critical/high by default.
On the false positives question, answered in the thread above. Short version: v0.1 is same-file only, allowlist config is coming in v0.2 before most people would want to hard-gate on that rule.
This is exactly where AI coding needs boring deterministic tools around it. The model can generate patterns faster than humans can review them, so the review layer needs checks that do not get tired. A linter for recurring AI-written security mistakes is much more useful than another reminder to “review carefully.”
This is exactly what the AI coding ecosystem needs not just better models, but better guardrails around them. AI generates code fast. It also generates vulnerabilities fast. The same speed that makes it useful is the same speed that makes it dangerous.
The security bugs AI assistants keep writing the fact that this is a consistent pattern means it's not a bug in any single model. It's a structural gap. And fixing it requires tools like this, not just better prompts.
Curious: what were the most common vulnerability patterns you found? SQL injection? Hard-coded secrets? Unsafe deserialization?
Thanks for building this and for sharing it.
hardcoded secrets and template-string SQL were by far the most common in my experience. Secrets because AI will complete whatever pattern you start — start typing const API_KEY = and it fills in a real-looking value. SQL because string interpolation is the obvious, readable solution and the model has no reason to prefer parameterized queries unless you explicitly ask.
Missing auth on route handlers was third, and the most dangerous because it's invisible — the code looks complete and correct, it just has a structural absence.
Unsafe deserialization is on the roadmap but didn't make v0.1 — it's harder to detect without deeper AST analysis.
Glad it's useful. github.com/Asyncinnovator/hallint