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

> Source: <https://dev.to/alimafana/i-told-the-ai-a-scanner-flagged-this-and-it-agreed-with-everything-4jn6>
> Published: 2026-08-27 11:26:31+00:00

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 that`gpt-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.*
