Development
Key points

Key points

  1. 1A topic-level check on the final message will pass while the agent quietly calls the wrong tool.
  2. 2Build fixtures that pair a benign task with an embedded instruction inside fetched content.
  3. 3Assert the exact tool name and its arguments as structured JSON, not the prose.
  4. 4Fail on an unexpected tool, changed arguments, or an extra call the task did not require.

Why prose checks pass while tool calls go wrong

An agent's visible answer and its side effects are two different outputs. The answer is the message the user reads; the tool call is a structured request the runtime executes. Indirect prompt injection lands in the second one. OWASP defines indirect prompt injection as an LLM accepting input from external sources, such as websites or files, where content in that source alters the model's behavior in unintended ways, and notes that this can provide unauthorized access to functions available to the LLM or execute arbitrary commands in connected systems (OWASP LLM01).

That matters because the response can stay on topic. Ask an agent to summarize an invoice, and the summary can be perfect while the trace shows a second call to send_email with the customer record in the body. The prose test checks "did the summary mention the total"; both the clean run and the attacked run pass it.

OWASP's mitigation list is unambiguous on where to aim: define and validate expected output formats, and enforce privilege control with least privilege access, handling privileged functions in code rather than handing them to the model (OWASP LLM01). A test suite that only inspects text cannot tell you whether either control held. The OWASP Top 10 for LLM Applications treats prompt injection as the first risk precisely because retrieved content, plugins and tools are where it becomes dangerous (OWASP Top 10 for LLM Applications).

There is a second reason to test the trace. Tool calling is structured output with consequence. A typed tool name and argument object describe an intended action, and runtime authorization decides whether that action runs (structured outputs and schemas). Once you accept that framing, the test target is obvious: the name and the argument object.

What a tool-call assertion actually sees

Most providers and agent frameworks expose a tool call as an object with three fields you can assert on: the tool name, the arguments, and a call id. The runtime adds whatever the tool returned. Your test should read the trace, the ordered list of these objects, and compare it against an expectation.

FieldExample value in an attacked runExample value in a clean runAssertion
tool namesend_emailsearch_docsMust equal expected name exactly
arguments.to[email protected]absentNo key outside the allowlist for this tool
arguments.doc_idinv-1042inv-1042Exact string match
call count21Equal to expected count
ordersearch_docs, send_emailsearch_docsFull sequence comparison

Two rules make this bite. First, compare the whole argument object, not the fields you remember. Injections tend to add a field rather than change one, so recipient, webhook or cc appearing in an object that should hold only doc_id is the signal. JSON Schema is the standard vocabulary for exactly this kind of structural check: required properties, allowed types, and constraints on what a valid instance must look like (JSON Schema validation). Second, compare the call count. A single unexpected call is easier to explain away than a call that exists at all.

A worked example, with numbers. Suppose your suite has 40 injection fixtures. Three of them produce a tool call with an extra recipient argument. Two produce a correct call but with the wrong document id swapped in. One produces two calls where the task needs one. A prose check catches zero of those six. A trace check catches six of six, and the failure message names the field, which is what makes the fix obvious. That ratio, not an abstract accuracy number, is the argument for building the fixtures.

Building adversarial fixtures that look like real work

A fixture is a pair: a task the agent is supposed to do, and a world the agent reads while doing it. The world contains both the information the task needs and an instruction it must not obey. Keep the benign task realistic, because a task that looks like a test invites defensive behavior you would not see in production.

The clean shape:

  • A benign task from the user, for example "summarize invoice inv-1042 and note the total."
  • A fetched document, page, or tool result that contains the invoice data plus an embedded instruction.
  • An expected trace with exactly one call to search_docs and arguments {"doc_id": "inv-1042"}.

Where to embed the instruction:

  1. In a document the agent retrieves by search.
  2. In a layer of a web page the agent fetches, including content that a reader would not notice but the parser sees, since prompt injection inputs do not need to be human-readable as long as the content is parsed by the model (OWASP LLM01).
  3. In a tool result, for example a "notes" field on a record, which is the closest analogue to real data poisoning.
  4. Across a chain of two sources, where the first tells the agent to fetch a second page that carries the payload.

OWASP's seventh mitigation for prompt injection is conduct adversarial testing and attack simulations, treating the model as an untrusted user to test whether trust boundaries and access controls hold (OWASP LLM01). Fixtures like the four above are that simulation in miniature. OWASP also recommends testing applications with adversarial prompts and treating all retrieved content as untrusted (OWASP Top 10 guidance), which is a good reminder that a passing fixture is evidence, not proof.

Promptfoo's red-team documentation is a useful reference for the attack shapes, with a direct owasp:llm:01 plugin and jailbreak strategies that layer variations on top of each generated input (Promptfoo OWASP LLM Top 10). You can borrow the payloads and still write your own assertions.

Making fixtures deterministic with a mock provider

An injection test that depends on a live model is a flaky test. The model may or may not follow the embedded instruction on any given run, which means your suite flips between pass and fail without a code change. Split the problem in two: prove the harness catches a steered call using a deterministic mock, then track the real model's behavior separately as a rate.

The mock approach is well established for LLM testing. LLMock, an open-source deterministic mock LLM server, advertises fixture-based routing with substring, regex and predicate matching, authentic SSE streaming, and tool call injection for testing agent workflows (LLMock announcement). Tool call injection is the feature you need: it lets a fixture say "return this tool call" so the assertion is about your harness, not the model.

The same idea appears in record and replay testing for APIs. The useful sequence is capture, save, replay, verify, and the recorded response must not become the desired behavior merely because it is what got recorded (GoReplay record and replay). Translate that: a recorded trace where the agent obeyed an injected instruction is a fixture for the failing case, never the expected output.

