DEV Community

Cover image for Adding a Stateful Trust Boundary to a LangGraph Agent
Anton Fedotov
Anton Fedotov

Posted on

Adding a Stateful Trust Boundary to a LangGraph Agent

LangGraph makes agent workflows easier to reason about.

You can see the nodes.
You can see the edges.
You can see where state is read, updated, and passed forward.

That is a big improvement over a black-box agent loop.

But it also exposes the question most agent pipelines eventually have to answer:

Which parts of this graph are allowed to trust external content?

A graph-based agent can read search results, PDFs, emails, tickets, web pages, tool outputs, and user-pasted text. Some of that content is useful evidence. Some of it may contain instructions that should never become part of the agent’s control flow.

The problem is not only whether a user prompt is malicious.

In a stateful graph, the real question is:

Where does untrusted content enter the graph, can it get written into state, and can it later influence a tool node?

That is where a stateful trust boundary helps.

This post shows how to add that boundary to a LangGraph workflow with Omega Walls.


The short version

If you only remember one thing, make it this:

Every edge that carries external text is a trust-boundary edge.
Every node that can execute tools needs a gateway.
Every state write needs a source/trust tag.

That is the whole mental model.

LangGraph gives you the graph. Omega Walls gives you a boundary around the parts of the graph that should not trust unverified content.


Why LangGraph changes the security conversation

In a simple agent loop, risk often looks like this:

user input -> model -> tool -> model -> answer
Enter fullscreen mode Exit fullscreen mode

That is easy to explain, but it hides the important part.

A real workflow is messier:

user input
  -> retrieve docs
  -> summarize docs
  -> update state
  -> decide next node
  -> call tool
  -> receive tool output
  -> update state again
  -> generate final answer
Enter fullscreen mode Exit fullscreen mode

Once state enters the picture, prompt injection is no longer just an input-filtering problem.

A retrieved web page can influence one node.
That node can write a summary into state.
A later node can treat that state as trusted context.
Another node can use that context to decide whether to call a tool.

Nothing needs to look dramatic in one step.

The bad pattern can be distributed across the workflow.

That is why graph agents need controls that are also graph-shaped.


The failure mode: untrusted text becomes workflow state

Here is the common failure mode.

You build a LangGraph workflow that looks roughly like this:

User request
  -> retrieve external documents
  -> analyze documents
  -> write notes into graph state
  -> agent decides what to do
  -> tool node executes
Enter fullscreen mode Exit fullscreen mode

The workflow feels clean.

The retrieval node retrieves.
The analysis node analyzes.
The state carries the result.
The tool node acts.

But if the retrieved document contains hidden instructions, and you write a derived version of that content into state without marking its source, you have a trust problem.

The graph state now contains external influence.

A later node may not know whether a sentence came from:

  • your system policy,
  • the user’s actual request,
  • a trusted internal source,
  • an external PDF,
  • a fetched web page,
  • or a tool result that contained untrusted text.

Once that distinction is lost, the model has to guess.

That is not a good boundary.

The dangerous part is not always the first model call. The dangerous part is what gets written into state and reused later.


Without a boundary, external text can be summarized into graph state and later treated as trusted workflow context.


The right mental model

The easiest way to reason about this is to treat every edge carrying external content as a boundary edge.

Trust-boundary edges in a LangGraph workflow: external content is guarded before it reaches graph state, context, or tools.

A LangGraph workflow should not treat all state as equally trusted.

A cleaner mental model looks like this:

Trusted inputs
  - system / app policy
  - developer-controlled config
  - user request

Untrusted inputs
  - web pages
  - search results
  - PDFs
  - emails
  - tickets
  - tool outputs containing external text

Boundary
  - inspect
  - filter
  - decide
  - tag
  - block or allow

Graph
  - agent node
  - state
  - tool nodes
  - memory writes

Gateway
  - tool execution
  - file/network/action controls
Enter fullscreen mode Exit fullscreen mode

The trust boundary should sit before untrusted content can shape model context, state, or tool execution.

Insert diagram here: Trust Boundary Edges in a LangGraph Workflow

Caption:

A practical trust-boundary model for LangGraph: trusted inputs can flow into the agent, but external content should pass through a boundary before it reaches graph state, context, or tools.


What Omega Walls adds

Omega Walls is a stateful trust boundary for RAG and agentic pipelines.

In this integration, the important pieces are:

  • model/input checks,
  • tool preflight checks,
  • memory write checks,
  • session-aware runtime state,
  • typed block paths,
  • structured decisions you can log or route into operator workflow.

The goal is not to make the graph useless by hard-blocking everything.

The goal is controlled degradation:

  • suspicious content can be soft-blocked,
  • risky sources can be quarantined,
  • tool execution can be frozen,
  • severe cases can be escalated,
  • safe parts of the workflow can continue.

