Skip to content
$ clawproof playbook --run langchain-agent-pipelines
LangChain2026-03-0810 min read

Securing LangChain Agent Pipelines

LangChain's flexibility is its strength and its risk. This playbook shows how to add permission wrappers, structured logging, and evaluation gates to your agent pipelines.

By Werner Plutat

On this page

LangChain's Trust Model

LangChain provides the plumbing for LLM-powered applications but deliberately leaves security to the developer. Its agent framework, whether using the legacy AgentExecutor or the newer LangGraph, trusts that every tool in the agent's toolkit is safe to call with any arguments. There is no built-in permission system, no argument validation layer, and no cost tracking.

This design means that any tool you bind to an agent can be called at any time during execution. If you give an agent both a `read_file` and a `write_file` tool, the LLM decides when and how to use them. A prompt injection embedded in retrieved documents can redirect the agent to overwrite files, exfiltrate data through tool calls, or enter infinite loops that burn through your API budget.

LangChain's callback system provides observability hooks but does not enforce policies. Callbacks can log and monitor, but they cannot block or modify tool execution by default. Security must be implemented at the tool level, the chain level, or in custom middleware wrapping the agent executor.

The shift from AgentExecutor to LangGraph introduces graph-based control flow, which gives you more natural places to insert security checks (as graph nodes), but the responsibility to build those checks remains entirely yours.

Tool Permission Wrappers

The most effective pattern for securing LangChain tools is to wrap them with a permission layer that checks the calling context before executing. This wrapper intercepts every tool invocation, validates the caller's permissions, and applies argument sanitization before delegating to the real tool.

The wrapper below uses LangChain's `BaseTool` subclassing pattern to create a permission-enforcing proxy. It checks a user context object (injected via RunnableConfig) against a permission registry and rejects unauthorized calls before the underlying tool ever executes.

tool_permission_wrapper.py
from langchain_core.tools import BaseTool, ToolException
from langchain_core.runnables import RunnableConfig
from typing import Any, Optional, Dict, Set
from pydantic import Field
import logging

logger = logging.getLogger(__name__)

# Map roles to allowed tool names
PERMISSION_REGISTRY: Dict[str, Set[str]] = {
    "viewer": {"search_docs", "get_status"},
    "editor": {"search_docs", "get_status", "update_record", "create_record"},
    "admin":  {"search_docs", "get_status", "update_record", "create_record", "delete_record", "manage_users"},
}

class PermissionWrappedTool(BaseTool):
    """Wraps any LangChain tool with role-based permission checks."""

    name: str = ""
    description: str = ""
    inner_tool: BaseTool = Field(exclude=True)
    max_input_length: int = 5000

    def __init__(self, tool: BaseTool, **kwargs):
        super().__init__(
            inner_tool=tool,
            name=tool.name,
            description=tool.description,
            **kwargs,
        )

    def _run(self, *args, config: Optional[RunnableConfig] = None, **kwargs) -> Any:
        user_role = (config or {}).get("configurable", {}).get("user_role", "viewer")
        user_id = (config or {}).get("configurable", {}).get("user_id", "anonymous")

        # Check permission
        allowed_tools = PERMISSION_REGISTRY.get(user_role, set())
        if self.name not in allowed_tools:
            logger.warning(f"BLOCKED: user={user_id} role={user_role} tool={self.name}")
            raise ToolException(f"Permission denied: role '{user_role}' cannot use '{self.name}'")

        # Validate input length to prevent payload attacks
        raw_input = str(args) + str(kwargs)
        if len(raw_input) > self.max_input_length:
            raise ToolException(f"Input exceeds maximum length of {self.max_input_length}")

        logger.info(f"ALLOWED: user={user_id} role={user_role} tool={self.name}")
        return self.inner_tool._run(*args, **kwargs)

def wrap_tools_with_permissions(tools: list[BaseTool]) -> list[PermissionWrappedTool]:
    """Wrap all tools in a toolkit with permission enforcement."""
    return [PermissionWrappedTool(tool=t) for t in tools]

Structured Logging with Callbacks

LangChain's callback system is the foundation for observability in agent pipelines. By implementing custom callback handlers, you can capture every LLM call, tool invocation, chain execution, and error in a structured format suitable for audit trails and security monitoring.

