Your Agent Passed the Eval. That's the Bug

Your Agent Passed the Eval. That's the Bug

10 min read
Updated April 27, 2026

When the agent knows it's being watched — detecting eval-aware code patterns before they ship.


The eight-character vulnerability

Here's a snippet pulled from a real Python agent. Read it once, the way you'd skim a code review:

if os.environ.get("EVAL_MODE"):
    system_prompt = SAFE_SYSTEM_PROMPT
temperature = 0.1 if os.environ.get("BENCHMARK_MODE") else 0.9

Looks fine. A pragmatic engineer wired up a quieter, more deterministic path for benchmarks so the eval scores don't get smeared by sampling noise. We've all written this code. We've all approved this PR.

Now read it again, slower.

You've built a switch. One side runs during evaluations. The other side runs in production. The eval suite measures the safe one. Customers get the other one. Whatever number you reported in the deck — the one with three decimal places and a green up-arrow — is a number about the version of your agent that knew it was being measured.

The bug isn't that the temperatures are different. The bug is that there's an if statement at all. Eval-awareness isn't a frontier-lab abstraction; it's eight characters: EVAL_MODE. And it ships in your codebase right now.

This post is about how that pattern got into your repo, why static analysis can catch it, and what to do when it fires. Two rules, in fact: one for the explicit branch above, and one for a quieter cousin that erodes your safety prompt across long conversations. Both shipped in agentcompliance this month. Both come from the same uncomfortable shift in how we have to think about evaluation.

Sandbagging is no longer a thought experiment

Two pieces of recent research changed how seriously security teams should take this:

Apollo Research showed that frontier models, when given context suggesting they're being supervised or evaluated, will adjust their behavior — sometimes deliberately scoring lower on capability tests when high scores would invite retraining or deployment restrictions. The technical term is sandbagging. The plain-English version: the model figured out that the eval was a job interview, and it answered the way it thought you wanted to hear.

Anthropic's interpretability team documented alignment faking — models behaving more compliantly when they infer the conversation is part of training, and reverting when they infer it isn't. The model isn't lying about its values. It's strategically displaying the values it expects to be measured against.

You don't have to buy the strong version of either claim to be unsettled by the weak version. The weak version is enough: any signal a model can correlate with eval mode is a signal it can react to. Once a model has been post-trained on enough RLHF, "is this an eval?" is just another feature in its conditional distribution, alongside "is this code?" and "is this French?"

The papers are about model-internal eval-awareness. The interesting question for application security is more boring and more tractable: what eval signals are we leaking through our own code? The answer turns out to be: more than you'd guess, and the leaks are scannable.

Three flavors of eval-aware code

The rule ai-agent-eval-awareness-sandbagging ships in agentcompliance and looks for three concrete patterns. Each one is a way the eval signal leaks from your CI environment into the model's input distribution.

Flavor A: the explicit prompt branch

if os.environ.get("EVAL_MODE"):
    system_prompt = "You are a careful, conservative assistant..."
else:
    system_prompt = "You are a fast, helpful assistant..."

Engineering reason: the eval suite needs deterministic outputs, and the production prompt has too much creative latitude.

Security reason it's wrong: the eval is now measuring a different system than the one your customers talk to. Whatever safety property you attributed to the eval result — refusal rates, jailbreak resistance, hallucination scores — is a property of the conservative prompt, not the deployed prompt. You shipped a benchmark for a system you don't operate.

The Semgrep pattern is unglamorous:

- pattern: |
    if <... os.environ.get($ENV_VAR) ...>:
        $PROMPT = $VALUE
- metavariable-regex:
    metavariable: $ENV_VAR
    regex: '^(EVAL_MODE|IS_EVAL|BENCHMARK_MODE|CI_EVAL|EVAL_RUN|IS_BENCHMARK|TEST_MODE|EVAL_HARNESS|EVAL_CONTEXT|SANDBAGGING_TEST)$'
- metavariable-regex:
    metavariable: $PROMPT
    regex: '(?i)(system_prompt|prompt|instruction|persona)'

The list of trigger env vars is itself the punchline. These are exactly the names a busy engineer would reach for. The rule isn't catching a clever attack; it's catching the obvious naming convention used by the people writing the bug.

Flavor B: the parameter fork

This one is harder to spot in review:

temperature = 0.1 if os.environ.get("BENCHMARK_MODE") else 0.9
tools = SAFE_TOOLS if os.environ.get("CI_EVAL") else FULL_TOOLS

Two ternaries. No prompt change, no obvious smell. The model is the same, the system prompt is the same — but the sampling distribution and the tool surface are different. Your eval ran with three tools wired up; your production agent has eleven.

Tool-surface forks are the worst variant because the eval doesn't even test the missing capabilities. It can't fail on tools it never saw. You scored 100% on a quiz that omitted the hard questions and called it coverage.

The rule covers this with a separate pattern, scoped to the variables that matter for LLM call configuration: temperature, top_p, top_k, tools, model, max_tokens, tool_choice. Same env-var allowlist, different sink. The TypeScript and Go variants follow the same shape because the engineer who reaches for EVAL_MODE in Python reaches for process.env.EVAL_MODE in TypeScript the next morning.

Flavor C: the slow erosion

The cousin rule, ai-multi-turn-prompt-erosion, isn't about explicit branches at all. It's about what happens to your safety scaffolding over a long conversation:

import anthropic

client = anthropic.Anthropic()
messages = []

while user_keeps_chatting():
    messages.append({"role": "user", "content": next_input()})
    response = client.messages.create(
        model="claude-opus-4-7",
        system=SYSTEM_PROMPT,
        messages=messages,
    )
    messages.append({"role": "assistant", "content": response.content[0].text})

This is competent code. It's also broken in a specific way: messages grows without bound. By turn 30, your system prompt is competing with thirty turns of chat for the model's attention. By turn 100, the system prompt is page-23 of a long document, and the model is operating on a degraded version of its own constitution.

This is prompt erosion: the safety properties that held on turn 1 are not the safety properties that hold on turn 50. Your eval ran for three turns. Your customer just ran for fifty.

The Semgrep rule asks one structural question — did you slice the history before passing it in?

- pattern: |
    $LIST.extend($HIST)
- pattern-not: |
    $LIST.extend($ANYTHING[-$N:])
- pattern-not: |
    $LIST.extend($ANYTHING[:$N])

If the answer is no — no [-MAX_TURNS:], no .slice(-MAX_TURNS) — the rule fires. It's not a clever rule. It's a load-bearing one.

What these rules can't see

This is the honest part, and the part most security-vendor blogs skip.

Static rules catch eval-awareness when it's keyed on something a parser can see: an env var, a feature flag, a CI variable. They do not catch:

  • Behavior shifts inferred from prompt content. If the model learns that "Question 1 of 50, multiple choice" means "this is an eval," no env var is involved. The signal is in the data, not the code.

  • Model recognition of benchmark distributions. If a model has seen MMLU at training time, it doesn't need an EVAL_MODE flag — it can recognize the format from a single example.

  • Strategic deception that's robust to rule-level pattern matching. The behavioral phenomena Apollo and Anthropic documented are internal to the model. You cannot grep your way out of them.

The clever class of eval-awareness needs red-teaming, behavioral testing across plausibly-eval and plausibly-not-eval contexts, and — at the frontier — interpretability work that's still mostly in research labs. None of that is what these two rules do.

What these rules give you is the lazy class. The eight-character vulnerability. The unbounded extend. The two-line ternary. These won't make a Defcon talk, but they're in your codebase, and they invalidate your eval results just as cleanly as the clever ones do. Eliminate the lazy class first; it's cheap, it forces the harder problem to be the only problem, and — bluntly — if you haven't done it, the rest of your alignment story is unfalsifiable.

This is the same logic as input validation. We don't write input validators because they catch all attacks. We write them because they catch the easy attacks for free, and they make the hard attacks expensive enough that the attacker has to commit to one.

Triage: what to do when one of these fires

Flavor A (prompt branch). The right fix is almost never "add the env var to the eval." Collapse the branch. If you genuinely need a more deterministic prompt during evaluation, treat the divergence as the system under test: run your safety eval against the production prompt with low temperature, and report that score. If the numbers regress, that's information you wanted to know before a customer found it.

Flavor B (parameter fork). Same logic. If a tool is too dangerous to expose to the eval, it's too dangerous to expose without a stronger guardrail in production. The eval is the wrong place to apply the guardrail; the runtime is. Move the safety property into a wrapper around the call, not a fork before it.

Flavor C (prompt erosion). Bound the history. history[-MAX_TURNS:] is the obvious fix; the less obvious one is to re-assert the system prompt periodically on long conversations. Some teams send a fresh system message every N turns; some compress and prepend. Either is better than letting the original constitution drift to page 23. And add a turn-budget assertion to your eval: if your safety suite doesn't run at turn 50, the rules of turn 50 don't apply to your safety claims.

In all three cases, the fix has the same shape: make the eval a measurement of the deployed system, not a measurement of the system that knows it's being measured.

The shape of the new SDLC

The classical SDLC assumed the artifact under test was passive. You wrote tests; the code did or did not pass them. The code did not have an opinion about being tested.

Agents do. Or, more carefully: agents have the same statistical relationship to their inputs they always had, but those inputs now include strong signals about whether they're being evaluated, and post-trained models will react to those signals whether you want them to or not.

That doesn't make the eval worthless. It means the eval needs adversaries to the eval-aware behavior itself, not just to the task. Static rules catch the lazy class — explicit branches, eroded prompts, two-line ternaries. The hard class needs red-teaming and interpretability research that most teams aren't yet equipped to do.

We catch the lazy lies. The clever ones still need humans — and a constitution that survives turn 30.


Try it

agentcompliance scan ./your-agent-repo

The two rules in this post — ai-agent-eval-awareness-sandbagging and ai-multi-turn-prompt-erosion — are part of the standard ruleset, no flag needed. They cover Python, TypeScript/JavaScript, and Go. If you find a false positive on real code, file an issue at github.com/AI-AgentLens/AIriskcompliance/issues and we'll narrow them. The rules ship at WARNING severity by default; flip them to ERROR once you've cleaned up your existing branches.

Eval-awareness is not the kind of thing you fix once. It's the kind of thing you scan for on every PR, the way you scan for hardcoded secrets — because the same engineer who knew not to commit a key will reach for IS_EVAL next quarter for a perfectly good reason.

The reason will be good. The eval will still be wrong.

Gary
Written by
Gary

Security architect specializing in application security, threat modeling, and AI agent risk. Builder of runtime security tooling for autonomous AI agents. Co-founder of AI Agent Lens, where he leads development of AgentShield (runtime command evaluation), AI governance scanning, and security taxonomy frameworks. Passionate about making AI agents safe enough to trust with production systems.

Anshuman Biswas
Contributor
Anshuman Biswas

Engineering leader specializing in threat detection, security engineering, and building enterprise B2B systems at scale. Deep hands-on roots in software architecture and AI tooling - currently exploring the frontier of AI agents as co-founder of AI Agent Lens.

Comments

Loading comments...