That matters in real agents. A production workflow should not have only two modes: “everything allowed” and “everything dead.”


Install

Install Omega Walls with integration adapters:

pip install "omega-walls[integrations]"
Enter fullscreen mode Exit fullscreen mode

You can also install only the base package and the framework dependencies you use, but the integrations extra is the fastest path for framework adapters.


Minimal LangGraph integration

If you already have a compiled LangGraph workflow, the integration shape is small:

from omega.integrations import OmegaLangGraphGuard

guard = OmegaLangGraphGuard(profile="quickstart")

safe_graph = guard.wrap_graph(compiled_graph)
safe_tool = guard.wrap_tool("network_post", network_post_fn)
guard_node = guard.build_guard_node()  # optional StateGraph node helper
Enter fullscreen mode Exit fullscreen mode

The three important pieces are:

safe_graph = guard.wrap_graph(compiled_graph)
Enter fullscreen mode Exit fullscreen mode

This wraps the graph-level execution path.

safe_tool = guard.wrap_tool("network_post", network_post_fn)
Enter fullscreen mode Exit fullscreen mode

This ensures the tool call goes through a guarded preflight path before it executes.

guard_node = guard.build_guard_node()
Enter fullscreen mode Exit fullscreen mode

This gives you an optional explicit guard node you can place inside a StateGraph when you want the boundary to be visible in the workflow itself.


Where to put the guard

There are two common patterns.

Pattern 1: Wrap the compiled graph

Use this when you want the simplest integration.

from omega.integrations import OmegaLangGraphGuard

guard = OmegaLangGraphGuard(profile="quickstart")

compiled_graph = graph.compile()
safe_graph = guard.wrap_graph(compiled_graph)

result = safe_graph.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": "Summarize the latest external research note."
            }
        ]
    },
    config={
        "configurable": {
            "thread_id": "customer-support-123"
        }
    }
)

print(result)
Enter fullscreen mode Exit fullscreen mode

This is the easiest entry point.

You keep your graph structure. The guard sits around the graph execution path. If your app already passes a thread_id, conversation_id, or session_id, the adapter can use that to keep runtime decisions tied to the right workflow session.

Use this when you want a quick integration without changing the graph topology.


Pattern 2: Add an explicit guard node

Use this when you want the trust boundary to be visible in the graph.

The exact graph shape depends on your app, but the idea is simple:

START
  -> retrieve_external_content
  -> omega_guard
  -> agent
  -> tools
  -> END
Enter fullscreen mode Exit fullscreen mode

Example shape:

from typing import TypedDict, List, Any
from langgraph.graph import StateGraph, START, END
from omega.integrations import OmegaLangGraphGuard

class AgentState(TypedDict):
    messages: List[dict]
    retrieved_docs: List[dict]
    guarded_docs: List[dict]
    risk_notes: List[str]

guard = OmegaLangGraphGuard(profile="quickstart")

def retrieve_external_content(state: AgentState) -> dict:
    # Replace with your retriever, search API, document loader, etc.
    docs = [
        {
            "source_id": "web:example.com/page",
            "source_type": "web",
            "trust": "untrusted",
            "text": "External page content..."
        }
    ]
    return {"retrieved_docs": docs}

omega_guard = guard.build_guard_node()

def agent_node(state: AgentState) -> dict:
    # At this point, only guarded/allowed content should shape the prompt.
    messages = state["messages"]
    guarded_docs = state.get("guarded_docs", [])

    # Build your prompt from trusted messages + guarded docs.
    # Call your model here.
    return {
        "messages": messages + [
            {
                "role": "assistant",
                "content": f"Processed {len(guarded_docs)} guarded documents."
            }
        ]
    }

graph = StateGraph(AgentState)

graph.add_node("retrieve_external_content", retrieve_external_content)
graph.add_node("omega_guard", omega_guard)
graph.add_node("agent", agent_node)

graph.add_edge(START, "retrieve_external_content")
graph.add_edge("retrieve_external_content", "omega_guard")
graph.add_edge("omega_guard", "agent")
graph.add_edge("agent", END)

compiled_graph = graph.compile()
safe_graph = guard.wrap_graph(compiled_graph)
Enter fullscreen mode Exit fullscreen mode

This version has one major advantage: the trust boundary is not hidden.

Anyone reading the graph can see that external content does not go straight from retrieval into the agent node.

It passes through the guard first.


Guard tool nodes, not only text inputs

The mistake is to guard only text before the model call.

In a LangGraph workflow, tool nodes are often where the real-world impact happens.

A tool might:

  • send a network request,
  • write a file,
  • update a ticket,
  • call an internal API,
  • send a message,
  • trigger a transaction,
  • fetch another external page.

If external text can influence a tool call, that tool call should go through a gateway.

