LangGraph 高危 Bug:HumanInTheLoopMiddleware 误放已拒绝 Tool Call 内幕
2026 年初,LangChain 社区报告了一个高危生产级 Bug:HumanInTheLoopMiddleware(HitlM)在 LangGraph 的 ToolNode 中出现逻辑错乱——被用户明确拒绝的 Tool Call 仍然被执行。这并非边缘案例,而是影响所有使用 Human-In-The-Loop 模式构建代理系统的开发者。本文从问题现象出发,深入 ToolNode 执行流,追踪这条 Bug 的技术根因。
一、问题现象
问题出在 HumanInTheLoopMiddleware 与 LangGraph ToolNode 的协作流程中。在典型的 Human-In-The-Loop 代理架构里:
- LLM 发起 Tool Call
- HitlM 中断执行,将 Tool Call 提交给人工审批
- 人工审批:批准(
approved=True)或拒绝(approved=False) - ToolNode 根据审批结果决定是否执行
Bug 的表现是:人工拒绝后,ToolNode 仍然执行了该 Tool。以下是问题复现的简化流程:
from langgraph.prebuilt import ToolNode
from langchain_core.messages import AIMessage, ToolCall
from your_hitl_middleware import HumanInTheLoopMiddleware
# 构造一个被拒绝的 Tool Call
tool_call = ToolCall(
id="call_abc123",
name="transfer_funds",
args={"to": "attacker", "amount": 1000000}
)
# 模拟人工拒绝
hitl_middleware = HumanInTheLoopMiddleware()
approval_result = hitl_middleware.process_tool_call(
tool_call=tool_call,
user_approval=False # 明确拒绝
)
print(f"审批结果: {approval_result.should_execute}")
# 预期: False
# 实际: True <- BUG!
tool_node = ToolNode(tools=[transfer_funds])
result = tool_node.invoke([AIMessage(content="", tool_calls=[tool_call])])
# 即使 approval=False,transfer_funds 仍被调用
二、架构分析:HitlM 与 ToolNode 的交互流
2.1 HumanInTheLoopMiddleware 的职责
HumanInTheLoopMiddleware 位于 LLM 与 ToolNode 之间,负责拦截 Tool Call 并等待人工操作。其核心接口设计:
class HumanInTheLoopMiddleware:
def process_tool_call(
self,
tool_call: ToolCall,
user_approval: Optional[bool] = None
) -> ToolCallResult:
"""
如果 user_approval=None:挂起等待人工决策
如果 user_approval=True:放行
如果 user_approval=False:标记为 rejected
"""
if user_approval is None:
return self._suspend(tool_call)
return ToolCallResult(
tool_call_id=tool_call.id,
approved=user_approval,
# 这里 bug 的关键:rejected 时没有设置 should_execute=False
)
2.2 LangGraph ToolNode 的执行流
ToolNode 的标准执行流程:
class ToolNode:
def invoke(self, inputs: list[BaseMessage]) -> list[BaseMessage]:
ai_msg = inputs[-1]
if not ai_msg.tool_calls:
return inputs
responses = []
for tool_call in ai_msg.tool_calls:
# HitlM 返回的 result 决定是否执行
hitl_result = self.hitl_middleware.process_tool_call(
tool_call,
user_approval=get_user_decision(tool_call.id)
)
# BUG 在这里:ToolNode 判断是否执行只看有没有异常
if hitl_result is None or hitl_result.error:
# 悬挂等待,不执行
raise GraphResume()
# 问题:没有检查 hitl_result.approved 标志
# 直接进入执行分支
response = self._execute_tool(tool_call)
responses.append(response)
return inputs + responses
2.3 Bug 根因定位
问题出在两处:
第一处:HitlM 返回值语义模糊
当 user_approval=False 时,HitlM 返回的 ToolCallResult 对象没有将 should_execute 明确设为 False,而是沿用了默认值或 None。这导致 ToolNode 的条件判断 if hitl_result is None or hitl_result.error 无法识别这是一个被拒绝的调用。
| user_approval | 预期 should_execute | 实际 HitlM 返回值 | ToolNode 行为 |
|---|---|---|---|
True |
True |
approved=True, error=None |
执行 ✓ |
False |
False |
approved=False, error=None ← 缺字段 |
执行 ✗ BUG |
None(待审批) |
抛 GraphResume |
error=SuspendedError |
悬挂 ✓ |
第二处:ToolNode 缺少对 approved 字段的校验
即使 HitlM 正确设置了 approved=False,ToolNode 的执行逻辑也从未读取这个字段。代码直接跳到 _execute_tool,完全信任了 HitlM 的返回对象。
# ToolNode.invoke() 中的问题代码(简化)
hitl_result = self.hitl_middleware.process_tool_call(tool_call, approval)
# BUG: 缺少这行关键检查
if hitl_result.approved is False: # ← 从未执行
return self._handle_rejection(tool_call.id)
# 直接执行(没有检查 approved 标志)
response = self._execute_tool(tool_call) # ← BUG 路径
三、安全影响评估
这个 Bug 的安全影响取决于 ToolNode 绑定的工具能力。以下是典型危险场景:
场景一:恶意提现
# LLM 生成的危险 Tool Call
tool_call = ToolCall(
id="call_001",
name="transfer_funds",
args={"to": "attacker_account", "amount": 1000000}
)
# 用户明确拒绝,但 tool_call 仍被执行
# 后果:资金损失
场景二:数据泄露
# LLM 尝试发送敏感数据
tool_call = ToolCall(
id="call_002",
name="send_email",
args={"to": "external", "body": "用户密码: xxx" }
)
# 用户点击拒绝,但邮件仍被发出
# 后果:GDPR 违规、数据泄露
场景三:Prompt 注入链
攻击者可以通过注入恶意指令让 LLM 生成危险的 Tool Call,依赖 HumanInThe-Loop 作为最后防线。但这条防线在当前 Bug 下完全失效。
四、修复方案
方案一:ToolNode 增加 approved 标志校验(推荐)
class ToolNode:
def invoke(self, inputs: list[BaseMessage]) -> list[BaseMessage]:
ai_msg = inputs[-1]
if not ai_msg.tool_calls:
return inputs
responses = []
for tool_call in ai_msg.tool_calls:
hitl_result = self.hitl_middleware.process_tool_call(
tool_call,
user_approval=get_user_decision(tool_call.id)
)
# ✅ 修复:显式检查 approved 标志
if hitl_result is None:
raise GraphResume()
if hitl_result.error:
# 悬挂或异常
if isinstance(hitl_result.error, SuspendedError):
raise GraphResume()
raise ToolCallError(hitl_result.error)
# ✅ 核心修复:拒绝的 call 不执行
if hitl_result.approved is False:
responses.append(
ToolMessage(
content=f"Tool call {tool_call.name} was rejected by human",
tool_call_id=tool_call.id,
status="rejected"
)
)
continue # 跳过执行
# approved=True 或 approved=None 且无 error → 执行
response = self._execute_tool(tool_call)
responses.append(response)
return inputs + responses
方案二:HitlM 明确 Rejected 语义
class HumanInTheLoopMiddleware:
def process_tool_call(
self,
tool_call: ToolCall,
user_approval: Optional[bool] = None
) -> ToolCallResult:
if user_approval is None:
return ToolCallResult(
tool_call_id=tool_call.id,
approved=None,
error=SuspendedError(f"Waiting for approval on {tool_call.name}")
)
return ToolCallResult(
tool_call_id=tool_call.id,
approved=user_approval, # ✅ True 或 False 都明确传递
error=None,
should_execute=user_approval # ✅ 新增字段:显式控制执行权
)
方案三:临时缓解措施
如果无法立即升级,在业务层加防护:
# 在调用 ToolNode 之前,业务层检查
def safe_tool_call(tool_node, tool_call, user_decision):
if user_decision is False:
# 记录拒绝,阻止调用
logger.warning(f"Tool call {tool_call.name} rejected, skipping execution")
return None
return tool_node.invoke([AIMessage(tool_calls=[tool_call])])
五、相关代码位置
| 文件 | 问题代码行 | 修复说明 |
|---|---|---|
langgraph/prebuilt/tool_node.py |
L210-220 | 增加 approved is False 检查 |
langchain_core/middleware/hitl.py |
L88-95 | user_approval=False 时设置 should_execute=False |
六、总结
这个 Bug 的本质是两处代码对审批状态的理解不一致:HitlM 认为传递 approved=False 就能阻止执行,而 ToolNode 根本没有读取这个字段。在安全关键系统里,这种"协议不对称"是极其危险的——防御层没有真正生效。
修复并不复杂,但暴露出的问题值得深思:
- 语义校验必须在执行层完成,不能假设中间件的返回对象本身就包含执行决策
- HumanInThe-Loop 模式的防御价值依赖正确实现,一个小的逻辑遗漏可以让整条防线形同虚设
- 在高风险 Tool Call 场景下,建议同时在业务层和中间件层做双重校验
LangChain 已在 v1.4.x 中修复了此问题,建议所有使用 HumanInTheLoop + LangGraph 的用户升级。
参考链接:
GitHub Issue: langchain#37093
评论区