mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
* test(mcp): cover passive skill tool visibility * test(mcp): tighten deferred discovery coverage
403 lines
13 KiB
Python
403 lines
13 KiB
Python
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from langchain.agents import create_agent
|
|
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
from langchain_core.tools import tool
|
|
from pydantic import Field
|
|
|
|
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
|
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
|
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
|
from deerflow.agents.thread_state import ThreadState
|
|
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, write_slash_skill_source_path
|
|
from deerflow.runtime.serialization import serialize
|
|
from deerflow.skills.describe import build_skill_search_setup
|
|
from deerflow.skills.types import Skill, SkillCategory
|
|
from deerflow.tools.builtins.tool_search import build_deferred_tool_setup
|
|
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
|
|
|
_SLASH_SOURCE_OWNER_TOKEN = "test-slash-source-owner"
|
|
_CALC_CALLS: list[str] = []
|
|
_DENIED_CALLS: list[str] = []
|
|
|
|
|
|
@tool
|
|
def calc(expression: str) -> str:
|
|
"""Evaluate an arithmetic expression."""
|
|
_CALC_CALLS.append(expression)
|
|
return "4"
|
|
|
|
|
|
@tool
|
|
def denied_lookup(query: str) -> str:
|
|
"""Run a lookup the active skill does not authorize."""
|
|
_DENIED_CALLS.append(query)
|
|
return "denied data"
|
|
|
|
|
|
class _StorageStub:
|
|
def __init__(self, skills: list[Skill]):
|
|
self._skills = skills
|
|
self.load_calls = 0
|
|
|
|
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
|
|
self.load_calls += 1
|
|
return [skill for skill in self._skills if skill.enabled or not enabled_only]
|
|
|
|
def get_container_root(self) -> str:
|
|
return "/mnt/skills"
|
|
|
|
|
|
class _RecordingModel(GenericFakeChatModel):
|
|
bound_tool_names: list[list[str]] = Field(default_factory=list)
|
|
|
|
def __init__(self, responses: list[AIMessage]):
|
|
super().__init__(messages=iter(responses))
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
self.bound_tool_names.append([getattr(candidate, "name", "") for candidate in tools])
|
|
return self
|
|
|
|
|
|
def _skill(name: str, allowed_tools: list[str]) -> Skill:
|
|
skill_dir = Path(f"/tmp/skills/public/{name}")
|
|
return Skill(
|
|
name=name,
|
|
description=f"Description for {name}",
|
|
license="MIT",
|
|
skill_dir=skill_dir,
|
|
skill_file=skill_dir / "SKILL.md",
|
|
relative_path=Path(name),
|
|
category=SkillCategory.PUBLIC,
|
|
allowed_tools=tuple(allowed_tools),
|
|
enabled=True,
|
|
)
|
|
|
|
|
|
def _active_policy(skill: Skill) -> tuple[SkillToolPolicyMiddleware, dict[str, object]]:
|
|
middleware = SkillToolPolicyMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
|
middleware._storage = lambda: _StorageStub([skill])
|
|
context: dict[str, object] = {}
|
|
write_slash_skill_source_path(
|
|
context,
|
|
skill.get_container_file_path(),
|
|
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
|
)
|
|
return middleware, context
|
|
|
|
|
|
def _deferred_setup():
|
|
return build_deferred_tool_setup(
|
|
[tag_mcp_tool(calc), tag_mcp_tool(denied_lookup)],
|
|
enabled=True,
|
|
)
|
|
|
|
|
|
def test_passive_empty_skill_policy_preserves_deferred_mcp_discovery_and_calling():
|
|
passive_fixture = _skill("example-safe-skill", [])
|
|
storage = _StorageStub([passive_fixture])
|
|
policy = SkillToolPolicyMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
|
policy._storage = lambda: storage
|
|
setup = _deferred_setup()
|
|
model = _RecordingModel(
|
|
[
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "tool_search",
|
|
"args": {"query": "select:calc"},
|
|
"id": "passive-search-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "calc",
|
|
"args": {"expression": "2 + 2"},
|
|
"id": "passive-calc-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(content="done"),
|
|
]
|
|
)
|
|
_CALC_CALLS.clear()
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[calc, denied_lookup, setup.tool_search_tool],
|
|
middleware=[
|
|
policy,
|
|
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
|
],
|
|
state_schema=ThreadState,
|
|
)
|
|
|
|
result = graph.invoke(
|
|
{"messages": [HumanMessage(content="use the configured MCP calculator")]},
|
|
context={},
|
|
)
|
|
|
|
assert model.bound_tool_names[0] == ["tool_search"]
|
|
assert "calc" in model.bound_tool_names[1]
|
|
# This tool remains hidden because it was not promoted, not because the passive
|
|
# skill applied a policy restriction.
|
|
assert "denied_lookup" not in model.bound_tool_names[1]
|
|
assert _CALC_CALLS == ["2 + 2"]
|
|
assert result["promoted"] == {"catalog_hash": setup.catalog_hash, "names": ["calc"]}
|
|
assert storage.load_calls == 0
|
|
|
|
|
|
def test_active_skill_can_search_promote_and_call_allowed_deferred_tool():
|
|
restricted = _skill("restricted", ["calc"])
|
|
policy, context = _active_policy(restricted)
|
|
setup = _deferred_setup()
|
|
model = _RecordingModel(
|
|
[
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "tool_search",
|
|
"args": {"query": "select:calc,denied_lookup"},
|
|
"id": "search-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "calc",
|
|
"args": {"expression": "2 + 2"},
|
|
"id": "calc-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(content="done"),
|
|
]
|
|
)
|
|
_CALC_CALLS.clear()
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[calc, denied_lookup, setup.tool_search_tool],
|
|
middleware=[
|
|
policy,
|
|
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
|
],
|
|
state_schema=ThreadState,
|
|
)
|
|
|
|
result = asyncio.run(
|
|
graph.ainvoke(
|
|
{"messages": [HumanMessage(content="use the allowed calculator")]},
|
|
context=context,
|
|
)
|
|
)
|
|
|
|
assert model.bound_tool_names[0] == ["tool_search"]
|
|
assert "calc" in model.bound_tool_names[1]
|
|
assert "denied_lookup" not in model.bound_tool_names[1]
|
|
assert _CALC_CALLS == ["2 + 2"]
|
|
assert result["promoted"] == {"catalog_hash": setup.catalog_hash, "names": ["calc"]}
|
|
search_result = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "search-call"]
|
|
assert len(search_result) == 1
|
|
assert '"name": "calc"' in search_result[0].content
|
|
assert "denied_lookup" not in search_result[0].content
|
|
|
|
|
|
def test_tool_search_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
|
restricted = _skill("restricted", ["calc"])
|
|
policy, context = _active_policy(restricted)
|
|
setup = _deferred_setup()
|
|
model = _RecordingModel(
|
|
[
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "tool_search",
|
|
"args": {"query": "select:denied_lookup"},
|
|
"id": "search-denied",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "denied_lookup",
|
|
"args": {"query": "secret"},
|
|
"id": "denied-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(content="done"),
|
|
]
|
|
)
|
|
_DENIED_CALLS.clear()
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[calc, denied_lookup, setup.tool_search_tool],
|
|
middleware=[
|
|
policy,
|
|
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
|
],
|
|
state_schema=ThreadState,
|
|
)
|
|
|
|
result = graph.invoke(
|
|
{"messages": [HumanMessage(content="try the denied lookup")]},
|
|
context=context,
|
|
)
|
|
|
|
assert all("denied_lookup" not in names for names in model.bound_tool_names)
|
|
assert _DENIED_CALLS == []
|
|
assert result["promoted"] == {
|
|
"catalog_hash": setup.catalog_hash,
|
|
"names": [],
|
|
}
|
|
search_result = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "search-denied"]
|
|
assert len(search_result) == 1
|
|
assert "denied_lookup" not in search_result[0].content
|
|
blocked = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "denied-call"]
|
|
assert len(blocked) == 1
|
|
assert blocked[0].status == "error"
|
|
assert "not allowed by the active skill policy" in blocked[0].content
|
|
|
|
|
|
def test_auto_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
|
restricted = _skill("restricted", ["calc"])
|
|
policy, context = _active_policy(restricted)
|
|
setup = _deferred_setup()
|
|
routing = McpRoutingMiddleware(
|
|
{"denied_lookup": {"priority": 100, "keywords": ["denied lookup"]}},
|
|
setup.catalog_hash,
|
|
3,
|
|
)
|
|
model = _RecordingModel(
|
|
[
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "denied_lookup",
|
|
"args": {"query": "secret"},
|
|
"id": "auto-denied-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(content="done"),
|
|
]
|
|
)
|
|
_DENIED_CALLS.clear()
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[calc, denied_lookup, setup.tool_search_tool],
|
|
middleware=[
|
|
policy,
|
|
routing,
|
|
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
|
],
|
|
state_schema=ThreadState,
|
|
)
|
|
|
|
result = graph.invoke(
|
|
{"messages": [HumanMessage(content="run the denied lookup")]},
|
|
context=context,
|
|
)
|
|
|
|
assert all("denied_lookup" not in names for names in model.bound_tool_names)
|
|
assert _DENIED_CALLS == []
|
|
assert result["promoted"] == {
|
|
"catalog_hash": setup.catalog_hash,
|
|
"names": ["denied_lookup"],
|
|
}
|
|
blocked = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "auto-denied-call"]
|
|
assert len(blocked) == 1
|
|
assert blocked[0].status == "error"
|
|
assert "not allowed by the active skill policy" in blocked[0].content
|
|
|
|
|
|
def test_restrictive_skill_keeps_deferred_skill_discovery_available():
|
|
restricted = _skill("restricted", [])
|
|
hidden = _skill("hidden", ["write_file"])
|
|
skill_setup = build_skill_search_setup(
|
|
[restricted],
|
|
enabled=True,
|
|
)
|
|
policy, context = _active_policy(restricted)
|
|
model = _RecordingModel(
|
|
[
|
|
AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "describe_skill",
|
|
"args": {"name": f"select:{restricted.name},{hidden.name}"},
|
|
"id": "describe-call",
|
|
"type": "tool_call",
|
|
}
|
|
],
|
|
),
|
|
AIMessage(content="done"),
|
|
]
|
|
)
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[skill_setup.describe_skill_tool],
|
|
middleware=[policy],
|
|
state_schema=ThreadState,
|
|
)
|
|
|
|
result = graph.invoke(
|
|
{"messages": [HumanMessage(content="describe available skills")]},
|
|
context=context,
|
|
)
|
|
|
|
assert model.bound_tool_names[0] == ["describe_skill"]
|
|
described = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "describe-call"]
|
|
assert len(described) == 1
|
|
assert "Skill: restricted" in described[0].content
|
|
assert "Skill: hidden" not in described[0].content
|
|
|
|
|
|
def test_policy_tokens_do_not_appear_in_debug_or_checkpoint_streams():
|
|
restricted = _skill("restricted", [])
|
|
policy, context = _active_policy(restricted)
|
|
model = _RecordingModel([AIMessage(content="done")])
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[],
|
|
middleware=[policy],
|
|
state_schema=ThreadState,
|
|
)
|
|
|
|
events = list(
|
|
graph.stream(
|
|
{"messages": [HumanMessage(content="finish")]},
|
|
context=context,
|
|
stream_mode=["debug", "checkpoints"],
|
|
)
|
|
)
|
|
serialized = [serialize(chunk, mode=mode) for mode, chunk in events]
|
|
payload = json.dumps(serialized, ensure_ascii=False, default=str)
|
|
|
|
assert events
|
|
assert _SLASH_SOURCE_OWNER_TOKEN not in payload
|
|
assert policy._decision_owner_token not in payload
|
|
assert "__slash_skill_secret_source" not in payload
|
|
assert SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY not in payload
|