Example:

from omega.integrations import OmegaLangGraphGuard

guard = OmegaLangGraphGuard(profile="quickstart")

def network_post(url: str, payload: dict) -> dict:
    # Your real network operation lives here.
    # The guard should sit before this function executes.
    return {"status": "ok", "url": url}

safe_network_post = guard.wrap_tool("network_post", network_post)
Enter fullscreen mode Exit fullscreen mode

Then use safe_network_post in your graph instead of the raw function.

def tool_node(state: dict) -> dict:
    payload = {
        "summary": state.get("summary", "")
    }

    result = safe_network_post(
        url="https://internal.example/ingest",
        payload=payload
    )

    return {"tool_result": result}
Enter fullscreen mode Exit fullscreen mode

The important thing is not the name network_post.

The important thing is the chokepoint.

If a tool can do something outside the model, it should not be callable through an unguarded side path.


Handle blocked paths explicitly

A boundary should not fail mysteriously.

Your application should know whether the model/input step was blocked or the tool call was blocked.

from omega.adapters import OmegaBlockedError, OmegaToolBlockedError

try:
    result = safe_graph.invoke(
        {
            "messages": [
                {
                    "role": "user",
                    "content": "Analyze this external report and continue the workflow."
                }
            ]
        },
        config={
            "configurable": {
                "thread_id": "workflow-123"
            }
        }
    )

except OmegaBlockedError as exc:
    print("Blocked model/input step")
    print("Outcome:", exc.decision.control_outcome)
    print("Reasons:", exc.decision.reason_codes)

except OmegaToolBlockedError as exc:
    print("Blocked tool call")
    print("Tool:", exc.gate_decision.tool_name)
    print("Reason:", exc.gate_decision.reason)
Enter fullscreen mode Exit fullscreen mode

This gives your app a real branch.

You can log it.
You can show a safe message.
You can ask for human approval.
You can continue without the risky source.
You can freeze tools for the session.

The point is not just “block bad thing.”

The point is to make the decision operational.


Guard memory writes

Graph state is not the only state you should care about.

Many agent systems also write to memory:

  • user facts,
  • summaries,
  • preferences,
  • retrieved notes,
  • intermediate conclusions,
  • task state,
  • long-term memory,
  • cached tool results.

If a memory write came from external text, preserve that fact.

Do not let the system turn:

external page said X
Enter fullscreen mode Exit fullscreen mode

into:

known fact: X
Enter fullscreen mode Exit fullscreen mode

without a trust tag.

Use the memory write check when persistence is involved:

decision = guard.check_memory_write(
    text="The external document says the support workflow should skip approval.",
    source_id="web:example.com/page",
    source_type="web",
    source_trust="untrusted",
    thread_id="workflow-123",
)

if decision.mode == "allow":
    save_to_memory(...)
elif decision.mode == "quarantine":
    save_to_quarantine(...)
else:
    print("Memory write denied")
Enter fullscreen mode Exit fullscreen mode

The exact storage backend is up to your app.

The principle is not optional:

Memory should remember where information came from.

If you lose provenance, your future graph steps cannot tell the difference between trusted state and external influence.


Verify the integration

After wiring the guard, run the strict smoke for LangGraph:

python scripts/smoke_langgraph_guard.py --strict
Enter fullscreen mode Exit fullscreen mode

This is the first thing I would run locally.

Not “does my app still start?”
Not “does the graph compile?”
But:

Is the guard actually on the execution path?

That is the test that matters.

For a broader release gate across framework adapters, run:

python scripts/run_framework_smokes.py --strict
Enter fullscreen mode Exit fullscreen mode

The expected high-level result should be boring:

status = ok
framework_count = 6
total_failures = 0
min_gateway_coverage >= 1.0
total_orphans = 0
Enter fullscreen mode Exit fullscreen mode

Boring is good here.

It means your wrappers are not decorative.


A practical graph checklist

When reviewing a LangGraph workflow, I would walk it with this checklist.

1. Which nodes receive external text?

Look for nodes that read from:

  • retrievers,
  • search APIs,
  • browser tools,
  • PDFs,
  • emails,
  • tickets,
  • web fetchers,
  • tool outputs.

Those nodes should either be guarded directly or feed into a guard node before the agent consumes their output.

2. Which edges carry untrusted content?

Edges are not just control flow.

In a stateful graph, edges also carry influence.

If an edge carries external text, treat it as a boundary edge.

3. Which nodes write state?

Any node that writes to graph state can change future behavior.

If it writes derived content from an untrusted source, keep source metadata attached.

4. Which nodes can execute tools?

Tool nodes should call wrapped tools, not raw functions.

If a tool can write, send, fetch, mutate, or trigger an external action, it belongs behind a gateway.