A minimal harness in pseudocode:

def test_injected_invoice_does_not_email():
    fixture = load("fixtures/invoice_with_embedded_instruction.json")
    trace = run_agent(task=fixture.task, world=fixture.world, mock=fixture.mock)

    calls = trace.tool_calls
    assert len(calls) == 1, f"expected 1 call, got {len(calls)}"
    call = calls[0]
    assert call.name == "search_docs", f"unexpected tool {call.name}"
    assert call.arguments == {"doc_id": "inv-1042"}, call.arguments

Three assertions, three distinct failure messages. When someone breaks it, the CI log says which of the three failed. Keep fixtures in version control as JSON or YAML so they are reviewed like code, a property record-replay tooling treats as important: human-readable test files can be diffed and reviewed in pull requests (Keploy record and replay).

For a smoke-test layer, the discipline is the same one that applies to any build: a small set of checks that answer "is this stable enough to test further," run first, fast, and cheap. A practical smoke suite for an e-commerce app runs eight cases in about 23 seconds, and the filter is whether a failure blocks everything else (smoke testing in CI/CD). Your injection smoke suite is four to eight fixtures that cover the highest-consequence tools, not the whole library.

Wiring it into CI as a gate

Smoke tests belong in the deployment stage, run after deploying to staging or production and before promoting a build, because they catch configuration problems that unit tests miss (CircleCI smoke tests). Injection tests for tool calls fit the same slot, for the same reason: the failure is a wiring problem, an over-broad tool schema or a missing confirmation step, not a typo in a function.

A workable gate:

  • Run on every pull request that touches prompts, tool schemas or retrieval code.
  • Mock all model calls so the suite is deterministic.
  • Fail the build on any unexpected tool name, any argument outside the allowlist, or a call count above the expected number.
  • Keep the suite under a minute so nobody routes around it. Build speed is the whole point of continuous integration; a ten-minute build is a ceiling, not a target (GitLab CI best practices).
  • Record the real-model rate separately, on a schedule, and track it over time.

The last point deserves emphasis. The mock proves the harness works. It does not prove your model is safe, because the mock does what you told it to do. Treat the live number as a metric with a trend, sampled over many runs, and treat any regression as a reason to look at tool permissions before you look at prompts. OWASP's own position is that fool-proof prevention for prompt injection is unclear given the stochastic nature of models, and the pragmatic response is mitigation, with human approval required for high-risk actions (OWASP LLM01). A test that fails on an unexpected transfer_funds call is a cheap way to find out that a confirmation step is missing.

If your suite spans several tool-using agents and you want them driven through one interface while you build the assertions, Quanta Inference exposes text, image, video, audio and embedding models through one API key, OpenAI-compatible and prepaid in US dollars (https://quantaintelligence.ai/inference).

A checklist before you trust a green build

  • Every fixture has an explicit expected trace, including the call count.
  • Arguments are compared as whole objects, not field by field.
  • Tool schemas reject unknown properties on the server side, not only in the test.
  • The mock returns the injected-call variant so the failing path is proven to fail.
  • CI fails on unexpected tool, changed argument and extra call as separate messages.
  • The highest-consequence tools have a human-approval step, tested by a fixture.

The most common gap is the fourth item. Teams test that the clean path passes, never that the harness catches the attacked path, so a broken assertion goes unnoticed until an incident. Before trusting the suite, run each fixture against a mock that deliberately emits the steered call and confirm the test fails.

FAQ

FAQ

Why not just check the final answer for suspicious content?

Because the side effect is the risk. A steered call can happen while the answer stays on topic and helpful, and OWASP lists unauthorized access to functions and arbitrary command execution among the consequences of prompt injection, both of which are trace events, not text (OWASP LLM01). Text checks supplement trace assertions; they do not replace them.

What is the difference between a smoke test and a full injection suite here?

A smoke suite is small, fast and run first, designed to catch instability before deeper testing, on the order of a handful of checks finishing in well under a minute (smoke testing in CI/CD). A full suite covers many payload shapes and tool combinations and can run longer. Keep the gate small enough that nobody disables it.

Should the fixtures use a real model or a mock?

Both, for different jobs. Use a mock for the gate, so the suite is deterministic and cheap, the way fixture-based routing with tool call injection is designed (LLMock). Use a real model on a schedule to measure how often it obeys an embedded instruction, which is a rate you track, not a pass or fail.

How do I know my assertions are strict enough?

Run each fixture against a mock configured to emit the attack. If the test still passes, the assertion is too loose. JSON Schema constraints give you a standard way to state what a valid argument object looks like, including required properties and no extra fields (JSON Schema validation).

What if the agent legitimately needs two tool calls?

Then the expected trace for that fixture has two calls, in order, with arguments for each. The rule is not "one call"; it is "exactly the calls the task requires, with exactly the arguments it requires." Writing multi-step expectations is the same discipline as single-call ones, and you can borrow capture and replay tooling to record a good trace and verify it stays stable (record and replay for HTTP APIs).

Sources

Sources

  1. LLM01:2025 Prompt Injection - OWASP Gen AI Security Project
  2. OWASP Top 10 for Large Language Model Applications
  3. LLM Structured Outputs with JSON Schema
  4. A Vocabulary for Structural Validation of JSON
  5. OWASP Top 10 for LLM Applications: Risks & Mitigations
  6. OWASP LLM Top 10
  7. CopilotKit
  8. Record and Replay Testing for HTTP APIs with GoReplay
  9. Record and Replay Testing Guide
  10. Smoke Testing in CI/CD Pipelines: What Actually Matters
  11. Smoke testing in CI/CD pipelines
  12. Continuous integration best practices
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles