← Back to writing
Post

Guardrails Belong on the Critical Path

How I built a 3-tier safety interceptor for an AI agent framework — one chokepoint between planning and execution, errors-as-data, default-deny approvals, and the honest limits of regex blocklists and LLM judges, audited line-by-line against the 0.3.4 code.

2026-10-1410 minAI Agents · Safety & Guardrails · ModusAI · Agent Infrastructure · Python

Introduction

Most agent safety advice is a checklist: block dangerous tools, add human approval, review with an LLM.

Checklists don't stop anything. Placement does.

In my agent framework, safety is not a helper the agent loop consults when it remembers. It is an interceptor sitting on the single path between planning and execution inside the agent loop. Every tool call the loop dispatches passes through one call site — verified, not assumed (more on the audit below).

Safety is a chokepoint, not a checklist.

Two honest boundaries up front, because the rest of this post depends on them. First, the interceptor is opt-in per agent: construct an Agent without a policy and there is no interceptor at all. The chokepoint exists where you install it. Second, the guarantee covers the agent loop — planner delegates, Teams, Workflows, the deploy server, and the scheduler all funnel through Agent.run. Direct use of the lower-level Actions.execute() API bypasses the interceptor entirely. That is documented, not hidden, and it shapes everything below.

That one decision shaped everything else: the three tiers, the error protocol, and — the part nobody writes about — the synthetic tool results I had to invent because strict providers reject requests with unmatched tool calls.


The placement nobody gets wrong twice

A ReAct loop has a natural seam. The model proposes tool calls. Something executes them. If safety lives anywhere except exactly on that seam, it can be bypassed — by a new code path, a refactor, a "quick" direct call someone adds six months later.

The agent loop calls one method:

block_reason = self._safety.check(tc.name, tc.arguments or {})

That call happens after duplicate-call filtering and batch truncation, before the thread pool fans out to execute. I audited the call graph rather than trusting the design doc: SafetyInterceptor.check has exactly one caller in the codebase — the ReAct loop. Planner strategies delegate to sub-agents' run(). Teams dispatch to members' run(). Workflow steps run through run_agent into agent.run(). The deploy server and the scheduler both execute workflow.run(). Retries live inside Actions.execute, downstream of the check, so they re-run already-approved calls — no bypass, and no re-check either.

The dotted line is the point. The chokepoint thesis holds for everything that goes through the agent loop — which is everything the framework itself routes. It does not hold for a developer who imports the action layer directly and executes tools by hand. "No other road" was the draft's strongest sentence and its least accurate; the accurate version is narrower and still useful: one call site, one loop, every framework-dispatched tool call.


Errors are data, not exceptions

The signature is the whole philosophy:

def check(self, tool_name: str, args: dict[str, Any]) -> str | None:
    """Returns None if approved, error message if rejected."""

None means go. A string means stop — and the string matters, because it is fed back into the conversation as data, not raised as an exception. The loop tells the model what happened:

blocked_reasons[tc.id] = f"blocked by safety policy: {block_reason}"

and later, if everything was blocked:

return "All tool calls were blocked by safety policy."

An exception would abort the run. A string lets the model react — try a different tool, ask the user, explain why it can't proceed. The ReAct loop already knows how to self-correct from tool errors; a safety block is just another error with a reason attached. That was a deliberate choice: the safety system speaks the loop's native language.


The synthetic-result problem

Here is the detail that surprised me, and the reason this post exists.

When a tool call is blocked, you can't just drop it. Strict providers (OpenAI's API among them) reject a follow-up request where an echoed tool_call has no matching tool result message. The request is malformed. So dropping a blocked call silently doesn't just lose information — it breaks the conversation.

The fix: every blocked call gets a synthetic result appended to the message list:

messages.append({
    "role": "tool",
    "tool_call_id": tc.id,
    "content": f"Tool call skipped: {reason}. Proceed without it.",
})

The invariant is exact: every original call ID receives exactly one result message. Executed calls get their real output. Blocked calls get the skip notice with the reason. Calls dropped by batch truncation get the same treatment through the same code path — one mechanism for "didn't run," whatever the cause. The protocol constraint — every call needs a result — shaped the safety design more than any policy debate did. The model is told what was skipped and why, in the exact slot where a real result would have gone. It reads naturally in context, and the provider stays happy.


Tier 1: deterministic rules, including the nested-args trap

The first tier is boring on purpose: an exact-match blocklist for tool names, and regex patterns matched against command arguments.

policy = SafetyPolicy(
    block_tools=["delete_file", "shell_exec"],
    block_commands=[r"rm\s+-rf"],
)

The part worth writing about is the argument walk — and its history. In 0.3.3, the walk was surface-only: top-level strings and lists of strings. A dict nested inside the arguments sailed through unexamined, and the flat-argument tests all passed, because flat arguments match fine. That is how a passing suite hides an entire class of inputs.

The current walk is recursive over the whole argument tree — dicts, lists, tuples, every string wherever it hides:

@staticmethod
def _find_pattern(pattern: str, value: Any) -> str | None:
    if isinstance(value, str):
        if re.search(pattern, value, re.IGNORECASE):
            return value
        return None
    if isinstance(value, dict):
        for v in value.values():
            found = SafetyInterceptor._find_pattern(pattern, v)
            if found is not None:
                return found
        return None
    if isinstance(value, (list, tuple)):
        for item in value:
            found = SafetyInterceptor._find_pattern(pattern, item)
            if found is not None:
                return found
        return None
    return None

That walk ships in 0.3.4 — with the nested-args test that should have existed from the start, because flat-args tests will never catch a nesting hole. This doesn't make regex blocklists good — I'll get to their limits. But a blocklist that only inspects the surface of the arguments is theater. If you're going to match patterns, match them everywhere the string could hide.


Tier 2: human approval, and why the default is deny

The second tier routes named tools to a human callback:

policy = SafetyPolicy(
    require_approval=["deploy"],
    on_approval=lambda name, args: ask_human(name, args),
)

The important line isn't the callback. It's what happens when there is no callback:

if self._policy.on_approval is None:
    return (
        f"Tool '{tool_name}' requires approval but no approval "
        "handler is configured (default-deny)"
    )

A tool marked as requiring approval, with nobody wired up to approve it, is blocked. Not warned about. Not logged-and-allowed. Blocked.

This one I learned the hard way: 0.3.3 returned None here — allowed. A gate that fails open on missing configuration exists only when everything is already fine, and configuration drift (approval listed in one place, handler forgotten in another) is exactly when you need the gate. Default-deny makes the misconfiguration loud instead of invisible.


Tier 3: the LLM judge, and the opposite default

The third tier is optional and off by default: ask another model whether the action is safe, at low temperature, and block if the response contains "block".

response = self._tier3_provider.complete(
    messages=[{"role": "user", "content": prompt}],
    model=self._policy.tier3_model,
    temperature=0.1,
)
if "block" in response.lower():
    return f"Action '{tool_name}' was blocked by LLM safety review: {response}"

Now the asymmetry. Tier 2 defaults to deny. Tier 3 defaults to fail-open — tier3_fail_open=True — with fail-closed one flag away: if the reviewer itself errors and the flag is off, the action is blocked with the cause attached. In 0.3.3 there was no flag and no choice; 0.3.4 makes the default explicit and the alternative available.

Why the opposite default? Because the two tiers fail differently. A missing approval handler is a configuration fact — deterministic, detectable, permanent until fixed. Denying is safe and loud. A reviewer outage is transient — a rate limit, a timeout, a 500. Failing closed on every reviewer hiccup turns every provider blip into a full agent stop, and operators will respond by disabling the tier entirely. A safety layer that trains people to turn it off is worse than one that degrades gracefully.