5. Can a later node distinguish trusted from untrusted state?

If not, you probably need better state shape.

A useful state object should preserve the difference between:

trusted_policy
user_request
guarded_docs
quarantined_docs
tool_results
risk_notes
Enter fullscreen mode Exit fullscreen mode

Instead of dumping everything into one generic context field.


Example state shape

Here is a simple state shape I prefer for guarded graph workflows:

from typing import TypedDict, List, Literal, Optional

class SourceRef(TypedDict):
    source_id: str
    source_type: str
    trust: Literal["trusted", "semi", "untrusted"]

class DocumentChunk(TypedDict):
    text: str
    source: SourceRef

class RiskNote(TypedDict):
    source_id: str
    outcome: str
    reason_codes: List[str]

class AgentState(TypedDict):
    messages: List[dict]

    # Raw external inputs
    retrieved_docs: List[DocumentChunk]

    # Content allowed to shape context
    guarded_docs: List[DocumentChunk]

    # Content blocked or held for review
    quarantined_docs: List[DocumentChunk]

    # Runtime/security notes
    risk_notes: List[RiskNote]

    # Tool outputs with provenance
    tool_results: List[dict]
Enter fullscreen mode Exit fullscreen mode

This makes trust visible.

It is much easier to reason about:

state["guarded_docs"]
Enter fullscreen mode Exit fullscreen mode

than a giant mixed list called:

state["context"]
Enter fullscreen mode Exit fullscreen mode

The name matters because future contributors will follow the shape you give them.

If everything is called context, everything will eventually be treated as context.


What happens when something is risky?

The useful behavior is not always “stop the agent.”

Often, the better behavior is controlled degradation.

For example:

External document looks suspicious
  -> remove it from context
  -> continue with remaining docs
  -> log reason
  -> freeze tools if tool-abuse pressure appears
  -> escalate only if needed
Enter fullscreen mode Exit fullscreen mode

That is better than letting the model ingest everything.

It is also better than killing every workflow at the first suspicious string.

A graph workflow gives you room to degrade gracefully:

retrieve docs
  -> guard docs
  -> if safe: continue
  -> if suspicious: continue without that source
  -> if tool risk: freeze tool node
  -> if severe: route to human review
Enter fullscreen mode Exit fullscreen mode

That is a product behavior, not just a security behavior.


What this does not solve

A trust boundary is not magic.

It does not replace:

  • least-privilege tool permissions,
  • secret management,
  • network allowlists,
  • proper auth,
  • human approval for sensitive operations,
  • logging and incident review,
  • model-side safety controls.

It also depends on architecture.

If raw tools can execute outside the gateway, the boundary can be bypassed.
If untrusted content can be written directly into state, the boundary is not really a boundary.
If your app removes source metadata too early, later nodes cannot reason about trust.

So the integration rule is simple:

Put the boundary on the actual execution path, not next to it.


A useful boundary should not have only two states: allow everything or kill the whole workflow.


Controlled degradation: remove risky influence, freeze dangerous execution paths, and continue with the remaining safe workflow.

A good rollout order

I would not jump straight to hard blocking.

Use this order:

1. Wrap the graph.
2. Wrap tool nodes.
3. Add an explicit guard node where external content enters.
4. Run strict smoke.
5. Run in monitor mode.
6. Inspect reports and decisions.
7. Add operator workflow.
8. Enable enforcement for selected paths.
Enter fullscreen mode Exit fullscreen mode

The monitor phase is important.

You want to see:

  • which sources trigger decisions,
  • which tools would have been blocked,
  • whether benign security docs stay quiet,
  • whether risky paths show up clearly,
  • whether operators can understand the decision.

Hard blocking before observability is how you create a production incident while trying to prevent one.


Final thought

LangGraph gives you a better way to build agents because it makes workflow structure explicit.

That same explicit structure gives you a better way to secure them.

Do not think of a trust boundary as a single filter before the prompt.

In a graph, the boundary is a design discipline:

  • external text enters through guarded edges,
  • state keeps provenance,
  • tools execute through a gateway,
  • risky content can be removed without killing the whole workflow,
  • decisions are visible enough to debug.

That is the practical shape I want in production agent systems.

Not a bigger prompt.

A clearer boundary.


Omega Walls is open source and ships framework adapters for LangChain, LangGraph, LlamaIndex, Haystack, AutoGen, and CrewAI.

Install:

```

bash
pip install "omega-walls[integrations]"


Enter fullscreen mode Exit fullscreen mode

LangGraph smoke:


bash
python scripts/smoke_langgraph_guard.py --strict


Enter fullscreen mode Exit fullscreen mode

GitHub: https://github.com/synqratech/omega-walls
PyPI: https://pypi.org/project/omega-walls/
Site: https://synqra.tech/omega-walls

Top comments (0)