Home / Articles

Three LangChain Middleware Guards That Keep Agents From Leaking Secrets

Stop an agent from sending API keys to the model or deleting files unasked: PIIMiddleware, HumanInTheLoopMiddleware, call limits and why middleware order decides safety.

Publisher: REACTAPP.TOP10 min read2230 words

This article is published in English.

Sources: **If You’re Not Using These Three Guards in Your AI Agent, It Has Already Sent Your Secrets to the Provider**

Pasting something sensitive into a chat window is easy to do and easy to forget. Maybe a file went into ChatGPT for debugging and you froze after sending, wondering whether an API key, a database password or a client token was in it. You reread the message, close the tab and decide it's probably fine. Probably isn't a policy.

An agent makes this worse, because it does the equivalent automatically, many times per session and without telling you. Give one access to a codebase for review and it will eventually open a .env file containing something like OPENAI_API_KEY=sk-proj-.... With no guards in place, that key goes verbatim to the language model, lands in trace logs and sits in the provider's records. Whether it travels further, into a fine-tuning set or a logging service you can't see, is unknowable from your side.

A few turns later the same agent calls a cleanup tool and removes a directory you never mentioned, logging delete_directory(path="./tmp_backups") quietly and moving on. You discover it when a file you need has vanished.

These are two separate failures with no bug and no dramatic hallucination. The agent simply had no line between what it could do and what it should do. Middleware is the fix, and the same ideas sit behind protections in tools like Cursor and GitHub Copilot. Here is how to build them into your own agent.

What middleware does

Picture the agent as a production line. Data comes in from you, from files and from search results, goes to the model, which makes decisions, which become actions. A middleware is a checkpoint on that line. It intercepts the data, runs a check or a transformation, then passes it on unchanged, alters it, or stops it.

Placement is everything. Too late and the sensitive data has already crossed a boundary. Too early and you're inspecting the wrong thing.

Copilot's secret scanning sits on the output side. Before a suggestion appears in your editor, a pattern matcher examines it, and anything shaped like a key starting with sk-, ghp_ or AKIA is suppressed. Cursor's Review Changes sits ahead of any file write: execution pauses completely, a diff is displayed and the agent can't go on until you approve. If you reject, the write never occurs and the agent looks for another way.

One guards what comes out of the model, the other what the model may do. LangChain lets you build both, plus resource limits.

Catching secrets: PIIMiddleware

Your agent never touches the filesystem directly. It sees only what lands in its context window, which means what you type and what its tools return. Sensitive data can therefore enter in exactly two ways: you paste it, or a tool reads it from disk, as when a directory scan opens a .env file. Both routes pass through the middleware layer before the model sees anything, and that choke point is where PIIMiddleware acts.

There's a flag, apply_to_tool_results, that most tutorials use without explaining. It defaults to False. Enable only apply_to_input=True and you cover the case where you paste something sensitive, while the case where the agent reads a file stays wide open. An agent with file access will happily fetch an entire .env as a tool result, and the model gets it.

A minimal correct setup looks like this:

from langchain.agents.middleware import PIIMiddleware

PIIMiddleware(
    "email",
    strategy="redact",            # replaces match with [REDACTED_EMAIL]
    apply_to_input=True,          # scans what you type
    apply_to_tool_results=True,   # scans what tools return - never skip this
)

API keys are the ones that cause real incidents, and no stock detector knows your key format, so you supply a regex. This one covers the common prefixes:

import re
API_KEY_PATTERN = r"(?:sk-|ghp_|AKIA)[a-zA-Z0-9]{20,48}"
# sk-   → OpenAI and Anthropic keys
# ghp_  → GitHub personal access tokens
# AKIA  → AWS access key IDs
PIIMiddleware(
    "api_key",
    detector=API_KEY_PATTERN,
    strategy="redact",
    apply_to_input=True,
    apply_to_tool_results=True,
)

The middleware offers four strategies:

  • redact swaps the match for [REDACTED_FIELDNAME]. It suits logs, traces and one-way pipelines.
  • mask replaces characters with asterisks but keeps the length, useful when you want to show a value exists without revealing it.
  • block raises an exception and ends the whole run. Use it when tolerance is zero, in high-security settings.
  • hash substitutes a deterministic hash, helpful when you need to follow an entity without storing its value.

Most people assume block is the safest choice, and for tool results it's usually the wrong one. Say the agent reads 30 files during a scan and one has an email address in a comment. With block, that kills the entire session. redact removes the value and lets work continue, which is almost always what you want. Keep block for situations where the mere presence of sensitive data means the agent reached somewhere it shouldn't have.

