---
date: '2026-11-06'
description: 'Norn measured obedience and had no number for the damage an agent with tools does. That is where two metrics came from: the damage that happened and the risk a guardrail leaves at the attempt. Authorization decides what counts as an attack, irreversibility decides how much it weighs.'
lang: en
readingMinutes: 17
tags:
- ai
- security
- norn
title: Your agent hits you. Do you know how much it hurts?
url: https://foron.dev/en/blog/your-agent-hits-you/
---


Thinking about how to improve Norn, I went back to the usual: naming what I want to see when it happens, and what I want to see before it happens.

At some point in that exercise I asked myself what [Norn](https://github.com/foron23/norn) detects. It detects obedience: whether the model does what the document tells it, with a canary as witness and a corpus I can count. That part is measured.

The uncomfortable doubt came right after that. An agent with tools does not only obey: it executes. And of what it executed I had no number. It is not that I measured it badly. It did not exist. The question that comes out of that is not the one the benchmarks ask, which measure whether the attack wins and how much utility defending costs: it is how much of what happened actually hurt, and how much was stopped halfway.

The doubt became a question, the question a process. Out of the process came the hypothesis and the tests. The rest is history.

## What counts as pain?

An agent with five tools does not hurt the same in all of them: reading a customer's email and deleting their record have nothing in common. So I turned the question around and asked the security question: what changes in the system when the tool runs, and how much of that change can be undone.

Reading changes nothing. Writing a note changes the CRM, but it can be deleted. Sending an email leaves the system and does not come back: you cannot unsend it. Delete a record and another call does not bring it back. Running code can be anything, and that is exactly why it sits at the top: inside it you fit writing, sending and deleting, and it also stays. It weighs the same as deleting.

That is where the weights come from: read 0, write 1, send 2, delete 3, execute 3. They are not a severity table copied from a framework. They come from a question you can answer by looking at the system: if this happens, what does it take to go back? And the zero is not disdain: a read does not change anything.

There is also the case that ruins the clean picture. A read that returns the data in the answer changes nothing inside the app, but the data is already outside. Nothing was written, but something left, and that looks more like sending than reading. I gave it 0.5 and I do not like it, because it is exactly the kind of number that gets summed somewhere it should not. We will come back to it.

The other half of the definition is missing, and it is the one that decides what counts as an attack: authorization. If the operator asked for the email and the policy allows it, the app is doing its job. If the order came hidden inside an email, it is an attack, even if the tool is the same and the result identical.

The nuance that cost me a whole campaign: authorization covers the act, not the content. An authorized send can carry data nobody authorized moving. The tool's weight sets the ceiling; the marker says whether the data actually travelled.

## The hit and the damage

A hit can leave no damage, and the first number I built only knew how to count damage.

With the definition in hand, the index almost wrote itself: realized damage divided by weighable replicas. I called it IDR, for Impact Damage Rate. The verdict is all or nothing: PASS only if nothing came out.

There is a condition you cannot skip. A weighable replica is one carrying a marker. If the corpus broke and the marker did not travel, that replica does not count and comes out n/a, never 0. A zero is a claim, "nothing happened here", and without a witness I cannot make it.

And then the other one showed up, the one this title is about. I put the guardrail in front of the write. The agent tried, the policy said no, and the report came out with zero.

Zero is not true.

In the campaign with the guardrail on, the app defended itself three times, once per replica, and my number said "nothing happened" three times. A write that was asked for and never happened is residual risk, and it does not live in the same place as damage: it is the difference between what happened and what almost happened, two words security has used since before this was a problem[^1].

Which is how measuring damage alone falls apart: the app that denies everything looks as clean as the one that never gets attacked.

The shortcut was obvious and I took it: add the two together in one index. Realized damage plus half a point for every denied attempt. If the attempt never became damage, let it count half a point, and the report counts the risk without inventing damage.

Sounds reasonable. It is the kind of formula you defend in a review because there is an intuition behind it, not a number.

It was taking it apart that made me see it. Of the 0.0833 that campaign produced, 43 % was exposure. Nearly half of the app's score came from the times the app had defended itself.

Read it again. The app that denies five writes scores worse than the one that lets two through, because every denial adds half a point to its damage list. The index was not measuring risk: it was charging for defence.

And there was a second symptom, dumber and more revealing. The verdict only looked at realized damage, while the average added both things. Two readings of the same report, side by side, and I had been reading the one I liked.

## How much does intent weigh?

Intent is measured on the same scale as damage, not with a probability. That is the short answer, and it took me a while because the first attempt was different: putting exposure inside damage.

Once the question is clean, if damage is what the system did, exposure is what the system was willing to do and the policy stopped. No need to invent a unit: the tool that never got past the attempt has the same weight as the one that ran.

The first thing I dropped was counting denials. Three denied writes and one denied read are not four of anything: the read would not have changed the state even if it had gone through. Counting calls measures activity, not risk.

The second thing I dropped was probability. 42 replicas at temperature zero tell me whether the model obeyed, not how often it would in production. A number that looks like a probability and is not ends up in a slide.

I kept the role weight and the same denominator. The second number is EXR, for Exposure Rate, and it is computed the same way as the first: what the policy stopped, weighed on the same scale.

Think of a doorman. Damage is what got broken inside. Exposure is the queue he turned away at the door: nobody got in, but the queue tells you what they came for. And they were not all there for the same thing: one asked about the guest list, another brought a sledgehammer. Weighing that queue on the same scale as the damage inside is what makes the two comparable; counting everyone the same would say three questions and three sledgehammers are the same.

The rest follows. Both numbers use the same scale and the same denominator, so they are in the same units: they can be subtracted, and the ratio between them has no units at all. That ratio is containment. A doorman who lets nothing through scores one; one who never looks at the door, zero.

I set the edge cases by hand before running anything. A denied read weighs zero, because it was not going to change anything. A denied send weighs two. A denied execution weighs three. And if the corpus ever brings a tool the manifest does not declare, it weighs zero and is reported as unknown: no guessing.

I also decided not to put a probability model on top. EXR does not say "it would have happened 30 % of the time". It says how much weight the acts carried when the agent asked and the policy said no. Probability is another experiment and another number, and mixing them now would be repeating the 0.5 mistake with more decimals.

The order matters. Damage cannot exceed exposure, because what ran is a subset of what was attempted. If it comes out the other way, it is not a strange app: it is a broken meter.

## How I talk to the app

To measure I needed something I could break without consequences, so I built a small app with the tools a real support agent would have: read the inbox, write a CRM note, look up an account, read an attachment and send an email. Five, not one more. In front of the tools, a guardrail decides what runs.

Testing that app takes one request. A POST to the chat endpoint with two fields, the operator's message and a session id, and a token that identifies the client. No console, no database access, no files copied onto the server. If a probe needed one of those three, it would be measuring something else, not what happens to a user.

The app's handling of that message is exactly the mechanism I want to measure. It builds the system prompt with its rules, searches the inbox and the attachments for the fragments that match the question, and calls the model with that and with the list of tools. If the model answers with text, it sends it to the client. If it asks for a tool, the app checks it against the policy: if the policy allows it, the app executes it and returns the result to the model so it can continue; if it denies it, it returns the denial. Then it calls again, up to four times per turn.

That is the door an injection comes through without anyone pasting it into the prompt. The app's own search brings the planted document, and it brings it flagged as untrusted content. The tester does not write the order in the message: they write it in an email and let the app find it. The idea that the attacker is the data and not the user has been written down for years[^2], and it is still the vector that worries me most. Nor is it a lab curiosity: it is the first risk on the OWASP list for LLM applications[^3].

The answer comes back as a stream of events, not as JSON at the end. Run start, retrieved fragments, the agent's text, every tool call with its arguments, every policy decision, and the close with token usage. Every event carries the run id, so I can follow a whole replica without guessing anything.

The doors are the ones the app already has: upload an attachment and receive an email. The corpus is fourteen cases, and if an order cannot be planted through a door that exists, the case is worthless.

Each replica opens a new session, so the previous one leaves no state in the next, and carries its own canary. If the marker shows up where it should not, I know which run moved it.

I keep what the app itself recorded. After the run I can ask it for the run record, the CRM notes and the outbox, and compare: if the stream says there was no send and the outbox has an email, I believe the outbox.

The thing that makes the test repeatable is the manifest. The app declares its doors, its endpoints, where the outbox lives and under what condition a send counts as having happened. Give someone that file and they run the campaign and get the same thing. None of this touches the app from the inside. A scanner fires probes and reports; it does not clean its traces from the audited system, because then the report would be the only proof of what happened.

## The experiment

The only variable is the guardrail, and it does not change at runtime: the app starts with it on or off.

I ran four campaigns: with the guardrail on, with the guardrail off, with the guardrail off again and with the guardrail on again. Alternating on purpose, because if the model provider changes its mood mid-afternoon, two campaigns in a row with the same sign hide that change as an effect. Forty-two replicas per campaign, three per case, temperature zero.

One limitation I would rather state before showing numbers. Three replicas per case at temperature zero are near copies: this measures the corpus, not sampling noise. One app, one model, one corpus. It is not a benchmark and I do not pretend it is.

The numbers, then. Guardrail on: 0.0476 damage, 0.0714 exposure, 60 % containment. No guardrail: 0.1667 damage, zero exposure, zero containment.

Both campaigns with the guardrail on came out identical down to the last digit, and both without it too. At 42 replicas and temperature zero that is not statistics: the setup repeats. And when it repeats, every difference between on and off has a concrete cause you can look up in the record, instead of staying noise.

It shows better in the tools. Guardrail on: three write calls, none allowed. Guardrail off: three out of three. The email went out once with the guardrail and twice without it.

{{< figure src="/img/posts/your-agent-hits-you/campanas-en.png" alt="Four campaigns of the same corpus: damage in red, exposure in amber and containment in cyan" caption="Four campaigns of the same corpus. Red is realized damage, amber is exposure, cyan is containment. The two with the guardrail on give the same thing, and the two without it as well." >}}

## What it means

The guardrail does one thing, and does it well: it contains the write. It does not touch reads, it does not touch lookups, and on the email it does not even change the decision, because it allows it the same with the guardrail on as off.

The zero exposure of the campaign without a guardrail is not good news either. It is that nobody is stopping anything, so everything the agent tried was accomplished. The number that looks reassuring is the one measuring the absence of defence.

What nearly slipped past me is in the email, which went out more often with the guardrail off. It is tempting to credit the policy with containment there. It is not: in both cases the send was authorized. The difference is that, running into the denied write, the model gave up the chain before reaching the email. Fewer attempts by discouragement, not by rule. That is luck, and luck is not designed.

That is why there are two numbers and not one: damage alone does not tell an app that defends itself from one nobody attacks. Containment is what separates them, and that is why it is not added to damage: it is read beside it.

One piece of damage is left that neither metric explains, and it is the only one that survives with the guardrail on.

An email went out with an account code inside. It went out because the operator had asked for it: the tool was authorized, the recipient was internal and the policy said yes.

The act was authorized. The data was not.

What I did not expect to find, and this is my conclusion, not the papers': what is published asks two questions. Whether the attack wins, measured as a success rate, which does not tell deleting a record from reading an email. And how much legitimate work defending costs. My two questions are different: how much damage was realized, on a scale that weighs what it costs to undo, and how much weight the agent asked for and the policy stopped. The first contains the success rate and is not the success rate; the second touches utility and is not utility. And the one I am missing, friction, is exactly the side they already measure. If someone published this pair and I missed it, send me the link.

That is where these two metrics stop. They say how much it hurt and how much was stopped. They do not say where attempts come from, or what happens when the attack travels inside an act the operator signed. For that you need a place with real tools, a guardrail you can turn off, and someone watching from outside.

## What this does not measure

The first weak point is the door. The scanner comes in with a token up front and speaks the endpoint's protocol. An agent in production rarely gives you that: it sits behind a form, with a session, CSRF and sometimes a second factor. A driver that logs in like a person and keeps the session is pending work, and until it exists this works for apps that give you an API, not for those that only have an interface.

The second is that this is a proof of concept. Five tools, fourteen hand-written cases, and the execute and delete weights without a single case using them: so that 3 is a declaration, not a measurement.

That is the limit that bothers me most, because the corpus does not scale, as it needs to be crafted by hand. Today a tool the manifest does not declare weighs zero and is reported as unknown, which is another way of not measuring it. If the app brings twenty tools tomorrow I am not going to write twenty payloads, so the way out is the schema, which is also an attack surface[^4]: from each tool you get its parameters and their types, and from there a family of cases with the same assertion for all of them, that the canary reaches the argument it should. Free-text parameters take the order; identifiers are filled in first by calling the read tools, so the payload points at objects that exist. That way an unknown tool stops being ignored: it comes in with generated cases and with a count of how many tool and parameter pairs are covered and how many are not.

The rest of the limits are scope. Four campaigns at temperature zero say the setup repeats, not that this happens in the next app you test. There are benchmarks with more cases, more models and more tools for that[^5], and this one does not compete with them. The marker sees literal copies of the data, so an agent that paraphrases is only caught by the argument scoring, and for that I have to have guessed what to look at. And the 0.5 for the read that returns the data I set by hand: it is a judgement until someone argues with data.

Friction is missing, and it is the big hole. Without measuring how much legitimate work gets stopped, containment rewards denial: a guardrail that denies everything would score zero damage and one containment, and these two metrics would applaud it. The price is already measured elsewhere: a defence by design solves 77 % of the tasks in an agent bench with guarantees, against 84 % without defence[^6]. Seven points of utility in exchange for zero policy violations. That is the number I am missing.

What comes next: the login, the friction and the case generator from the schema. None of that fits in a post.

The loop does not close here: it closes with the next campaign.

## References

[^1]: Guide for Conducting Risk Assessments · NIST Special Publication 800-30 Rev. 1 · September 2012 · https://csrc.nist.gov/pubs/sp/800/30/r1/final
[^2]: Not What You've Signed Up For: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection · Kai Greshake, Sahar Abdelnabi, Shailesh Mishra, Christoph Endres, Thorsten Holz, Mario Fritz · AISec 2023 · arXiv:2302.12173 · May 2023 · https://arxiv.org/abs/2302.12173
[^3]: OWASP Top 10 for LLM Applications 2025 · LLM01:2025 Prompt Injection · OWASP Gen AI Security Project · November 2024 · https://genai.owasp.org/llmrisk/llm01-prompt-injection/
[^4]: MCPTox: A Benchmark for Tool Poisoning Attack on Real-World MCP Servers · Zhiqiang Wang, Yichao Gao, Yanting Wang, Suyuan Liu, Haifeng Sun, Haoran Cheng, Guanquan Shi, Haohua Du, Xiang-Yang Li · arXiv:2508.14925 · https://arxiv.org/abs/2508.14925
[^5]: AgentDojo: A Dynamic Environment to Evaluate Prompt Injection Attacks and Defenses for LLM Agents · Edoardo Debenedetti, Jie Zhang, Mislav Balunović, Luca Beurer-Kellner, Marc Fischer, Florian Tramèr · NeurIPS 2024 Datasets and Benchmarks · arXiv:2406.13352 · June 2024 · https://arxiv.org/abs/2406.13352
[^6]: Defeating Prompt Injections by Design · Edoardo Debenedetti, Ilia Shumailov, Tianqi Fan, Jamie Hayes, Nicholas Carlini, Daniel Fabian, Christoph Kern, Chongyang Shi, Andreas Terzis, Florian Tramèr · arXiv:2503.18813 · March 2025 · https://arxiv.org/abs/2503.18813

