LangGraph 高危 Bug:HumanInTheLoopMiddleware 误放已拒绝 Tool Call

LangGraph 高危 Bug:HumanInTheLoopMiddleware 误放已拒绝 Tool Call

LangGraph 高危 Bug:HumanInTheLoopMiddleware 误放已拒绝 Tool Call 内幕

2026 年初,LangChain 社区报告了一个高危生产级 Bug:HumanInTheLoopMiddleware(HitlM)在 LangGraph 的 ToolNode 中出现逻辑错乱——被用户明确拒绝的 Tool Call 仍然被执行。这并非边缘案例,而是影响所有使用 Human-In-The-Loop 模式构建代理系统的开发者。本文从问题现象出发,深入 ToolNode 执行流,追踪这条 Bug 的技术根因。


一、问题现象

问题出在 HumanInTheLoopMiddlewareLangGraph ToolNode 的协作流程中。在典型的 Human-In-The-Loop 代理架构里:

  1. LLM 发起 Tool Call
  2. HitlM 中断执行,将 Tool Call 提交给人工审批
  3. 人工审批:批准approved=True)或拒绝approved=False
  4. 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

如果内容对您有帮助,欢迎打赏

您的支持是我继续创作的动力

前往打赏页面

评论区

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注