Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

When the Guard Itself Breaks: Making .NET AI Agent Guardrails Fail Closed

We test guardrails by sending them bad input: an injection, a card number, a refund that's too big. That's necessary, but it only tests the guard when the guard is working. The more uncomfortable question is: what does y

We test guardrails by sending them bad input: an injection, a card number, a refund that's too big. That's necessary, but it only tests the guard when the guard is working. The more uncomfortable question is: what does your agent do when the guard itself breaks? It throws, its dependency is down, it gets wired into the pipeline wrong, or it runs in a code path you didn't expect.

In my experience the answer is usually "it allows the request", and nobody notices until much later. So I went through the guard layers of a .NET helpdesk agent and broke each one on purpose. Below is every place one of them wanted to fail open, and what I changed so it fails closed instead.

The stack is .NET 10 with Microsoft.Agents.AI 1.25.0 and Microsoft.Extensions.AI 10.11.0. The model in the tests is an offline fake that behaves badly on purpose: it calls tools it was never offered, loops, and ignores refusals. Code below is trimmed to the shape; omitted parts are marked.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete guarded agent from this post, with a step-by-step PDF, is inside Tech Skill Builder.

Quick map: where the guards sit

Agent Framework gives you three middleware hooks, and I use all of them:

  • agent run middleware: sees the incoming messages and the final response (input screen, caller check)
  • function calling middleware: sees every tool call the model makes (the tool policy)
  • IChatClient middleware: sees every model call (budgets, output scrubbing)

Each of the failures below happened at one of these layers.

Break 1: the guard throws, and the agent carries on

My first "halt this run" was an exception thrown from the function middleware. It didn't halt anything. FunctionInvokingChatClient caught it, sent "Error: Function failed." back to the model as the tool result, and the loop kept going. That's the documented behaviour of MaximumConsecutiveErrorsPerRequest: exceptions are reported to the model and only rethrown once the limit (3 by default) is reached.

So a guard that "stops" a run by throwing actually gives the model up to three more tries. The fix is to set it to zero, so exceptions propagate immediately:

.UseFunctionInvocation(configure: f =>
{
    f.MaximumConsecutiveErrorsPerRequest = 0; // guard halts must propagate
    f.IncludeDetailedErrors = false;          // no exception text to the model
    // iteration limit set from the model-call budget (complete project)
})

But that creates the opposite problem: now a flaky database aborts runs too. So the tool guard catches real tool bugs itself and lets only its own halts escape:

try { result = await next(context, ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (GuardHaltException) { throw; }            // ours: end the run
catch (Exception)
{
    // audit as "tool_error", then return a generic result
    // (exception text never goes back into the context window)
    return ToolErrorResult;
}

Break 2: the classifier is down

The input screen calls an IInputClassifier to score prompt-injection risk. If that classifier is a remote service, at some point it will time out. The tempting code is catch { verdict = Safe; } so users aren't blocked by an outage.

That's a guard that quietly becomes "allow" exactly when you can't see what's happening. Mine blocks instead, with its own rule id so it's visible on a dashboard:

try { verdict = await classifier.ClassifyAsync(text, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
    return Block(run, "classifier_unavailable"); // fail closed
}

The user gets "temporarily unavailable, try again shortly", not an agent with no screen. A test (Classifier_outage_fails_closed) plugs in a classifier that always throws and checks that the rule fired and the model was called zero times.

Break 3: nobody said who's asking

Every policy decision depends on the caller: a customer can refund up to $50, staff up to $500, and customers only see their own orders. The caller comes from AgentRunOptions.AdditionalProperties, set by the host, never by the model.

So what happens when the host forgets to set it? The easy default is an anonymous caller with some role, and the run continues. Mine blocks the run as no_caller before the model is called at all. The test asserts exactly that: outcome Blocked, zero model calls.

Break 4: a layer runs outside the pipeline you designed

The function and chat middleware need per-run state (the caller, counters, rules that fired). I pass it through an AsyncLocal scope opened by the run guard. The question is what a layer should do if that scope is missing, for example because someone registered the tool middleware on a different agent, or called the chat client directly.

Skipping the check ("no scope, nothing to enforce") is failing open. Mine treats it as a wiring bug and halts:

public static GuardRun Require(string layer) =>
    CurrentRun.Value ?? throw new GuardHaltException(
        "no_guard_scope", $"{layer} ran outside a guarded agent run");

Outside_a_guarded_run_the_guard_fails_closed pins it.

Break 5: streaming takes a different path

Two separate streaming surprises:

  • If you register agent run middleware with only the non-streaming function, the docs warn that streaming calls "will only run in non-streaming mode". I checked: the stream came back as one update. Register both delegates.
  • An AsyncLocal value set inside an async iterator doesn't survive across yield return. So a scope opened once at the top of the streaming run guard is gone by the time the inner stream reaches a tool call. Because of Break 4, the result is a halt with no_guard_scope, not an unguarded tool call, and a streaming test with a tool call shows exactly that without the fix. The fix is to re-establish the scope around every MoveNextAsync of the inner stream:
// inside RunStreamingAsync
while (true)
{
    ValueTask<bool> step;
    using (GuardScope.Begin(run)) step = e.MoveNextAsync();
    // await step, turn GuardHaltException into a refusal, yield e.Current
    // (full loop, hold-back redaction and final outcome in the complete project)
}

This is the best argument I know for Break 4: a guard that halts on missing state turned a silent bypass into a failing test.

Break 6: the model just keeps asking

A denial is not an error. The tool guard returns a short reason-only result (DENIED by policy (rule id), never the arguments) and the model is free to try again. A stubborn or manipulated model will. Two limits stop that from turning into a loop:

  • a tool-call budget per run (6 by default), halted as tool_budget_exceeded
  • a denial budget: after 3 denied calls in one run, the next one halts the run as repeated_policy_denials

A halted turn isn't persisted, and a test checks that the session's stored history afterwards is identical to what it was before the halted turn.

One I chose not to use

FunctionInvocationContext.Terminate = true ends the loop after the current call. It sounds like a halt, but the Learn docs warn it can leave the history inconsistent, and in my spike the run ended on a function result with empty text. Fine for "we're done", not something I'd hang a security decision on.

The checklist

  • [ ] Guard exceptions end the run (MaximumConsecutiveErrorsPerRequest = 0), tool bugs don't
  • [ ] A broken dependency of the guard blocks, with its own rule id
  • [ ] A missing caller blocks before the first model call
  • [ ] A layer with no run state halts instead of skipping its checks
  • [ ] Both streaming and non-streaming paths are registered and tested
  • [ ] Denials and tool calls have per-run budgets
  • [ ] Every fail-closed path has a test that forces it

The project behind this has 94 offline tests (85 unit, 9 integration with WebApplicationFactory), and a good share of them exist only to force a guard into one of the failures above.

There's also one case none of this covers: every call the model makes passes every policy, and the action is still wrong, because the instruction came from the data the agent read, not from the user. The project handles that too, and the PDF walks through how.

Get the complete project

The snippets here are the outline. The complete .NET 10 solution is in Tech Skill Builder: all three guard layers, the per-tool policy catalog, the secret redactor, the streaming hold-back redactor, the text-free audit trail, an ASP.NET Core minimal API with SSE streaming, a CLI demo with 13 scenes and all 94 tests, plus an in-depth PDF guide. No API key needed to run it.

Tech Skill Builder gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. Join Tech Skill Builder

What does your agent do today when its guard throws?

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.