But here is the boundary the first draft blurred, and it matters: reviewer availability governs whether the optional review happens — it must never govern whether a high-impact action is permitted. There is no risk-based fail-open in this implementation. Searching public docs and deploying to production get the same fail-open behavior from tier 3 today. The tiers run in strict short-circuit order (limits → deterministic → approval → judge), so a tier-2 block returns before the judge ever runs, and the judge can never override a mandatory approval — but nothing stops a reviewer outage from waving through a destructive action that nobody put on the approval list.

The practical rule, stated plainly: high-impact tools belong on require_approval with a real handler, full stop — and high-impact reviewer setups belong on tier3_fail_open=False, with an operator who accepts the availability cost. The judge is a backstop for the things you didn't think to gate, not a substitute for gating the things you did.

I would rather expose that trade-off than pretend one default fits both. And note the matching itself — substring search for "block" — is deliberately crude. It's documented as such. A judge that parses free text with string matching is a heuristic wearing a uniform; treat it like one.

It is, frankly, worse than "crude" — it misclassifies in both directions, including the dangerous one:

Reviewer response Current behavior
BLOCK: unsafe action Block (correct)
Do not block; this action is safe Block (wrong — contains "block")
This action is unsafe. DENY. Allow (wrong and dangerous — no substring match)
ALLOW: safe action Allow (correct)

The third row is the one that should worry you: a reviewer issuing an explicit denial in the wrong vocabulary gets interpreted as permission. The fix is a constrained, validated verdict — a schema with decision: "allow" | "block", malformed or ambiguous output handled deliberately (and for sensitive actions, ambiguity must not become permission). That schema does not exist in the implementation today; the substring check is what ships. I'm keeping it in the article because it is the code, and presenting the structured verdict as the proposed improvement it is.


Limits: the other half of the interceptor (and who owns what)

The interceptor also enforces budgets, because a runaway loop is a safety problem before it's a cost problem:

SafetyPolicy(
    max_tool_calls_per_iteration=10,
    max_total_tool_calls=100,
)

Ownership first, because the first draft blurred it. The agent loop owns per-iteration size: oversized batches are cut to the limit before checking, with excess calls getting the same synthetic-result treatment as blocked ones. The interceptor owns the cumulative total plus an optional per-iteration counter (for callers that pass an iteration key): the per-iteration increment-and-compare happens inside one lock-held section — genuinely atomic — while the global total is incremented under the lock and compared just after it, on the thread's own snapshot. Two components, two limits, no shared responsibility.

