The Fail-Open Problem in AI Agent Security
You built a gate. The gate checks every tool call your AI agent makes. If the call violates policy, the gate denies it. The agent never reaches the tool. Clean architecture.
Now: what happens when the gate throws an exception?
If the answer is “the tool runs anyway,” you don’t have a gate. You have a suggestion box. And the agent is free to ignore suggestions.
This is the fail-open problem, and it is the single most common structural defect I see in AI agent security tooling. Not the most exotic. Not the most sophisticated. The most common. And it survives in production because it doesn’t show up in a demo, doesn’t trigger in a unit test, and doesn’t raise an alarm in a dashboard. The security layer fails silently, the agent continues happily, and nobody knows the gate was advisory all along.
I want to walk through three real examples. They all came out of a security review I ran on my own project, raucle, earlier this year. I’m writing about them because they’re not raucle-specific. They’re patterns. If you’re wiring security into an agent framework, at least one of them is probably sitting in your codebase right now.
What fail-open actually means
Fail-open is when a security control’s failure path leads to “allow” instead of “deny.” The control doesn’t crash the system. It doesn’t raise an alert. It just stops enforcing, and the system continues as if nothing happened.
The opposite is fail-closed: if the security layer can’t make a determination, the default action is deny. The system stops. Somebody has to investigate.
In traditional security architecture, fail-closed is the default for anything that matters. Your firewall drops packets it can’t evaluate. Your authentication layer rejects tokens it can’t verify. Your SIEM flags events it can’t classify. The entire posture is conservative: when in doubt, block.
In AI agent security, the defaults are inverted. Most integrations fail open. And the reason is structural, not intentional. It’s how the frameworks are built.
Bug one: the exception that vanished
The first bug was in the LangChain integration.
LangChain uses a callback system to let you hook into the agent’s execution loop. You register a callback handler, and LangChain calls it at various points: before a tool runs, after it returns, on error. The Raucle callback handler sat on the before_tool_call hook, ran the capability gate, and raised CapabilityDenied if the call violated policy.
That’s the design. Here’s what actually happened.
LangChain’s callback manager catches exceptions thrown by callback handlers. By default, it logs them as warnings and continues execution. The tool call that the gate just denied proceeds anyway. The agent never sees the denial. The user never sees the denial. The only trace is a warning line in a log file that nobody reads.
So the gate was doing exactly the right thing: examining the call, determining it was unauthorised, raising an exception to stop it. And the framework was quietly swallowing that exception and carrying on.
This is not a LangChain bug. LangChain’s callback system is designed this way for reasonable reasons: a buggy callback shouldn’t crash your agent loop. The problem is that a security callback is not a normal callback. A security callback’s failure mode needs to be “stop execution,” not “log and continue.” The framework’s default tolerance for handler exceptions is correct for observability callbacks and wrong for enforcement callbacks.
The fix was two lines: raise_error=True and run_inline=True on the callback registration. The first tells LangChain to propagate the exception instead of swallowing it. The second ensures the callback runs synchronously on the same thread, not queued on a handler pool where exceptions disappear into the void. Both are load-bearing. Both are commented in the code now, because if someone removes either one during a refactor, the gate becomes advisory again and nothing will tell them.
The regression test doesn’t mock the callback. It runs the real tool.run() dispatch path with a blocked tool call and asserts that the tool never executes. Because the point of the test is to catch the exact failure mode where the framework circumvents the gate, and you can’t catch that if you stub out the framework.
I’ll be honest: this bug was in production code for longer than I’d like to admit. The gate was working. The tests were passing. The integration looked correct. It wasn’t until I ran the actual end-to-end path, with a real LangChain tool dispatch, that the tool executed despite the denial. That’s the thing about fail-open bugs: they’re invisible until you exercise the integration path, not the unit.
Bug two: the missing argument that scanned nothing
The second bug was in the MCP server.
MCP, the Model Context Protocol, is how agents talk to tools. A tool declares its input schema, the agent constructs a call with arguments, and the server processes it. The Raucle MCP server sat in front of tool execution, scanning the call arguments for policy violations before passing them through.
The bug: when a tools/call request omitted a schema-required argument, the scanner received an empty string for that argument. It scanned the empty string. The empty string is clean. It contains no injection patterns, no forbidden values, no policy violations. The scanner returned CLEAN. The server returned ALLOW. The tool executed with a missing required argument.
Think about that for a moment. The tool had a declared schema requiring certain arguments. The agent’s call omitted one. The security layer scanned nothing, found nothing wrong, and let it through. The security control’s failure to handle a malformed input produced an allow verdict.
This is fail-open by omission. The scanner didn’t crash. It didn’t error. It just didn’t account for the case where an argument it was supposed to check wasn’t there. And because the default verdict for “nothing matched” is ALLOW, the missing argument sailed through.
If you’re building a scanner, the instinct is to treat “no match” as “clean.” That’s correct for a detection system: if your patterns don’t fire, the input is probably fine. But it’s wrong for an enforcement system. An enforcement system’s default should be: if I can’t verify the input, I deny it. Missing required arguments are not clean. They’re unverified.
The fix: the server now enforces each tool’s declared inputSchema.required before scanning. If a required argument is missing, the server returns isError and the call never reaches the scanner. The scanner only sees complete, schema-conformant argument sets.
This bug is particularly insidious because it exploits the gap between two systems with different defaults. The scanner’s default is “allow if nothing matched.” The schema validator’s default should be “reject if required fields are missing.” When the schema validator is absent or bypassed, the scanner’s permissive default fills the gap, and the gap is exactly the size of an attacker’s missing argument.
Bug three: the error message that leaked
The third bug was the least dangerous but the most instructive.
When the gate denied a call, it included a reason in the denial. The reason was visible to the caller, which in this case meant the agent framework, which in some configurations meant the LLM itself. The reason contained the raw exception text from whatever internal check failed: stack traces, internal path names, constraint details.
This isn’t fail-open in the classic sense. The gate still denied the call. But it’s a fail-open pattern in a broader sense: the security control’s failure mode leaked information that should have stayed internal. An attacker who can read the denial reason gets a diagnostic channel into the gate’s internals. They learn which constraints exist, which checks fired, what the internal naming scheme looks like. That’s reconnaissance material.
The fix was straightforward: the caller-visible denial reason is now generic (“policy violation: constraints not satisfied”), and the full traceback is logged server-side only. But the pattern is worth naming, because it shows up everywhere in agent security tooling. Security controls produce rich diagnostic output by default, because the developer who built the control wanted to debug it. That output then flows to the agent, which passes it to the LLM, which may pass it to the user, which may be the attacker.
The principle is the same as fail-closed: when a security control fails, it should fail minimally. Deny the action. Log the detail internally. Give the caller the least information compatible with useful error handling. If the caller needs more, they can correlate using a request ID against server-side logs.
Why this keeps happening
These three bugs share a common shape. In each case, the security control was correctly implemented in isolation. The gate worked. The scanner worked. The denial mechanism worked. The failure was at the integration boundary, where the security control meets the framework it’s embedded in.
And in each case, the framework’s default behaviour was permissive. LangChain catches callback exceptions because that’s the safe default for observability hooks. The MCP scanner returns CLEAN for empty input because that’s the safe default for a detector. The denial reason includes diagnostic text because that’s the safe default for debugging.
None of those defaults are wrong in their original context. They’re wrong when repurposed for enforcement. The problem is that agent frameworks don’t distinguish between “I’m observing what happens” and “I’m controlling what happens.” The integration point is the same callback hook, the same middleware slot, the same event handler. The framework can’t tell the difference, so it applies the same permissive defaults to both.
This is why fail-open is a structural problem, not a coding mistake. You can’t fix it by writing better code in the security control. The control is correct. You fix it by understanding the framework’s failure semantics at the integration boundary and explicitly overriding the defaults that don’t serve enforcement.
What to do about it
If you’re building or integrating security controls into an agent framework, here’s what I’d check, in order:
Find the exception path. When your security callback or middleware throws, what happens? Does the framework propagate the exception and stop execution, or does it catch it, log a warning, and continue? Don’t guess. Write a test that throws from inside the callback and assert that the tool never runs.
Find the missing-input path. When the input your control is supposed to evaluate is absent, malformed, or empty, what verdict does your control produce? If the answer is “ALLOW because nothing matched,” you have a fail-open gap. Your control should reject inputs it can’t fully evaluate.
Find the output path. When your control denies an action, what information flows back to the caller? Does it reach the LLM? Could it reach the user? Strip it to the minimum. Log the detail server-side.
Test the integration, not the unit. Every fail-open bug I found was invisible at the unit level. The gate denied correctly. The scanner scanned correctly. The denial fired. The bugs only surfaced when I exercised the real framework dispatch path, with real tool execution, and asserted on the outcome rather than the callback. If your tests mock the framework, they’re testing a version of the system that doesn’t exist in production.
Default to deny. This is the architectural principle. Every path your control doesn’t explicitly handle should produce a deny verdict. Unknown input format: deny. Missing required field: deny. Exception in evaluation: deny. Timeout: deny. The only paths that produce ALLOW are the ones your control explicitly evaluated and found compliant. This is the difference between “enumerate bad” and “fail-closed by construction.” It’s also the design change that turned raucle from a system that sometimes failed open into one that structurally can’t.
The uncomfortable part
I found these bugs in my own project. I’m writing about them because I think the instinct in security is to hide your bugs and present only the clean outcome. That instinct is wrong. If you’re building security tooling, the people evaluating it deserve to know what was broken, how it was found, and what was fixed. A security project that claims it has never had a fail-open bug is either lying or hasn’t looked hard enough.
The three bugs above are fixed. Each one has a regression test that exercises the real integration path. The fixes are commented, because the most dangerous thing about a fail-open fix is that someone removes it during a refactor and the system quietly reverts to advisory mode.
But the patterns are still out there. If you’re wiring security into an agent framework, check your exception path. Check your missing-input path. Check what flows back to the LLM. The gate you built might not be a gate at all.
