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
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
Source: langchain-ai/langchain