cd /news/artificial-intelligence/i-told-the-ai-a-scanner-flagged-this… · home topics artificial-intelligence article
[ARTICLE · art-112944] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

I Told the AI "A Scanner Flagged This" — and It Agreed With Everything

A developer found that a commercial AI model from a frontier lab removed only 20% of false alarms in code scanning, while another removed 51%, because the first was told a scanner had flagged the code and agreed with it. The developer built four countermeasures to combat this sycophancy, but found their effectiveness depends more on the model than the prompt.

read12 min views2 publishedAug 27, 2026

I gave two AI models the same 200 pieces of code, the same prompt, the same

question. One of them removed 51% of the false alarms. The other removed only

20% of the false alarms — and confirmed 90% of everything it was shown.

Same inputs. Same instructions. A 2.5× difference in the only thing I was

measuring.

The model that failed isn't a bad model. It's a well-regarded commercial model

from a frontier lab. It failed at this task for a specific, predictable reason:

I told it that a scanner had already flagged the code, and it believed me.

This article is about that failure mode, the four countermeasures I built to

fight it, and the uncomfortable finding that whether those

countermeasures work is mostly a property of the model, not the prompt.

Quick context if you're new to the series.

My scanner works in two stages. Fixed rules trace data flows through code and

find every place where user input reaches something dangerous — a database query,

a file open, a system command. That stage is deterministic: same code in, same

suspects out, every time.

The problem is that this stage over-reports, badly. It flags code like this:

String id = request.getParameter("id");
if (!id.matches("[0-9]+")) {
    throw new IllegalArgumentException("id must be numeric");
}
String sql = "DELETE FROM products WHERE id = " + id;
stmt.executeUpdate(sql);

Untrusted input genuinely does reach the SQL string. The path is real. But

matches("[0-9]+")

means id

can only ever be digits, so there's no attack. The

rules see the connection; they can't see the meaning.

So the second stage hands each flagged snippet to a language model and asks one

narrow question: is this actually exploitable?

That's the whole bet. And it has a flaw sitting right at the centre of it.

The prompt has to explain the situation. Here's the actual line from my

llm.py

:

A static-analysis engine flagged the code below as a possible {vuln_class}
({cwe}). Decide whether it is a REAL vulnerability or a FALSE ALARM.

Read that as the model reads it. Before it sees a single line of code, it has been

told that an expert system already concluded something is wrong.

That's an enormous hint. And language models are trained, deliberately, to be

agreeable — reinforcement learning from human feedback rewards responses people

approve of, and people approve of agreement. The tendency is well documented

enough to have a name: sycophancy.

For most applications, agreeableness is harmless or even desirable. For mine it's

fatal. A judge that agrees with every accusation isn't a judge. It's a rubber

stamp with a token bill.

Worse, it fails invisibly. The pipeline runs, verdicts come back, findings get

reported — and the output is identical to having no judge at all. You'd only

notice by measuring, which is exactly what most people building these systems

never do.

The first defence is the bluntest. From the RULES

block of the real prompt:

RULES
- Judge ONLY the code shown. Never assume code you cannot see.
- confirmed=true only if attacker-controlled input reaches the dangerous sink
  with nothing neutralising it on the way.
- confirmed=false if the input is not attacker-controlled, never reaches the
  sink, or is neutralised (parameterised query, escaping, encoding, allow-list).
- Static engines raise false alarms routinely. Rejecting a finding is a correct
  and expected answer. Do NOT agree just because the engine flagged it.

That last rule is doing the heavy lifting. It does three things at once:

It states a fact about the world — static engines raise false alarms

routinely. This isn't an instruction, it's context that makes rejection

reasonable rather than contrarian.

It labels rejection as correct. Without this, the model may treat "no" as a

failure to be helpful. Models optimise hard for being helpful.

It names the specific failure — "do NOT agree just because the engine flagged

it." Naming the exact error is more effective than a general instruction to be

careful.

Note also what rule 2 and 3 do: they give concrete, checkable criteria for

both answers. Not "use your judgement" but "confirmed=false if... neutralised

(parameterised query, escaping, encoding, allow-list)." The model is being handed

a checklist, and "allow-list" is precisely what catches the matches("[0-9]+")

case.

Instructions describe. Examples demonstrate. The prompt includes exactly two

worked examples — and the balance between them is the point.

The first shows a genuine vulnerability being confirmed:

String id = req.getParameter("id");
stmt.executeQuery("SELECT * FROM t WHERE id = " + id);
{
  "reasoning": "getParameter is attacker-controlled. It is concatenated straight
                into the SQL string. Nothing escapes or parameterises it.",
  "confirmed": true,
  "severity": "Critical",
  "explanation": "An attacker can change the SQL statement and read or modify
                  arbitrary rows.",
  "fix": "PreparedStatement ps = conn.prepareStatement(\"SELECT * FROM t WHERE id = ?\"); ps.setString(1, id);"
}

The second shows a flagged finding being rejected:

String id = req.getParameter("id");
PreparedStatement ps = conn.prepareStatement("SELECT * FROM t WHERE id = ?");
ps.setString(1, id);
ps.executeQuery();
{
  "reasoning": "Source is attacker-controlled but bound with setString on a
                parameterised query. The driver sends it as data, never as SQL.",
  "confirmed": false,
  "severity": "Low",
  "explanation": "The query is parameterised, so the input cannot change the
                  SQL structure.",
  "fix": "No change needed."
}

Why this specific pairing matters:

Both examples start identically. Both begin with

req.getParameter("id")

— attacker-controlled input, no question about it. The

difference is entirely in what happens next. The model can't shortcut on "does

this involve user input?" because both do.

The rejection is not a trivial case. I deliberately didn't use an obviously

safe example like a hardcoded string. I used a real flagged finding — one a static

engine would genuinely report — being correctly dismissed. That's the behaviour I

need, so that's what I demonstrate.

The reasoning models the right thinking. "The driver sends it as data, never

as SQL" is the actual security reasoning, stated compactly. The examples aren't

just teaching output format; they're teaching how to think about the question.

One-shot prompting with only the confirmation example would have been actively

harmful — it would have taught the model that the expected answer is yes.

My scanner also retrieves real published vulnerabilities similar to the code under

review, and pastes them into the prompt as background. It's a genuinely useful

feature and a genuinely dangerous one.

Think about what's happening: I'm about to ask "is this code vulnerable?" and

immediately before asking, I'm showing the model three real, confirmed

vulnerabilities that look like it. That's textbook priming.

So the retrieved block carries an explicit disclaimer:

KNOWN RELATED ADVISORIES — real-world {vuln_class} reports, for grounding only.
They do NOT prove the code below is vulnerable; judge it on its own merits and
reject it if it is safe.

And one implementation detail that matters more than the wording: when nothing is retrieved, the prompt is byte-for-byte identical to the no-retrieval version.

This one isn't in the prompt text at all — it's in the response schema:

_RESPONSE_SCHEMA = {
    "type": "OBJECT",
    "properties": {
        "reasoning":   {"type": "STRING"},
        "confirmed":   {"type": "BOOLEAN"},
        "severity":    {"type": "STRING", "enum": ["Critical", "High", "Medium", "Low"]},
        "explanation": {"type": "STRING"},
        "fix":         {"type": "STRING"},
    },
    "required": ["reasoning", "confirmed", "severity", "explanation", "fix"],
}

reasoning

is declared first. Since the model generates the JSON in order, it

must produce its analysis before it emits the verdict.

Flip those two fields and the dynamic reverses: the model commits to confirmed:

and then generates text justifying a decision it has already made. Same

true

model, same prompt, worse answers — purely from field order.

The reasoning is capped at three sentences, incidentally, for a boring reason I'll

cover in a later article: an earlier model spent its entire 8,192-token budget

"thinking" and returned nothing at all.

Here's where I have to be careful, because this is exactly the kind of claim

people make without evidence.

On my small test project, the scanner flags 6 candidates, of which 2 are

sanitised-but-flagged cases. The judge rejected exactly those 2. Its verbatim

reasoning on the SQL case:

"The input 'id' is validated against a numeric-only regex '[0-9]+' before being

concatenated into the SQL query. This allow-list guard prevents any SQL

injection characters from reaching the sink."

And on the XSS case:

"The source is request.getParameter('comment'). The input is passed through a

strip() method that uses a regex to allow only alphanumeric characters and

spaces. This effectively neutralizes any XSS payload."

That's correct security reasoning, arriving at "no" on a finding an engine

flagged. The countermeasures appear to be working.

At benchmark scale, on 200 stratified cases from the OWASP Benchmark:

result
False alarms removed 50 of 98 (51%)
Real bugs lost 2 of 98 (2%)
Precision 0.50 → 0.67
95% CI [0.43–0.57] → [0.59–0.74], non-overlapping

Half the false alarms gone, at a cost of 2% of the real vulnerabilities, and the

