Skip to content
Blog

Domain Guardrails: Keeping Vertical Agents Inside Their Lane

Scope vertical agents to their domain with Microsoft Agent Framework middleware, typed tool policies, and deterministic label-based enforcement.

Published on • October 18, 2026

AI Assistant

A healthcare triage agent that starts answering legal questions, or a support agent that improvises SQL against your production database, is a vertical agent that left its lane. The failure is rarely the model refusing to help — it is the framework giving the model a path to act outside its intended domain. Microsoft Agent Framework gives you two enforcement layers for this: middleware that intercepts agent runs, tool calls, and chat requests, and FIDES — the information-flow control system that deterministically blocks untrusted content from driving privileged tools.

What “Inside Their Lane” Actually Means

Decompose the lane into three enforceable properties:

  • Scope — the agent only answers questions about its domain (claims status, not tax advice).
  • Capability — the agent only calls tools registered for that domain (no arbitrary code execution, no unbounded SQL).
  • Information flow — data fetched from untrusted sources cannot flow into privileged sinks (an issue body cannot steer a tool that posts public comments).

Scope and capability are heuristic checks; information flow can be made deterministic. Treating them separately is the design decision that keeps your guardrails debuggable.

Layer 1: Agent Middleware for Scope

Agent Framework’s Python SDK defines three middleware types: agent middleware that intercepts run execution, function middleware that intercepts tool calls, and chat middleware that intercepts the raw model requests. All three use the same shape — a context object plus a call_next callback — and multiple instances form a chain, with agent-level middleware wrapping run-level middleware.

A scope guard on the input side is a few lines:

from agent_framework import AgentContext, AgentResponse, Message, MiddlewareTermination

DOMAIN_TERMS = {"claims", "policy", "coverage", "deductible", "premium"}
OUT_OF_DOMAIN = ("tax", "litigation", "immigration", "investment")

async def domain_scope_middleware(
    context: AgentContext,
    call_next,
) -> None:
    last = context.messages[-1] if context.messages else None
    query = (last.text or "").lower() if last else ""

    if any(term in query for term in OUT_OF_DOMAIN):
        context.result = AgentResponse(
            messages=[Message("assistant", [
                "I only handle insurance claims and coverage questions. "
                "Please route tax or legal questions to the appropriate specialist."
            ])]
        )
        raise MiddlewareTermination(result=context.result)

    await call_next()

The docs are explicit about termination semantics: set context.result, raise MiddlewareTermination, and the rest of the chain plus the normal execution path are skipped. The same pattern works for function and chat middleware, which is what makes it usable as a fail-closed control boundary rather than a suggestion to the model.

Layer 2: Function Middleware for Capability

Scope checks stop off-domain questions. Function middleware stops off-domain actions by validating arguments before the tool executes. The FunctionInvocationContext carries the function being invoked and its validated arguments:

from agent_framework import FunctionInvocationContext, FunctionMiddleware

ALLOWED_TABLES = {"claims", "policies", "customers"}

class SqlTableGuard(FunctionMiddleware):
    async def process(self, context: FunctionInvocationContext, call_next) -> None:
        if context.function.name == "query_claims_db":
            table = context.arguments.get("table")
            if table not in ALLOWED_TABLES:
                context.result = {"error": f"Table '{table}' is outside the claims domain."}
                return  # do not call the tool
        await call_next()

Register it agent-level so every run inherits it:

agent = Agent(
    client=chat_client,
    name="claims_assistant",
    instructions="You answer questions about insurance claims and coverage only.",
    tools=[query_claims_db, lookup_policy, get_claim_status],
    middleware=[domain_scope_middleware, SqlTableGuard()],
)

For the .NET side of the framework, the equivalent is function calling middleware registered through the agent builder (originalAgent.AsBuilder().Use(runFunc: ..., runStreamingFunc: ...).Use(CustomFunctionCallingMiddleware).Build()), with the same inspection-and-modify contract.