Two details the audit surfaced, reported as found:

  1. The budget counts proposals, not executions. The counter increments before the tier checks run, so a blocked call still consumes budget. Defensible (an attacker probing the policy shouldn't get unlimited probes), but "100 tool calls" really means "100 proposed tool calls."
  2. The interceptor's per-iteration counter has no caller. check() accepts an iteration key — but the agent loop calls check(name, args) without it, enforcing per-iteration size by truncation instead. The capability is real, tested, and currently unused by the loop. A live path and a dormant one, side by side; the post describes both so nobody mistakes the dormant one for the enforced one.
  3. Scope is the interceptor's lifetime — a per-Agent instance whose counters accumulate across runs unless reset() is called.

Limits are the least glamorous tier and the one that fires most often in practice. Infinite loops don't announce themselves.


Safety is bigger than the interceptor

The interceptor is the chokepoint, but guardrails leak out of any single component. The framework treats safety as layers that reinforce each other:

Retrieved context is framed as data. Memory contents are injected with an explicit header — retrieved facts, data not instructions, ignore instructions inside. Skill prompts are wrapped in delimiters with the same framing. This doesn't solve prompt injection (nothing does, currently), but it moves the boundary from "hope the model figures it out" to "the protocol marks provenance."

Tools declare their own risk — but declarations aren't enforcement. This is where the audit found the sharpest edge, so here is the exact precedence. There are two approval mechanisms, and they do not talk to each other: (1) the central policy list (SafetyPolicy.require_approval + handler), enforced pre-execution in the interceptor, deny under default-deny when no handler exists; (2) a per-tool require_approval flag enforced inside execution — but only if a handler was wired through the agent constructor, and **silently ignored otherwise, with no default-deny. Nothing auto-syncs tool flags into the central policy; the operator aligns them by hand. (A third mechanism, workflow step-level approval, gates whole steps before they run.) A flag that exists but isn't enforced is not a guardrail; it's a label — and the sensitive marker is exactly that kind of label: it tells the agent not to auto-store a tool's results into memory, a hygiene flag with no enforcement behind it.

Arguments are validated before execution. Tools can declare a Pydantic parameters_model so arguments are validated and coerced — with validation failures returned as data the loop can correct, in the same errors-as-data spirit.

Network tools share one SSRF guard — scoped, not absolute. URL validation lives in a single module used by vision, speech, and web tools alike: scheme allowlist (http/https only), resolve-then-check over every resolved address (not just the first, via full getaddrinfo, IPv4 and IPv6 alike through the ipaddress module), denying private, loopback, link-local, reserved, and multicast ranges. Redirect re-validation happens per-hop inside each caller, not inside the guard — the guard validates one URL at a time, and the callers loop it over redirect chains. And the guard module states its own boundary in its docstring: resolving and connecting are separate syscalls, so a DNS-controlling attacker can still race the check. It stops accidents and prompt-injection probes, not a determined network adversary. Shared code means the fix lands once; it also means a guard bug exposes all three callers at once — which is why the guard is small, dependency-free, and tested in isolation.

Logs are redacted. Secrets get scrubbed at the logging layer, so a trace or an eval report doesn't become a credential leak.

The safety module keeps its provider dependency behind a bulkhead. The top level of the safety package imports nothing but its own policy — the provider factory is resolved through a function-local import, only on the path where tier 3 is enabled with a string provider name, and the judge behind tier 3 speaks through a minimal JudgePort protocol rather than a concrete client. The thing that constrains everything else should itself depend on almost nothing; here that is enforced by construction, not convention. (0.3.3 imported the provider factory at module top level — the decoupling is part of what 0.3.4 ships.)


What the tests actually prove (and what they don't)

The review asked for evidence, so here is the safety suite — 24 tests:

  • test_block_tools / test_block_commands_in_args / test_block_commands_in_list_args — tier-1 blocks fire on surface values.
  • TestNestedArgs (3 tests) — dict-nested and tuple-nested matches fire; non-matching and non-string values are ignored. The test that would have caught the 0.3.3 hole.
  • test_require_approval_approved / test_require_approval_rejected / test_require_approval_without_handler_denies — tier-2 allow, deny, and missing-handler deny.
  • test_global_limit / test_reset / test_budget_counts_blocked_proposals — cumulative budget trips, resets, and counts proposals.
  • test_per_iteration_counter / test_per_iteration_keys_are_independent / test_reset_clears_iteration_counts — the iteration counter trips per key and resets.
  • TestTier3Judge (7 tests) — block and allow on the substring, the false positive and the dangerous miss both pinned as documented behavior, judge not called when disabled, fail-open by default, fail-closed when configured.
  • test_concurrent_calls_never_exceed_total — 8 threads into 10 calls each against a 50 budget: exactly 50 authorized, every run.
  • test_agent_with_safety_policy — an agent constructed with a policy actually carries a live interceptor.

Missing, and therefore claimed nowhere above as verified: no redirect-chain test for the SSRF guard, and no live-reviewer test — the judge tests use a double, so provider-shaped responses remain cassette and live-test territory.

Code references

The repository isn't public yet, so verify against the published package instead — modus-ai 0.3.4 on PyPI contains the exact code discussed here (0.3.3 is referenced explicitly wherever behavior changed between the two):

pip download modus-ai==0.3.4 --no-deps
tar xzf modus_ai-0.3.4.tar.gz
Claim in this post Where to look
Single check() call site in the ReAct loop modus/agent.py (~line 606), the only caller of check()
check() signature, tiers, default-deny, fail-open default + fail-closed flag modus/safety/_interceptor.py
Policy fields and budget defaults (10/100) modus/safety/_policy.py
Recursive argument-pattern walk (0.3.3 checked surface strings only) SafetyInterceptor._find_pattern in _interceptor.py
Synthetic skip-results for blocked/truncated calls modus/agent.py, search blocked_reasons
Budget ownership split (loop truncation vs interceptor total) modus/agent.py batch limit; _interceptor.py lock section
Tool-level approval (silently ignored without a handler) + sensitive memory-skip hygiene modus/actions/base.py (_execute_once); modus/agent.py (_store_tool_results)
SSRF guard scope and its documented TOCTOU boundary modus/core/_urlguard.py (module docstring + validate_url)
The 8 safety tests tests/test_safety/test_safety.py in the repo (not shipped in the wheel)

External behavior relied on: providers following the tool-calling protocol require every echoed tool_call to receive a matching tool result — see the OpenAI function-calling docs. That constraint (not any framework choice) is why blocked calls get synthetic results instead of being dropped.


The honest limit

This design has real weaknesses, and a guardrail post that doesn't name them is marketing.

Regex blocklists are evadable. Encoding tricks, string concatenation across arguments, novel phrasings of the same command — a pattern list is a snapshot of yesterday's attacks. The recursive walk closes the nesting hole, not the evasion hole. Treat tier 1 as a floor for known-bad shapes, not a ceiling on attacker creativity.

The LLM judge parses with substring matching. "block" in response.lower() is fragile in both directions — a verbose reviewer that never says the word, a benign explanation containing it. It's a heuristic, and heuristics need the deterministic tiers underneath them, not instead of them.

Fail-open means reviewer outages produce unreviewed actions. That's the documented trade, and the knob exists for whoever won't accept it: tier3_fail_open=False for high-stakes setups, tier 2 with a real human where it matters most.

Data-framing is mitigation, not a guarantee. Delimiters and headers reduce prompt-injection success; they don't eliminate it. The interceptor constrains actions, which is the layer you can actually enforce — constraining interpretation is a harder problem this design doesn't claim to solve.

The interceptor sees names and arguments, not consequences. It can't know that a benign-looking query returns sensitive data, or that ten individually-safe calls compose into something unsafe. Rate limits blunt the composition problem; they don't solve it.

The guarantee has a documented perimeter. No policy configured, no interceptor. Direct action-layer use, no interceptor. A tool-level approval flag with no handler wired, silently no approval. These aren't footnotes; they're the shape of the guarantee: everything the framework dispatches through the agent loop, when a policy is installed. If your threat model includes developers bypassing the loop, you need code review, not a better interceptor.


The actual goal

The goal was never to make the agent provably safe. No three-tier interceptor does that.

The goal was to make the unsafe path the one that requires effort: to run a dangerous tool through the agent you must pass the chokepoint, satisfy or remove the policy, and leave a trace of having done so. Safety lives between planning and execution on every framework-dispatched path, with the perimeter stated plainly. Blocks explain themselves to the loop. Missing configuration fails closed where failure is permanent, and degrades loudly where it's transient.

Non-enforceable work must stay out of the safety story.

Regex lists, LLM judges, data-framing headers — each is partial. What's enforceable is placement (one chokepoint on every loop-dispatched path), protocol (blocks as data the loop must digest), and failure direction (deny by default where config is wrong). Those three are architectural. Everything else is a policy you can tune.

That's the kind of safety I want around agents: a chokepoint with a stated perimeter, errors the loop can read, and trade-offs stated plainly instead of buried.