LangChain
humanbound-firewall ships a LangChain adapter in 0.3.0 and above: an AgentMiddleware that attaches the firewall to every boundary a LangChain agent exposes, calls the firewall's gateway on each, enforces the decision, and carries the session in the graph state. Nothing is configured per tool: the policy file and from_config() decide.
Requirements
The adapter is available in humanbound-firewall 0.3.0 and above (pip install "humanbound-firewall>=0.3.0"), with LangChain 1.x (langchain.agents.create_agent with middleware). LangChain is your application's dependency; the firewall has no [langchain] extra.
Two lines
from langchain.agents import create_agent
from humanbound_firewall import Firewall
firewall = Firewall.from_config("agent.yaml")
agent = create_agent(model, tools, middleware=[firewall.adapt_to("langchain")])
adapt_to() imports the adapter lazily and returns a fresh middleware bound to this firewall. An unknown framework name lists the known adapters and the manual fallback.
What it attaches
| Boundary | Hook | Trust class | On a block |
|---|---|---|---|
| The human turn entering the model | before_model, when the last message is a HumanMessage with text content — once per run |
request |
the run ends; the replacement text is the agent's reply |
Every tool's return value that is a text ToolMessage |
wrap_tool_call |
ingest, or recall when the policy lists the tool under tools.recall |
the ToolMessage content is replaced by the withheld notice; its tool_call_id is kept, so the agent loop stays valid |
| The start of the run | before_agent |
— | the session is read from the state (or created), the run counter advances, the user's request is pinned |
| The end of the run | after_agent |
— | the session, including verdicts judged in the background, is written back to the state |
A tool that returns a Command, or a message whose content is a list of blocks rather than text, is not screened: guard what it reads with inspect() inside the tool.
A sub-agent exposed as a tool is a tool result and is judged as ingest under this agent's policy. Every hook has an async variant; the firewall call, which is blocking I/O, runs off the event loop.
The screening call gives the firewall the tool result, a window of recent messages (the system prompt is never included), the session, and the boundary: the tool's name and kind, its own description, the policy's expects, the arguments the model called it with, and the tool_call_id. The ingest judge uses those facts generically — see Tier 3.
The session in the graph state
The session travels under humanbound_session in the agent state:
result = agent.invoke(
{"messages": [HumanMessage("Check the competitor price")]},
config={"configurable": {"thread_id": "t-42"}},
)
result["humanbound_session"]
# {"v": 1, "run": 3, "pinned_request": "Check the competitor price",
# "counts": {"evaluations": 7, "blocks": 1, "escalations": 0,
# "by_class": {"ingest": 5, "recall": 2}, "by_category": {"restriction": 1}},
# "recent": [...], "posture": "elevated", "last_seen": "2026-09-23T10:41:03Z"}
With a checkpointer the value persists across the runs of a thread, so a block in one run elevates the posture for the next. Parallel tool calls in one graph step each record on their own branch; the state key has a reducer that merges them, so nothing is lost and the posture can only rise. The session holds counts, payload hashes, boundaries, the posture and the run's pinned request (the user's turn); no tool output and no record content.
The trust-boundary inventory
mw = firewall.adapt_to("langchain", tools=tools) # tools: only for the inventory
for b in mw.boundaries:
print(b)
print(mw.report())
Humanbound Firewall · PriceWatch · adapter=langchain · classes=request,ingest,recall
boundary kind hook class mode note
human turn request before_model request block
fetch_url tools wrap_tool_call ingest block expects: a competitor's public web page
query_catalogue tools wrap_tool_call recall block integrity mode (declared: recall); expects: one catalogue row we wrote
check_our_stock tools wrap_tool_call recall block integrity mode (declared: recall); expects: a stock count from our warehouse system
get_competitor_listing tools wrap_tool_call ingest block expects: Fetch a competitor's product listing page
The header carries the policy's name:, the classes come from from_config() (all three by default), and expects falls back to each tool's own description — get_competitor_listing above declares none. This is the whitebox list a blackbox test cannot produce, and the list a threat model is checked against. Given the agent's tool list (tools=), the adapter warns about a policy entry naming a tool the agent does not have and leaves it out of the inventory; without tools=, the policy's entries are the inventory. Capabilities this adapter cannot attach (memory, inter_agent) are listed with no hook when the policy declares them (see below).
The policy, for LangChain tools
name: PriceWatch
capabilities: [tools]
tools:
recall: [query_catalogue, check_our_stock] # returns records WE authored → integrity check
class: {fraud_review_agent: ingest} # a sub-agent as a tool: outside content
expects: # defaults to the tool's own description
fetch_url: "a competitor's public web page"
query_catalogue: "one catalogue row we wrote"
check_our_stock: "a stock count from our warehouse system"
Without a tools: block every tool is ingest, the strict default. The block can only relax it; a tool can never be declared a request.
A staged rollout
The deployment's choices live in code, not in the policy file:
def audit(d): # observe-only: cannot change a verdict
log.info(
"firewall",
cls=d.cls,
boundary=d.boundary["name"],
verdict=d.verdict.value,
category=d.category.value,
action=d.action,
ms=d.elapsed_ms,
posture=d.session.posture,
)
firewall = Firewall.from_config(
"agent.yaml",
provider=provider,
classes=("ingest", "recall"), # the human turn is our own UI: not a boundary here
mode="block",
mode_by_class={"ingest": "log"}, # observe pages first; enforce records
fail="closed", # high stakes: an uncertain verdict withholds
on_decision=audit,
withheld_template="[Content withheld by policy: {category}. Continue without it.]",
)
mw = firewall.adapt_to("langchain") # keep the handle: flush() and the inventory live on it
agent = create_agent(model, tools, middleware=[mw])
log keeps the verdict but always passes; passthrough and disabled classes are not evaluated at all. on_decision fires with every Decision, including passthroughs and engine failures; it is the place to feed a dashboard or the platform's monitoring.
Log mode judges in the background
A logged verdict changes nothing the agent does, so in log mode the adapter does not wait for it. The tool result — or the human turn — goes to the model at once, and the same payload is judged on a background thread with the window, the boundary and the session captured at that moment. block mode is unchanged: the verdict comes first.
What that means in practice:
- Order is kept. Each LangGraph thread has one lane; its judgements run one at a time in call order, each on the session the previous one produced, so the verdicts and the session are the same a blocking run would give. Different threads are judged in parallel.
on_decisionfires when the verdict lands, possibly afterinvoke()has returned.d.boundary["tool_call_id"]ties a late verdict to the step that produced it.- The session is written back into
humanbound_sessionat the next model turn and inafter_agent. A verdict still pending when the run ends is folded in by the next run of that thread. mw.flush(timeout)waits for every pending judgement and returnsFalseon timeout. Call it before reading the session in a test, and before a request handler returns if the process may exit.adapt_to("langchain", log_blocking=True)restores waiting: the verdict is made before the result reaches the model and still always passes.
from langchain_core.messages import HumanMessage
from humanbound_firewall import Session
result = agent.invoke({"messages": [HumanMessage("Check the competitor price")]}, config)
mw.flush(timeout=30) # log-mode verdicts may still be in flight
session = Session.from_json(result["humanbound_session"])
Seeing the verdicts in a streamed run
With astream_events, a tool's on_tool_end event carries the tool's raw output. In block mode the middleware judges after the tool returns and the replacement lands in the state before the next model turn; in log mode nothing is replaced and the verdict arrives later. Subscribe to on_decision to see what the firewall decided; do not infer it from tool events.
Not attachable
LangChain exposes no generic hook for these; the manual tier serves them, and the inventory lists the first and third when the policy declares memory or inter_agent in capabilities::
- memory or store reads inside a node, and summarisation middleware;
- documents your application retrieves and stuffs into the prompt itself;
Command-style hand-offs between agents;- MCP or tool definitions injected at registration.
Call firewall.inspect(content, cls=..., session=..., boundary=...) where that content is read, and carry the session yourself.
Testing an integration with a fake engine
class FakeEngine: # Tier 2 stand-in: no network
supports_class = True
def classify(self, conversation, cls="request"):
hot = "send your unit cost" in conversation[-1]["u"]
return {
"decision": "BLOCK" if hot else "ALLOW",
"category": "restriction",
"attack_probability": 0.95 if hot else 0.05,
}
firewall = Firewall(config, scope_classifier=FakeEngine())
agent = create_agent(scripted_model, [fetch_url], middleware=[firewall.adapt_to("langchain")])
result = agent.invoke({"messages": [HumanMessage("Check the price")]})
assert POISON not in " ".join(str(m.content) for m in result["messages"])
assert result["humanbound_session"]["counts"]["blocks"] == 1
The library's own conformance test drives the same scripted thread through the manual tier and the adapter against one fake engine and asserts identical decisions and an identical session.
A worked example
PriceWatch is an intentionally vulnerable LangChain pricing agent with a fake competitor storefront: a hidden line on a page makes the agent leak its unit cost through a multi-hop injection. The repo's agent/agent_core.py shows the two-line integration, its agent.yaml the tools: block, and its walkthrough UI shows every firewall decision as the run streams.