Create a custom `BaseCallbackHandler` subclass that emits structured JSON logs for each event. Key events to capture include: `on_tool_start` (tool name, input arguments, timestamp), `on_tool_end` (output, duration), `on_tool_error` (exception details), `on_llm_start` (model name, prompt token estimate), and `on_chain_start`/`on_chain_end` for tracking overall pipeline execution.

For audit compliance, your callback handler should generate a unique trace ID per agent invocation and attach it to every log entry. This lets you reconstruct the full execution path of any agent run (which tools were called, in what order, with what arguments, and what the LLM decided at each step). Use LangChain's built-in `run_id` from the callback kwargs for this purpose.

Integrate your callback handler with LangSmith or a self-hosted tracing backend for production use. LangSmith provides built-in trace visualization, but for security-sensitive environments where traces may contain PII or proprietary data, self-host using OpenTelemetry-compatible collectors. Forward the structured logs to your SIEM for correlation with other application security events.

A critical pattern is to log the full chain-of-thought reasoning alongside tool calls. When an agent misbehaves, you need to see not just what tool was called but why the LLM chose to call it. Capture the `on_llm_end` output (the model's reasoning) immediately before each `on_tool_start` event to create a causal trace that explains the agent's decision-making process.

Chain-Level Guardrails

LangGraph's node-based architecture lets you insert guardrail nodes directly into the agent's execution graph. These nodes act as security checkpoints that inspect the agent's state and can halt execution, modify the state, or redirect the flow before a tool is called.

The pattern below shows a guardrail node that inspects the agent's planned action, checks it against security policies, and either allows execution to continue or forces the agent into a safe termination state.

langgraph_guardrails.py
from langgraph.graph import StateGraph, END
from langchain_core.messages import AIMessage, ToolMessage
from typing import TypedDict, Annotated, Sequence
import operator
import re

class AgentState(TypedDict):
    messages: Annotated[Sequence, operator.add]
    blocked_reason: str | None

DANGEROUS_PATTERNS = [
    re.compile(r"rm\s+-rf", re.IGNORECASE),
    re.compile(r"DROP\s+TABLE", re.IGNORECASE),
    re.compile(r"subprocess\.(?:run|call|Popen)", re.IGNORECASE),
    re.compile(r"os\.system\(", re.IGNORECASE),
]

MAX_TOOL_CALLS_PER_RUN = 15

def guardrail_node(state: AgentState) -> AgentState:
    """Inspect the last AI message for dangerous tool calls."""
    messages = state["messages"]
    last_message = messages[-1]

    if not isinstance(last_message, AIMessage) or not last_message.tool_calls:
        return {"messages": [], "blocked_reason": None}

    # Count total tool calls in this run
    tool_call_count = sum(
        1 for msg in messages
        if isinstance(msg, ToolMessage)
    )
    if tool_call_count >= MAX_TOOL_CALLS_PER_RUN:
        return {
            "messages": [AIMessage(content="Tool call limit reached. Ending execution.")],
            "blocked_reason": "max_tool_calls_exceeded",
        }

    # Scan tool call arguments for dangerous patterns
    for tool_call in last_message.tool_calls:
        args_str = str(tool_call.get("args", {}))
        for pattern in DANGEROUS_PATTERNS:
            if pattern.search(args_str):
                return {
                    "messages": [AIMessage(content="Blocked: dangerous pattern detected in tool arguments.")],
                    "blocked_reason": f"dangerous_pattern:{pattern.pattern}",
                }

    return {"messages": [], "blocked_reason": None}

def should_continue(state: AgentState) -> str:
    """Route based on guardrail result."""
    if state.get("blocked_reason"):
        return "blocked"
    last = state["messages"][-1]
    if isinstance(last, AIMessage) and last.tool_calls:
        return "tools"
    return END

# Wire into your LangGraph agent:
# graph.add_node("guardrail", guardrail_node)
# graph.add_edge("agent", "guardrail")
# graph.add_conditional_edges("guardrail", should_continue, {
#     "tools": "tool_executor",
#     "blocked": END,
# })

Evaluation Gates

Evaluation gates are automated test suites that run against your agent pipeline before every deployment, acting as a quality and security gate in your CI/CD process. Unlike traditional unit tests, these evaluations test the agent's behavior end-to-end, including its tool selection, argument generation, and response quality.

Build three categories of evaluation datasets: functional tests (does the agent call the right tools for common user queries?), boundary tests (does the agent refuse to call tools when the request is out of scope?), and adversarial tests (does the agent resist prompt injection attempts embedded in tool outputs or user messages?).

Use LangChain's `langsmith` evaluation framework or the `ragas` library to run evaluations. Define custom evaluators that check not just the final response but the intermediate tool calls. A correct final answer generated through unauthorized tool access is still a security failure. Your evaluator should inspect the full trace and flag runs where blocked tools were attempted.

Set quantitative thresholds for your evaluation gates: require at least 95% pass rate on functional tests, 100% on critical security boundary tests, and track regression on adversarial tests. Fail the deployment pipeline if any threshold is breached. Store evaluation results with the deployment artifact for audit traceability.

Run evaluations against realistic data volumes. An agent that behaves correctly with 3 search results may break when a retriever returns 50 documents, as the expanded context increases prompt injection surface area. Include long-context test cases that embed adversarial instructions deep within retrieved content to verify your guardrails hold under realistic conditions.

Kill Switch Patterns

  • โœ“Implement a feature flag (e.g., LaunchDarkly, environment variable) that disables all agent tool execution instantly without redeployment. The agent can still respond but cannot call tools.
  • โœ“Build per-tool kill switches: store an enabled/disabled flag per tool name in Redis or a fast KV store, check it in your PermissionWrappedTool before every execution
  • โœ“Add a per-user circuit breaker that automatically disables agent access for a user after 3 consecutive tool errors within 5 minutes, requiring manual re-enablement
  • โœ“Implement a global cost circuit breaker: if total tool execution costs across all users exceed a configured threshold within a rolling window, disable tool calling system-wide and alert on-call
  • โœ“Create an admin API endpoint that can inject a system message into all active agent sessions instructing the model to stop calling tools and inform the user of a maintenance window
  • โœ“Set up automated rollback: if your monitoring detects error rates above threshold in the first 15 minutes after deployment, automatically revert to the previous agent configuration
  • โœ“Maintain a separate read-only agent configuration that can be swapped in during incidents. It has access to search and retrieval tools only, with all write/delete tools removed.
  • โœ“Document the kill switch procedures in a runbook with clear escalation paths, and test them quarterly with simulated incidents to ensure they work under pressure

Production Readiness Checklist

  • โœ“All tools are wrapped with PermissionWrappedTool or equivalent. No raw tools are bound directly to agents.
  • โœ“A custom callback handler logs every tool invocation with structured fields: trace ID, user ID, tool name, sanitized arguments, duration, and outcome
  • โœ“LangGraph guardrail nodes are in place to scan tool call arguments for injection patterns and enforce per-run tool call limits
  • โœ“Evaluation gate suite covers functional, boundary, and adversarial test cases with defined pass-rate thresholds blocking deployment
  • โœ“Kill switches exist at global, per-tool, and per-user levels, and have been tested within the last 90 days
  • โœ“Retriever outputs are sanitized before being passed to the agent to strip known prompt injection patterns from retrieved documents
  • โœ“Agent timeout is configured at both the LangGraph level (max_iterations) and the infrastructure level (HTTP request timeout) to prevent runaway execution
  • โœ“Secrets and credentials are never passed through the agent's message history. Tools access them via server-side environment variables or secret managers.
  • โœ“Rate limiting is enforced per user and per session, with separate limits for read and write tool operations
  • โœ“An incident response runbook exists covering: how to disable agent tool access, how to identify affected users from traces, and how to roll back agent configuration
  • โœ“Load testing has verified that permission checks, guardrails, and logging do not introduce latency regressions exceeding 50ms p99 under expected peak traffic
  • โœ“The agent's system prompt includes explicit instructions to never execute tools based solely on content found in retrieved documents without user confirmation

Stay clawproof

New checks, playbooks, and postmortems. Twice a month.

No spam. Unsubscribe anytime.