Test yourself: if apply_to_input=True is set and apply_to_tool_results stays at its default, which specific situation is left unprotected? You should be able to answer in one sentence.

Stopping silent deletions: HumanInTheLoopMiddleware

Back to the deleted directory. HumanInTheLoopMiddleware is Cursor's Review Changes pattern turned into a code primitive. Before any destructive or modifying tool runs, the agent pauses and saves its state. You see exactly what it intends to do and must approve it before anything happens.

Pausing is the easy half. Resuming is the hard half. Coming back to the exact state the agent was in, possibly hours or days later and after a process restart, requires the conversation state, the pending action and the agent's place in its reasoning to be stored somewhere durable. That storage is called a checkpointer. Without one, the interrupt fires, the state vanishes and the next call starts from nothing, so the approval gate just ends the session.

The pause happens after the model has decided what to do but before any tool runs. That's the only workable window, since the decision is visible and nothing irreversible has happened.

from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.sqlite import SqliteSaver

# The checkpointer is not optional. Without it, resume is impossible.
with SqliteSaver.from_conn_string("./review_agent.db") as checkpointer:
    agent = create_agent(
        model="anthropic:claude-sonnet-4-20250514",
        tools=[read_file, list_directory, write_file, search_codebase],
        middleware=[
            HumanInTheLoopMiddleware(
                interrupt_on={
                    "write_file": True,       # always pause before writing
                    "read_file": False,        # reading is safe - no pause needed
                    "list_directory": False,
                    "search_codebase": False,
                }
            ),
        ],
        checkpointer=checkpointer,            # saved to SQLite, persists across restarts
    )

Resuming after approval looks like this:

# First call — agent hits the interrupt at write_file and pauses
result = agent.invoke(
    {"messages": [{"role": "user", "content": "Review and fix the config files"}]},
    config={"configurable": {"thread_id": "session-001"}}
    # thread_id ties the saved state to this specific session
)


# result.interrupted == True
# result.pending_tool_call == {"name": "write_file", "args": {"path": "src/config.py", ...}}
# You show this to the user and wait for approval
# User approves - resume the same thread
final_result = agent.invoke(
    Command(resume=True),
    config={"configurable": {"thread_id": "session-001"}}
    # same thread_id - loads state from the checkpointer and picks up where it stopped
)

One gotcha can eat an afternoon. interrupt_on={"write_file": True} matches the tool name as an exact string. If your tool is really called file_write or update_file, the interrupt never fires and the call slips through silently. Check the trace logs for the real name when the tool is invoked, and match that.

Two limits for two failure modes

Each step has the agent call the model, which picks an action and sometimes invokes tools, and one model call can launch several tool calls at once. Those are separate resources, and neither counts the other.

ModelCallLimitMiddleware tallies model calls, while ToolCallLimitMiddleware tallies tool executions. Ten model calls that each fan out into three parallel tool calls add up to 30 executions, and a ceiling on model calls alone would never notice. It works the other way too. An agent looping on its own reasoning keeps hitting the model without touching a tool, so the tool counter stays at zero and never trips.

from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware

ModelCallLimitMiddleware(
    max_calls=30,
    on_limit="raise",   # raises MaxCallsExceeded - catch this in your application
)
ToolCallLimitMiddleware(
    max_calls=60,
    on_limit="raise",
)

Use both, since each covers something the other can't.

On on_limit="raise" versus on_limit="return": choose "return" for an interactive session where a partial result helps, such as a summary of what was reviewed so far. Choose "raise" when a half-finished output would break something downstream.

Be careful with the numbers, too. A limit of max_calls=10 sounds like a safe ceiling for a quick test, yet an agent with a multi-step plan can spend 8 calls in its first turn alone. The limit then cuts it off mid-task and returns a truncated result, and you can lose twenty minutes before the logs explain it. The answer isn't dropping the limit but setting it to what the task really needs.

Order is the safety guarantee

Middleware runs in the sequence you list it, and that is no minor detail. If any middleware that rewrites or summarises message content comes before PIIMiddleware, the PII check scans text that has already been altered. When the change compressed, paraphrased or restructured the content so your pattern no longer matches, the redaction misses it, the secret is already in the model's context and the redaction ran after the damage.

For the safety stack here, the order is:

