deer-flow/backend/tests/test_tool_approval_runtime.py
Wenchao An 3d826c6d95
feat(approvals): add Ask, Auto and Full tool permission modes (#8033)
* feat(hitl): add human-in-the-loop approval for high-risk tool calls

* fix(hitl): address approval review findings

* docs(hitl): keep agent guidance under size limit

* fix(hitl): guard approval pause recovery

* docs(hitl): shrink inherited guidance

* fix(hitl): harden approval admission and recovery

* fix(approvals): address review safety and reconciliation gaps

* ci: retry approval checks after runner timeout

---------

Co-authored-by: wangzeren <1004695029@qq.com>
2026-11-11 20:59:05 +08:00

249 lines
12 KiB
Python

"""Drive real graph pause/resume; a card is not proof that the tool was gated."""
from unittest.mock import AsyncMock
import pytest
from _agent_e2e_helpers import build_single_tool_call_model
from langchain.agents import create_agent
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from deerflow.approvals.middleware import ToolApprovalMiddleware
from deerflow.config.app_config import AppConfig
from deerflow.config.hitl_approval_config import HitlApprovalConfig
def graph_fixture(*, mode="ask", reviewer=None, policy_overrides=None, inner=None):
calls = []
@tool
def external_write(value: str) -> str:
"""Write externally."""
calls.append(value)
return "written"
policy = HitlApprovalConfig(enabled=True, tools=["external_write"], default_mode=mode, **{"allowed_modes": ["ask", "auto", "full"], **(policy_overrides or {})})
middleware = ToolApprovalMiddleware(policy, app_config=AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy), reviewer=reviewer)
model = build_single_tool_call_model(tool_name="external_write", tool_args={"value": "original"}, tool_call_id="call")
graph = create_agent(model, tools=[external_write], middleware=[middleware, *([inner] if inner else [])], checkpointer=InMemorySaver())
return graph, {"configurable": {"thread_id": "approval-thread"}}, calls
def response(paused, option, **extra):
payload = paused["__interrupt__"][0].value
action = payload["actions"][0]
return {"responses": [{"tool_call_id": action["tool_call_id"], "request_id": action["request_id"], "option_id": option, **extra}]}
@pytest.mark.asyncio
async def test_ask_really_stops_then_executes_exact_call_once():
graph, config, calls = graph_fixture()
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
assert not calls
await graph.ainvoke(Command(resume=response(paused, "approve")), config)
assert calls == ["original"]
@pytest.mark.asyncio
async def test_rejection_and_stale_response_never_execute():
for decision in ("reject", "approve"):
graph, config, calls = graph_fixture()
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
value = response(paused, decision)
if decision == "approve":
value["responses"][0]["request_id"] = "stale"
result = await graph.ainvoke(Command(resume=value), config)
assert not calls
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in result["messages"])
@pytest.mark.asyncio
async def test_auto_uses_separate_reviewer_and_never_rereviews_on_resume():
reviewer = AsyncMock(return_value={"outcome": "ask", "risk": "high", "authorization": "explicit", "reason": "External change"})
graph, config, calls = graph_fixture(mode="auto", reviewer=reviewer)
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
assert not calls
assert reviewer.await_count == 1
await graph.ainvoke(Command(resume=response(paused, "approve")), config)
assert calls == ["original"]
assert reviewer.await_count == 1
@pytest.mark.asyncio
async def test_full_skips_reviewer_but_still_executes_inner_middleware():
from langchain.agents.middleware import AgentMiddleware
class MandatoryGuard(AgentMiddleware):
async def awrap_tool_call(self, request, handler):
return ToolMessage(content="Denied by account permission", tool_call_id=request.tool_call["id"], status="error")
reviewer = AsyncMock(side_effect=AssertionError("Full must not invoke the reviewer"))
graph, config, calls = graph_fixture(mode="full", reviewer=reviewer, inner=MandatoryGuard())
await graph.ainvoke({"messages": [("user", "write original")]}, config)
assert calls == []
reviewer.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["ask", "auto", "full"])
async def test_operator_deny_applies_in_every_mode(mode):
graph, config, calls = graph_fixture(mode=mode, policy_overrides={"deny_tools": ["external_write"]})
result = await graph.ainvoke({"messages": [("user", "write original")]}, config)
assert not calls and not result.get("__interrupt__")
@pytest.mark.asyncio
async def test_invalid_edit_and_injected_runtime_fields_never_execute():
for args in ({"value": 99}, {"value": "edited", "runtime": {"is_admin": True}}):
graph, config, calls = graph_fixture()
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
await graph.ainvoke(Command(resume=response(paused, "edit", args=args)), config)
assert not calls
def test_sync_graph_suspends_before_execution():
graph, config, calls = graph_fixture()
paused = graph.invoke({"messages": [("user", "write original")]}, config)
assert not calls
graph.invoke(Command(resume=response(paused, "approve")), config)
assert calls == ["original"]
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel", [False, True])
async def test_child_in_an_isolated_loop_waits_for_parent_decision(cancel):
import asyncio
import threading
from types import SimpleNamespace
from deerflow.approvals.runtime import ApprovalRuntime
from deerflow.approvals.store import ApprovalStore
store = ApprovalStore()
scope = ("owner", "parent-thread", "incarnation")
bridge = ApprovalRuntime(store, scope=scope, run_id="parent-run", mode="ask", timeout=5, record=SimpleNamespace(ownership_lost=False, abort_event=threading.Event()))
bridge.set_user_messages(["write original"])
graph, config, calls = graph_fixture()
context = {"is_subagent": True, "__tool_approval_runtime": bridge.child(), "user_id": "owner", "thread_id": "parent-thread"}
running = asyncio.create_task(asyncio.to_thread(lambda: asyncio.run(graph.ainvoke({"messages": [("user", "delegated work")]}, config, context=context))))
async with asyncio.timeout(5):
while not (rows := await store.list(scope=scope, status="pending")):
await asyncio.sleep(0.01)
row = rows[0]
assert row["payload"]["transport"] == "live"
assert calls == []
if cancel:
await bridge.close()
else:
action = row["payload"]["actions"][0]
await store.decide(row["request_id"], {"responses": [{"request_id": action["request_id"], "tool_call_id": action["tool_call_id"], "option_id": "approve"}]}, scope=scope, fingerprint=row["fingerprint"])
await running
assert calls == ([] if cancel else ["original"])
assert (await store.get(row["request_id"], scope=scope))["status"] == ("cancelled" if cancel else "consumed")
await bridge.close()
@pytest.mark.asyncio
async def test_exact_thread_grant_is_reused_then_revoked():
import threading
from types import SimpleNamespace
from deerflow.approvals.runtime import ApprovalRuntime
from deerflow.approvals.store import ApprovalStore
bridge = ApprovalRuntime(ApprovalStore(), scope=("owner", "thread", "incarnation"), run_id="run", mode="ask", timeout=5, record=SimpleNamespace(ownership_lost=False, abort_event=threading.Event()))
context = {"__tool_approval_runtime": bridge, "user_id": "owner", "thread_id": "thread"}
graph, config, calls = graph_fixture()
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config, context=context)
assert paused["__interrupt__"][0].value["actions"][0]["grant_allowed"]
await graph.ainvoke(Command(resume=response(paused, "approve_thread")), config, context=context)
assert calls == ["original"]
fresh, config, fresh_calls = graph_fixture()
result = await fresh.ainvoke({"messages": [("user", "write original")]}, config, context=context)
assert not result.get("__interrupt__") and fresh_calls == ["original"]
await bridge.store.revoke(scope=bridge.scope)
fresh, config, fresh_calls = graph_fixture()
result = await fresh.ainvoke({"messages": [("user", "write original")]}, config, context=context)
assert result.get("__interrupt__") and fresh_calls == []
await bridge.close()
@pytest.mark.asyncio
async def test_edited_arguments_are_schema_checked_before_tool_execution():
graph, config, calls = graph_fixture()
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
await graph.ainvoke(Command(resume=response(paused, "edit", args={"value": "edited"})), config)
assert calls == ["edited"]
@pytest.mark.asyncio
async def test_noninteractive_auto_uncertainty_fails_closed_without_parking():
reviewer = AsyncMock(return_value={"outcome": "ask", "risk": "high", "authorization": "unknown", "reason": "Need authorization"})
graph, config, calls = graph_fixture(mode="auto", reviewer=reviewer)
result = await graph.ainvoke({"messages": [("user", "write original")]}, config, context={"non_interactive": True})
assert not calls
assert not result.get("__interrupt__")
@pytest.mark.asyncio
async def test_parallel_approval_uses_each_interrupt_identity_without_replaying_sibling():
from _approval_test_helpers import StateAwareToolModel
calls = []
@tool
def external_write(value: str) -> str:
"""Write externally."""
calls.append(value)
return "written"
policy = HitlApprovalConfig(enabled=True)
app = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy)
graph = create_agent(
StateAwareToolModel(responses=[], calls=[{"name": "external_write", "args": {"value": value}, "id": value} for value in ("a", "b")]),
tools=[external_write],
middleware=[ToolApprovalMiddleware(policy, app_config=app)],
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "parallel"}}
paused = await graph.ainvoke({"messages": [("user", "write a and b")]}, config)
assert len(paused["__interrupt__"]) == 2
assert not calls
resume = {}
for item in paused["__interrupt__"]:
action = item.value["actions"][0]
resume[item.id] = {"responses": [{"request_id": action["request_id"], "tool_call_id": action["tool_call_id"], "option_id": "approve"}]}
await graph.ainvoke(Command(resume=resume), config)
assert sorted(calls) == ["a", "b"]
@pytest.mark.asyncio
async def test_auto_approved_parallel_sibling_is_not_reexecuted_on_human_resume():
from _approval_test_helpers import StateAwareToolModel
calls = []
@tool
def external_write(value: str) -> str:
"""Record a write."""
calls.append(value)
return "written"
async def review(action, instructions):
return {"outcome": "allow" if action["arguments"]["value"] == "a" else "ask", "risk": "medium", "authorization": "explicit", "reason": "review"}
reviewer = AsyncMock(side_effect=review)
policy = HitlApprovalConfig(enabled=True, default_mode="auto")
app = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy)
model = StateAwareToolModel(responses=[], calls=[{"name": "external_write", "args": {"value": value}, "id": value} for value in ("a", "b")])
graph = create_agent(model, tools=[external_write], middleware=[ToolApprovalMiddleware(policy, app_config=app, reviewer=reviewer)], checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "mixed"}}
paused = await graph.ainvoke({"messages": [("user", "write both")]}, config)
assert calls == ["a"]
assert len(paused["__interrupt__"]) == 1
await graph.ainvoke(Command(resume=response(paused, "approve")), config)
assert calls == ["a", "b"]
assert reviewer.await_count == 2