confidence intervals don't overlap — so the improvement isn't sampling noise.

That's the result I wanted.

I ran the identical experiment with a different model. Same 200 candidates. Same

code slices. Same prompt, character for character.

Judge Confirms False alarms removed Precision
gemma-4-31b-it (31B, open weights)
73% 51%
0.50 → 0.67
gpt-4o-mini (commercial)
90%
20% 0.50 → 0.55

gpt-4o-mini

confirmed 90% of everything put in front of it. Its precision

improvement — 0.50 to 0.55 — has confidence intervals that overlap with doing

nothing at all. Statistically, I cannot distinguish it from having no judge.

All four countermeasures were present in both runs. One model followed them. The

other largely didn't.

"You compared against the cheap mini model. Of course it lost."

Fair. So I ran the identical 200 candidates — same slices, same prompt,

character for character — through gpt-4o

, the frontier sibling.

Judge Confirms False alarms removed Real bugs lost Precision
gemma-4-31b-it (31B, open weights)
73% 51%
2% 0.50 → 0.67
gpt-4o (frontier)
80% 40% 0%
0.50 → 0.62
gpt-4o-mini (small commercial)
90%
20% 1% 0.50 → 0.55

Capability does matter within a family: gpt-4o

removed twice the false alarms

of its little sibling, and it was the only judge that kept every single real

bug. But it still finished behind a free, mid-size open model, and its

confidence interval ([0.55–0.70]) still touches the no-judge interval

([0.43–0.57]). Gemma remains the only judge whose improvement is statistically

separated at this sample size. The leaderboards predicted the mini → 4o step.

Nothing predicted the open model on top.

Going deeper(skip if you just want the lesson)First: does the headline survive the scanner's own later fixes? After this

experiment ran, the discovery stage gained its final recall improvements, so I

re-ran the same protocol on the finished pipeline. The result reproduces —

50 of 97 false alarms removed (52%) at the same 2% real-bug cost, precision

0.51 → 0.67, interval still separated. I cite the original run throughout

because it's the one all three judges shared.I also ran

gpt-4o-mini

over theentirecandidate set — 4,356 of 4,357

judged, at 148 judgments/minute for about $1.60 — to check whether the sample

was misleading me. It wasn't: at full census it removed 111 of 611 false alarms

(18%) and lost 3 of 743 true positives (candidate-level counts from an earlier

run, before the final recall fixes landed). The intervals still overlap.It's worth being precise about what this does and doesn't show. It does

not

show thatgpt-4o-mini

is a worse model in general — it's faster, cheaper per

token, and better at plenty of things. It shows that onthistask, withthis

prompt, it is much more likely to agree with a premise handed to it.I'd also caution against over-generalising from n=3 models. What I can defend is

narrow: the spread between three reasonable choices was large enough to

dominate every other engineering decision I made, and the public benchmark

scores predicted only part of the ordering — the step up inside the OpenAI

family, not the open model finishing first.

1. If your prompt asserts something, measure whether the model just agrees.

This applies far beyond security. Any time you write "the system detected X, is

this correct?" or "the user reported Y, is that plausible?", you've handed the

model a conclusion. The polite output you get back may be pure echo. The only way

to know is to feed it cases where the right answer is "no" and count.

2. Prompt engineering has a ceiling set by the model. I spent real effort on

those four countermeasures and I'd write them the same way again — the model that

follows them produces a statistically solid result. But identical instructions

produced a 2.5× difference in outcome. The prompt is necessary; it isn't

sufficient.

3. For judgement tasks, skepticism beats capability. Moving up a capability

tier helped — gpt-4o doubled its sibling's false-alarm removal at zero recall

cost. But the property that made Gemma the best judge here isn't reasoning power

or knowledge. It's willingness to contradict a premise supplied in the prompt.

That trait doesn't appear on any leaderboard I know of, which means you cannot pick a judge by reputation — you have to measure it on your own task.

4. Design the schema, not just the prose. Putting reasoning

before

confirmed

cost me nothing and changes the model's process. Field order is

prompt engineering.

Next in this series: the OWASP Benchmark — 1,478 test cases, 701 of them built

specifically to trick tools like mine.

I'm Ali Afana — AI builder and security researcher, writing from Gaza. I build systems in public, measure them against ground truth, and keep the receipts. This scanner is one project on a longer road — follow for what comes next.

── more in #artificial-intelligence 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-told-the-ai-a-scan…] indexed:0 read:12min 2026-08-27 ·