middleware=[
    # PIIMiddleware always first — it must see raw, untransformed data
    PIIMiddleware("api_key", detector=API_KEY_PATTERN, strategy="redact",
                  apply_to_input=True, apply_to_tool_results=True),
    PIIMiddleware("email", strategy="redact",
                  apply_to_input=True, apply_to_tool_results=True),

# Limits next - exact position within the group is flexible
    ModelCallLimitMiddleware(max_calls=30, on_limit="raise"),
    ToolCallLimitMiddleware(max_calls=60, on_limit="raise"),
    # Human-in-the-loop last in the safety group
    # (it fires in the after_model hook regardless of list position,
    # but last is a readable convention)
    HumanInTheLoopMiddleware(interrupt_on={"write_file": True}),
]

Think of the list as a pipeline and not as a plugin registry. The fourth item doesn't receive the original message. It receives what items one to three left behind. Move PIIMiddleware below anything that touches message content and the guarantee quietly disappears.

Another check: what fails if PIIMiddleware sits after a middleware that summarises conversation history? If you can answer without scrolling up, the ordering rule has landed.

Assembling a code review agent

Now combine all of it into an agent that behaves like the commercial tools. It has four tools: read_file, list_directory, write_file and search_codebase. Three safety layers wrap them: secrets never reach the model, writes need your approval and resource use has a hard ceiling.

from langchain.agents import create_agent
from langchain.agents.middleware import (
    PIIMiddleware,
    HumanInTheLoopMiddleware,
    ModelCallLimitMiddleware,
    ToolCallLimitMiddleware,
)
from langgraph.checkpoint.sqlite import SqliteSaver

# Custom pattern for secrets common in codebases
API_KEY_PATTERN = r"(?:sk-|ghp_|AKIA)[a-zA-Z0-9]{20,48}"
# sk-   → OpenAI / Anthropic keys
# ghp_  → GitHub personal access tokens
# AKIA  → AWS access key IDs
with SqliteSaver.from_conn_string("./review_agent.db") as checkpointer:
    agent = create_agent(
        model="anthropic:claude-sonnet-4-20250514",
        tools=[read_file, list_directory, write_file, search_codebase],
        middleware=[
            # 1. Catch secrets before they reach the model - on both surfaces
            PIIMiddleware(
                "api_key",
                detector=API_KEY_PATTERN,
                strategy="redact",
                apply_to_input=True,
                apply_to_tool_results=True,  # this is the one that catches .env reads
            ),
            PIIMiddleware(
                "email",
                strategy="redact",
                apply_to_input=True,
                apply_to_tool_results=True,
            ),
            # 2. Hard resource limits - stops infinite loops and runaway sessions
            ModelCallLimitMiddleware(max_calls=30, on_limit="raise"),
            ToolCallLimitMiddleware(max_calls=60, on_limit="raise"),
            # 3. Approval gate - nothing gets written without your explicit sign-off
            HumanInTheLoopMiddleware(
                interrupt_on={
                    "write_file": True,
                    "read_file": False,
                    "list_directory": False,
                    "search_codebase": False,
                }
            ),
        ],
        checkpointer=checkpointer,  # required for interrupt/resume to work
    )

When the agent wants to write a file, the exchange goes like this:

Agent: I'd like to update src/config.py to fix the circular import.
       Here is what I plan to write:
--- src/config.py ---
       from typing import Optional
       from pydantic import BaseModel
       class Settings(BaseModel):
           debug: bool = False
           database_url: str = "sqlite:///app.db"
       ...
       Approve this change? (yes/no)
User: yes
Agent: Written. Moving to tests/test_config.py next.
The agent cannot touch anything silently. Every write surfaces for review before it happens. Secrets in any file the agent reads are replaced with [REDACTED_API_KEY] before reaching the model — which also means they never appear in the approval prompt you read. What you are reviewing is already clean.

Two simple extensions are worth knowing. If the codebase holds user data, say phone numbers in test fixtures, add a PIIMiddleware("phone", strategy="redact") entry ahead of the email one. Every PII detector runs independently on the same input, so order inside that group doesn't matter. And if you'd rather degrade gracefully than stop hard, switch on_limit="raise" to on_limit="return" on both limit middlewares, and the agent will hand back whatever partial result it has.

Now the agent can't leak secrets to the model, can't alter files unless you approve and can't run past a resource ceiling you chose. One limitation remains: it can't stay coherent through a long session. A review of hundreds of files, with dozens of tool calls, will fill the context window before it finishes, so the agent loses track of what it already covered and its suggestions drift. That is a separate problem from the one solved here, which is controlling what the agent touches.