#40492·langchain

langchain 1.4.0: any after_model middleware using documented `jump_to="tools"` bypasses the HITL pending_tool_calls gate and re-executes rejected tool calls

Author: TardfyouCreated Sep 16, 2026Updated Sep 17, 2026
Labelsbuglangchainexternal

Submission checklist

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

Package (Required)

  • langchain
  • langchain-openai
  • langchain-anthropic
  • langchain-classic
  • langchain-core
  • langchain-model-profiles
  • langchain-tests
  • langchain-text-splitters
  • langchain-chroma
  • langchain-deepseek
  • langchain-exa
  • langchain-fireworks
  • langchain-groq
  • langchain-huggingface
  • langchain-mistralai
  • langchain-nomic
  • langchain-ollama
  • langchain-openrouter
  • langchain-perplexity
  • langchain-qdrant
  • langchain-xai
  • Other / not sure / general

Related Issues / PRs

No response

Reproduction Steps / Example Code (Python)

Self-contained minimal repro (langchain==1.4.0, langgraph==1.2.11; no network needed).
Run it as is: the CONTROL run shows a rejected tool call does NOT execute; the
EXPERIMENT run adds one after_model middleware that uses the documented
jump_to="tools" outcome, and the SAME rejected call now EXECUTES.

import sys
import tempfile
from pathlib import Path
from typing import Any

from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, HumanInTheLoopMiddleware
from langchain.agents.middleware.types import AgentState, hook_config
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command


class FakeToolModel(BaseChatModel):
    """Offline model that always requests the unsafe tool."""
    responses: list = [
        AIMessage(content="", tool_calls=[{
            "name": "unsafe_tool", "args": {"note": "poc"},
            "id": "call_1", "type": "tool_call"}]),
        AIMessage(content="done"),
    ]
    idx: int = 0

    @property
    def _llm_type(self) -> str:
        return "fake"

    def bind_tools(self, tools, **kwargs):
        return self

    def _generate(self, messages, run_id=None, **kwargs):
        from langchain_core.outputs import ChatGeneration, ChatResult
        msg = self.responses[min(self.idx, len(self.responses) - 1)]
        object.__setattr__(self, "idx", self.idx + 1)
        return ChatResult(generations=[ChatGeneration(message=msg)])


MARKER = Path(tempfile.gettempdir()) / "lc_hitl_poc_marker.txt"


@tool
def unsafe_tool(note: str) -> str:
    """A dangerous tool that must never execute when rejected."""
    MARKER.write_text(f"EXECUTED with note={note}")
    return "executed"


class JumpToToolsMiddleware(AgentMiddleware):
    """Uses the documented jump_to="tools" outcome (types.py: 'tools': Jump to the tools node)."""

    @hook_config(can_jump_to=["tools"])
    def after_model(self, state: AgentState, runtime: Any):
        last_ai = next((m for m in reversed(state.get("messages", []))
                        if isinstance(m, AIMessage)), None)
        if last_ai and last_ai.tool_calls:
            return {"jump_to": "tools"}
        return None


def run(jump: bool) -> bool:
    if MARKER.exists():
        MARKER.unlink()
    middleware = ([JumpToToolsMiddleware()] if jump else []) + [
        HumanInTheLoopMiddleware(
            interrupt_on={"unsafe_tool": {"allowed_decisions": ["approve", "reject"]}}),
    ]
    agent = create_agent(
        model=FakeToolModel(),
        tools=[unsafe_tool],
        middleware=middleware,
        checkpointer=InMemorySaver(),
    )
    cfg = {"configurable": {"thread_id": "poc"}}
    agent.invoke({"messages": [{"role": "user", "content": "run the unsafe tool"}]}, config=cfg)
    # human REJECTS the call
    agent.invoke(
        Command(resume={"decisions": [{"type": "reject", "message": "not allowed"}]}),
        config=cfg)
    return MARKER.exists()


print("CONTROL (no jump middleware):  rejected tool executed =", run(jump=False))
print("EXPERIMENT (jump_to='tools'):  rejected tool executed =", run(jump=True))

# CONTROL  -> False (correct: the rejection is honored)
# EXPERIMENT -> True  (the rejected tool call EXECUTES)
sys.exit(0)

Results:
CONTROL (no jump middleware):  rejected tool executed = False
EXPERIMENT (jump_to='tools'):  rejected tool executed = True

Error Message and Stack Trace (if applicable)

Description

Target: huntr.com -> report against PyPI package "langchain" (or GitHub repo langchain-ai/langchain)

Title

langchain 1.4.0: any after_model middleware using documented jump_to="tools" bypasses the HITL pending_tool_calls gate and re-executes rejected tool calls

Summary

In langchain 1.4.0, the HumanInTheLoopMiddleware records pending tool calls in AgentState (pending_tool_calls) before interrupting. Any other after_model middleware that returns the documented {"jump_to": "tools"} outcome routes the graph directly to the tool node, skipping the HITL middleware entirely - the previously rejected (or not-yet-reviewed) tool calls are executed with the user's authority. Additionally, tool calls carrying a tool_call_id already present in pending_tool_calls are not re-deduplicated on the bypassed path.

Root cause

  • libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py - pending_tool_calls bookkeeping
  • graph routing in libs/langchain_v1/langchain/agents/factory.py - jump_to="tools" short-circuit does not re-enter the HITL middleware This is distinct from public issue #40166 (cross-tool edit inside the review flow): here the entire review stage is bypassed via the documented jump_to outcome of a second middleware.

Reproduction

Two-middleware agent (HITLMiddleware with allowed_decisions=[approve, reject] on a marker-writing tool, plus a custom after_model middleware returning jump_to="tools" once). Control run: model proposes tool -> interrupt -> human rejects -> tool not executed. Attack run: same, but the second middleware emits jump_to="tools" -> tool executes, marker written, rejection ignored.

Full offline PoC (pytest, TestModel) available on request.

Impact

Any agent stack that composes an additional after_model middleware (documented, mainstream pattern - summarizers, guardrails, logging) silently defeats human-in-the-loop approval for every tool call.

Suggested fix

Route jump_to="tools" through the HITL middleware (or re-check pending_tool_calls in the tool node) so a rejected/reviewable tool call cannot execute without a decision.

Credit

Chengzhi Yi (GitHub: @Tardfyou, [email protected])

System Info

System Information

OS: Linux OS Version: #71-Ubuntu SMP PREEMPT_DYNAMIC Tue Jul 22 16:52:38 UTC 2025 Python Version: 3.12.3 (main, Aug 31 2026, 10:18:26) [GCC 13.3.0]

Package Information

langchain_core: 1.6.2 langchain: 1.4.0 langsmith: 0.12.1 langchain_protocol: 0.0.19 langgraph_sdk: 0.4.4

Optional packages not installed

deepagents deepagents-cli

Other Dependencies

anyio: 4.15.0 distro: 1.9.0 httpx: 0.28.1 httpx2: 2.12.0 jsonpatch: 1.33 langgraph: 1.2.11 orjson: 3.12.0 packaging: 26.3 pydantic: 2.13.5 pyyaml: 6.0.3 requests: 2.34.2 requests-toolbelt: 1.0.0 sniffio: 1.3.1 tenacity: 9.1.4 typing-extensions: 4.16.0 uuid-utils: 0.17.0 websockets: 16.1.1 xxhash: 4.0.1

Social handles (optional)

No response