Layer 3: Deterministic Enforcement with FIDES

Heuristic middleware fails silently the day someone slips a [SYSTEM OVERRIDE] line into an issue body or an email. Agent Framework’s security layer — FIDES, an information-flow control system built on the FIDES paper — addresses this by labeling content instead of trusting the model to notice attacks. Every Content item carries an integrity label (trusted or untrusted) and a confidentiality label (public, private, or user_identity). Labels propagate automatically through tool calls using a most-restrictive-wins rule, and policies are checked before a sensitive tool runs — not after.

Your vertical agent declares its sinks’ tolerances on the tools themselves:

from agent_framework import tool

@tool(additional_properties={"max_allowed_confidentiality": "public"})
async def post_public_notice(body: str) -> dict:
    """Publishes to the public portal. Refuses private context."""
    ...

@tool(additional_properties={"accepts_untrusted": False})
async def update_claim_record(claim_id: str, status: str) -> dict:
    """Mutates claim state. Refuses to run under untrusted context."""
    ...

Wiring it up is one opt-in via SecureAgentConfig, which acts as a context provider and injects the middleware, security tools, and instructions automatically:

from agent_framework.security import SecureAgentConfig

config = SecureAgentConfig(
    enable_policy_enforcement=True,
    approval_on_violation=True,       # surface violations as human approvals
    allow_untrusted_tools={"read_email", "read_issue"},
)
agent = Agent(..., context_providers=[config])

The guarantees are concrete. If the agent reads an email labeled untrusted and is fooled into calling update_claim_record, the call is refused because the sink declares accepts_untrusted=False. If it reads a private file and then tries post_public_notice, the max_allowed_confidentiality="public" cap blocks the exfiltration — the same fence handles prompt injection (wrong integrity) and data leakage (wrong confidentiality) without the model “noticing” anything. The framework’s own running example is a GitHub triage agent where an attacker hides read_file(".env") plus post_comment(...) instructions in a bug report; FIDES refuses the comment because the context is tainted private.

Two operational notes from the docs that matter in production:

  • Unlabeled tool output defaults to UNTRUSTED + PUBLIC. A tool you forgot to annotate fails closed rather than open. Keep data-fetchers in allow_untrusted_tools — they introduce untrusted content and must be callable in any context.
  • Security state is scoped to an AgentSession. Labels, variables, and pending approvals live in the session; reuse the same session to continue a run’s security state, and use separate sessions to isolate tenants. Pass the session to config.get_audit_log(session) for the compliance trail.

Managed Guardrails as the Outer Ring

When you deploy through Azure AI Foundry, Agent Framework adds managed responsible-AI features on top: task-adherence guardrails that keep the agent on its assigned task, PII protection that flags sensitive data access, and prompt injection defenses. These are provider-side controls and complement — not replace — your middleware. Middleware you own and test; Foundry guardrails cover the long tail.

Testing the Lane

Guardrails without tests are decoration. The framework’s middleware design makes each layer unit-testable in isolation:

async def test_out_of_domain_is_terminated():
    ctx = make_agent_context("What are the capital gains rules for my LLC?")
    await domain_scope_middleware(ctx, call_next=lambda: pytest.fail("agent should not run"))
    assert "only handle insurance claims" in ctx.result.text

Run the same fixture suite against every middleware in the chain, plus a dry-run mode: SecureAgentConfig(enable_policy_enforcement=False) propagates labels without blocking anything, so you can watch the audit log and see what would be blocked before flipping enforcement on.

Conclusion

Keeping a vertical agent in its lane is a layering problem. Agent middleware rejects off-domain requests before the model runs; function middleware validates tool arguments before side effects happen; and FIDES makes the information-flow guarantee deterministic, so an untrusted document can summarize itself but never drive a privileged sink. Microsoft Agent Framework exposes all three at the same interception points, which means you build the lane once and defend it at every depth — instead of hoping the system prompt holds.