mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 04:14:38 +08:00
fix(agent): enforce read-only subagent tool actions
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
"""LangChain 工具调用的 MoviePilot 宿主策略中间件。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware, ToolCallRequest, hook_config
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from app.agent.policy.api import resolve_api_operation
|
||||
from app.agent.policy.contracts import (
|
||||
ActionEffect,
|
||||
ExecutionOutcome,
|
||||
ExecutionReceipt,
|
||||
ToolOrigin,
|
||||
@@ -22,11 +25,19 @@ from app.agent.policy.registry import requests_system_setting_secrets
|
||||
from app.agent.policy.sanitizer import stable_type_name
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.mcp import McpExternalTool
|
||||
from app.agent.tools.result import EXECUTION_OUTCOME_KEY, ToolExecutionError, annotate_tool_result
|
||||
from app.agent.tools.tags import ToolTag
|
||||
|
||||
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
|
||||
POLICY_UNAVAILABLE_MESSAGE = "宿主策略暂时不可用,未执行该工具。"
|
||||
TOOL_TIMEOUT_MESSAGE = "工具执行超时,已停止等待结果;若工具包含外部写操作,操作可能仍在继续,请先确认实际状态再重试。"
|
||||
SUBAGENT_READ_ACTIONS = {
|
||||
"execute_command": frozenset({"read", "wait"}),
|
||||
"browse_webpage": frozenset({"snapshot", "get_content", "screenshot", "wait", "list_tabs"}),
|
||||
"persona": frozenset({"list"}),
|
||||
"agent_task": frozenset({"list"}),
|
||||
}
|
||||
|
||||
|
||||
# follow_imports=skip 下只在第三方中间件基类和装饰器边界忽略 misc。
|
||||
@@ -35,6 +46,7 @@ class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc]
|
||||
|
||||
模型供应商原生 server tools 在供应商侧执行,不经过本地 middleware,
|
||||
因而不具备这里生成的 start/finish/fail 回执。
|
||||
只读子代理的动作范围由宿主强制执行,不受主代理兼容观测模式影响。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -151,6 +163,17 @@ class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc]
|
||||
enforce_decision: bool = True,
|
||||
) -> tuple[bool, Any]:
|
||||
"""执行一次本地工具调用,并复用 ToolNode 的策略生命周期。"""
|
||||
if self.context.origin is ToolOrigin.SUBAGENT and not self._subagent_read_allowed(tool, arguments):
|
||||
return False, ToolMessage(
|
||||
content=json.dumps({
|
||||
"success": False, "error": "subagent_read_only",
|
||||
"message": "子代理只允许已声明的只读操作;请由主代理核验并执行已获授权的写入。",
|
||||
}, ensure_ascii=False),
|
||||
tool_call_id=str(invocation_id or ""),
|
||||
name=str(getattr(tool, "name", None) or "unknown"),
|
||||
status="error",
|
||||
additional_kwargs={EXECUTION_OUTCOME_KEY: ExecutionOutcome.FAILED.value},
|
||||
)
|
||||
observation = call_policy_hook(
|
||||
"start",
|
||||
self.orchestrator.start,
|
||||
@@ -195,6 +218,32 @@ class AgentPolicyMiddleware(AgentMiddleware): # type: ignore[misc]
|
||||
)
|
||||
return True, result
|
||||
|
||||
@staticmethod
|
||||
def _subagent_read_allowed(tool: Any, arguments: dict[str, Any]) -> bool:
|
||||
"""按真实 API 操作限制子代理,MCP 的兼容 Read 标签不构成只读证明。"""
|
||||
if isinstance(tool, MoviePilotApiTool):
|
||||
operation = resolve_api_operation(str(arguments.get("operation_id") or ""))
|
||||
if operation is None or operation.effect is not ActionEffect.SAFE_READ:
|
||||
return False
|
||||
try:
|
||||
normalized = tool.canonical_arguments(arguments)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return not requests_system_setting_secrets(normalized)
|
||||
if isinstance(tool, McpExternalTool):
|
||||
return False
|
||||
name = getattr(tool, "name", None)
|
||||
if name in SUBAGENT_READ_ACTIONS:
|
||||
action = arguments.get("action")
|
||||
return bool(
|
||||
isinstance(action, str) and action in SUBAGENT_READ_ACTIONS[name]
|
||||
and not arguments.get("cookies") and not arguments.get("user_agent")
|
||||
)
|
||||
tags = set(getattr(tool, "tags", None) or [])
|
||||
return ToolTag.Read in tags and not tags.intersection({
|
||||
ToolTag.Write, ToolTag.Message, ToolTag.UserInteraction,
|
||||
})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentPolicyMiddleware",
|
||||
|
||||
@@ -79,6 +79,7 @@ Rules:
|
||||
- Subagents must not send messages to the user, ask for interaction, or reveal their internal tool activity.
|
||||
- Give the user only your synthesized final answer and the minimum necessary next step.
|
||||
- If a task requires configuration changes, deletion, adding downloads, adding subscriptions, or any high-impact action, the main agent must handle it directly under the confirmation policy.
|
||||
- Child tools enforce read-only operations. Perform command launches, browser navigation/interactions, and external MCP calls in the main agent; pass the resulting evidence to a child for analysis when useful.
|
||||
</subagents>"""
|
||||
|
||||
SUBAGENT_TASK_DESCRIPTION = (
|
||||
@@ -103,7 +104,7 @@ Requirements:
|
||||
- Handle only the delegated subtask from the main agent. Do not converse with the user.
|
||||
- Do not send messages, request user interaction, or output progress updates.
|
||||
- Use tool results only for analysis, and return the final result only to the main agent.
|
||||
- Unless the task explicitly requires it and your tool set permits it, limit yourself to read-only inspection and diagnosis.
|
||||
- Limit yourself to the read-only operations permitted by the host. Command launches, browser navigation/interactions, external MCP calls, and writes must be handled by the main agent; request the resulting evidence instead of bypassing a denial.
|
||||
- If user confirmation or a high-impact change is needed, explain why the main agent must confirm it instead of executing it yourself.
|
||||
- Return a concise structured Chinese result with key evidence, judgment, and recommended next step.
|
||||
"""
|
||||
|
||||
@@ -51,6 +51,8 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
|
||||
|
||||
## 子代理连续工作
|
||||
|
||||
默认子代理在宿主执行边界强制只读。多功能 API 网关按具体 `operation_id` 判断副作用,继承管理员身份也不能修改、删除、触发下载或服务操作;敏感设置原值读取同样不开放。终端仅允许读取/等待已有输出;浏览器仅允许观察已有页面,不允许导航、输入、点击、脚本、关闭或修改 Cookie。外部 MCP 的兼容 Read 标签没有动作级保证,需交由主代理处理。正常业务查询和声明只读的本地工具仍可使用。
|
||||
|
||||
一轮结束会回收未完成的子任务,正常收敛后同一会话下一轮仍可委派。会话关闭时永久停止接收子任务;无法在等待上限内收敛的任务仍由原 owner 持有并封闭新提交。
|
||||
|
||||
子代理沿用主 Agent 的最终请求预算和上下文压缩机制。长调查可以压缩旧消息;最新单条工具事务或固定提示本身超过模型窗口时仍会明确失败,不会静默删除关键内容。
|
||||
|
||||
@@ -757,7 +757,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 995 |
|
||||
| 内部导入边 | 8,455 |
|
||||
| 内部导入边 | 8,458 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 995 / 8,455 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复与 Agent 计划模块的受控依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 995 / 8,458 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复与 Agent 计划模块的受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8455,
|
||||
"edge_sha256": "d989cabfc34fcff77f417e01f671915adb188f00d7beb00deb82fdff2d135bdf",
|
||||
"edge_count": 8458,
|
||||
"edge_sha256": "77a25667756a4b63d8487ac44252d77662044f1ea74d7c77dc0e4383a22d5f10",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1492,6 +1492,7 @@
|
||||
"app.agent.middleware.plan -> app.agent.tools.tags",
|
||||
"app.agent.middleware.policy -> app.agent",
|
||||
"app.agent.middleware.policy -> app.agent.policy",
|
||||
"app.agent.middleware.policy -> app.agent.policy.api",
|
||||
"app.agent.middleware.policy -> app.agent.policy.contracts",
|
||||
"app.agent.middleware.policy -> app.agent.policy.orchestrator",
|
||||
"app.agent.middleware.policy -> app.agent.policy.registry",
|
||||
@@ -1500,7 +1501,9 @@
|
||||
"app.agent.middleware.policy -> app.agent.tools.catalog",
|
||||
"app.agent.middleware.policy -> app.agent.tools.impl",
|
||||
"app.agent.middleware.policy -> app.agent.tools.impl.api",
|
||||
"app.agent.middleware.policy -> app.agent.tools.impl.mcp",
|
||||
"app.agent.middleware.policy -> app.agent.tools.result",
|
||||
"app.agent.middleware.policy -> app.agent.tools.tags",
|
||||
"app.agent.middleware.selection -> app.agent",
|
||||
"app.agent.middleware.selection -> app.agent.llm",
|
||||
"app.agent.middleware.selection -> app.agent.llm.helper",
|
||||
|
||||
185
tests/test_agent_subagent_permissions.py
Normal file
185
tests/test_agent_subagent_permissions.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""以实际执行边界验证只读子代理不会获得父任务的写权限。"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from app.agent.mcp import AgentMcpToolSpec
|
||||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||||
from app.agent.middleware.subagents import _builtin_subagent_profiles, _SubAgentAgentProvider
|
||||
from app.agent.policy.api import API_OPERATION_SPECS
|
||||
from app.agent.policy.contracts import ActionEffect, AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.mcp import McpExternalTool
|
||||
from app.schemas.agent import AgentMcpServerConfig
|
||||
|
||||
|
||||
class _Model(FakeMessagesListChatModel):
|
||||
"""脚本模型只用于向生产子图提交不应执行的动作,不作为智能评分。"""
|
||||
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
"""保留真实工具执行图,模型响应完全离线。"""
|
||||
return self
|
||||
|
||||
|
||||
def _context(admin=True, origin=ToolOrigin.SUBAGENT):
|
||||
"""创建不可由工具参数改写的宿主身份。"""
|
||||
return ToolPolicyContext(
|
||||
session_id="readonly", user_id="owner", origin=origin,
|
||||
principal_type=PrincipalType.SUBAGENT if origin is ToolOrigin.SUBAGENT else PrincipalType.HUMAN,
|
||||
auth_source=AuthSource.INTERNAL, agent_context={"is_admin": admin},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("admin", [False, True])
|
||||
@pytest.mark.parametrize("operation_id", [
|
||||
spec.operation_id for spec in API_OPERATION_SPECS
|
||||
if spec.effect not in {ActionEffect.SAFE_READ, ActionEffect.SENSITIVE_READ}
|
||||
])
|
||||
async def test_every_write_operation_is_denied_before_handler(admin, operation_id):
|
||||
"""全部登记的副作用操作均在 handler 前拒绝,普通角色也不能绕过。"""
|
||||
gateway = MoviePilotApiTool(session_id="readonly", user_id="owner")
|
||||
handler = AsyncMock(return_value="unexpected write")
|
||||
middleware = AgentPolicyMiddleware(context=_context(admin))
|
||||
allowed, result = await middleware.execute_tool_call(
|
||||
tool=gateway, arguments={"operation_id": operation_id}, handler=handler,
|
||||
invocation_id="write", enforce_decision=False,
|
||||
)
|
||||
assert allowed is False
|
||||
assert result.status == "error"
|
||||
assert json.loads(result.content)["error"] == "subagent_read_only"
|
||||
handler.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_child_graph_denies_write_then_executes_valid_read():
|
||||
"""真实默认子图必须零写入,并保留后续正常查询能力。"""
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = '{"success":true,"data":[]}'
|
||||
gateway = MoviePilotApiTool(session_id="readonly", user_id="owner", executor=executor)
|
||||
gateway.set_agent_context({"is_admin": True})
|
||||
model = _Model(responses=[
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"id": "write", "name": "moviepilot_api", "args": {
|
||||
"operation_id": "config.system.update", "body": {"setting_key": "LLM_MAX_ITERATIONS", "value": 128},
|
||||
},
|
||||
}]),
|
||||
AIMessage(content="", tool_calls=[{
|
||||
"id": "read", "name": "moviepilot_api", "args": {"operation_id": "site.list"},
|
||||
}]),
|
||||
AIMessage(content="已读取站点状态,未修改设置"),
|
||||
])
|
||||
provider = _SubAgentAgentProvider(model=model, profiles=_builtin_subagent_profiles(), tools=[gateway])
|
||||
result = await provider.run_task(description="只读检查站点", subagent_type="general-purpose")
|
||||
assert result == "已读取站点状态,未修改设置"
|
||||
assert [call.args[0] for call in executor.execute.await_args_list] == ["site.list"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("show_secrets", [True, "true", "1", 1])
|
||||
async def test_child_secret_read_uses_canonical_query_semantics(show_secrets):
|
||||
"""不能利用布尔字符串或数字的 HTTP 转换请求原始敏感设置。"""
|
||||
gateway = MoviePilotApiTool(session_id="readonly", user_id="owner")
|
||||
handler = AsyncMock()
|
||||
allowed, result = await AgentPolicyMiddleware(context=_context()).execute_tool_call(
|
||||
tool=gateway, arguments={"operation_id": "config.system.get", "query": {"show_secrets": show_secrets}},
|
||||
handler=handler, invocation_id="secret", enforce_decision=False,
|
||||
)
|
||||
assert allowed is False
|
||||
assert result.status == "error"
|
||||
handler.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_compatibility_read_tag_cannot_authorize_child_operation():
|
||||
"""外部 MCP 的固定 Read 标签没有动作级语义,不能给只读子代理授权。"""
|
||||
spec = AgentMcpToolSpec(
|
||||
server=AgentMcpServerConfig(id="remote", name="Remote", transport="stdio", command="unused"),
|
||||
name="erase", agent_tool_name="remote_erase", description="External operation",
|
||||
input_schema={"type": "object"},
|
||||
)
|
||||
tool = McpExternalTool(spec, session_id="readonly", user_id="owner")
|
||||
handler = AsyncMock()
|
||||
allowed, result = await AgentPolicyMiddleware(context=_context()).execute_tool_call(
|
||||
tool=tool, arguments={}, handler=handler, invocation_id="mcp", enforce_decision=False,
|
||||
)
|
||||
assert allowed is False and result.status == "error"
|
||||
handler.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("name,action,allowed", [
|
||||
("execute_command", action, action in {"read", "wait"})
|
||||
for action in ("start", "run", "read", "wait", "write", "kill")
|
||||
] + [
|
||||
("browse_webpage", action, action in {"snapshot", "get_content", "screenshot", "wait", "list_tabs"})
|
||||
for action in ("goto", "snapshot", "get_content", "screenshot", "click", "click_ref", "fill", "fill_ref",
|
||||
"select", "select_ref", "evaluate", "wait", "list_tabs", "open_tab", "focus_tab", "close_tab", "close_session")
|
||||
] + [("persona", action, action == "list") for action in ("list", "switch", "update")]
|
||||
+ [("agent_task", action, action == "list") for action in ("list", "create", "update", "run", "delete")])
|
||||
async def test_mixed_tools_are_checked_by_action_despite_read_tag(name, action, allowed):
|
||||
"""终端和浏览器的 Read 标签不能给输入、脚本、导航或关闭动作授权。"""
|
||||
tool = SimpleNamespace(name=name, tags=["read"])
|
||||
handler = AsyncMock(return_value="read result")
|
||||
permitted, result = await AgentPolicyMiddleware(context=_context()).execute_tool_call(
|
||||
tool=tool, arguments={"action": action}, handler=handler,
|
||||
invocation_id="mixed", enforce_decision=False,
|
||||
)
|
||||
assert permitted is allowed
|
||||
assert handler.await_count == int(allowed)
|
||||
if not allowed:
|
||||
assert result.status == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("extra", [{"cookies": "session=private"}, {"user_agent": "changed"}])
|
||||
async def test_browser_read_cannot_change_shared_session_identity(extra):
|
||||
"""截图等读取动作也不能顺带替换共享浏览器的 Cookie 或身份参数。"""
|
||||
handler = AsyncMock()
|
||||
permitted, _ = await AgentPolicyMiddleware(context=_context()).execute_tool_call(
|
||||
tool=SimpleNamespace(name="browse_webpage", tags=["read"]),
|
||||
arguments={"action": "snapshot", **extra}, handler=handler,
|
||||
invocation_id="browser", enforce_decision=False,
|
||||
)
|
||||
assert permitted is False
|
||||
handler.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("action", [None, {}, [], 1])
|
||||
async def test_invalid_mixed_action_is_denied_without_interrupting_graph(action):
|
||||
"""不合法的动作类型返回可恢复拒绝,不让集合查询异常打断子任务。"""
|
||||
handler = AsyncMock()
|
||||
permitted, result = await AgentPolicyMiddleware(context=_context()).execute_tool_call(
|
||||
tool=SimpleNamespace(name="execute_command", tags=["read"]),
|
||||
arguments={"action": action}, handler=handler,
|
||||
invocation_id="invalid", enforce_decision=False,
|
||||
)
|
||||
assert permitted is False and result.status == "error"
|
||||
handler.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parent_graph_keeps_authorized_api_write_path():
|
||||
"""只读约束限定子代理,主代理已有写入路径继续实际执行。"""
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = '{"success":true}'
|
||||
gateway = MoviePilotApiTool(session_id="readonly", user_id="owner", executor=executor)
|
||||
gateway.set_agent_context({"is_admin": True})
|
||||
model = _Model(responses=[AIMessage(content="", tool_calls=[{
|
||||
"id": "write", "name": "moviepilot_api", "args": {
|
||||
"operation_id": "config.system.update", "body": {"setting_key": "LLM_MAX_ITERATIONS", "value": 128},
|
||||
},
|
||||
}]), AIMessage(content="已执行")])
|
||||
graph = create_agent(model=model, tools=[gateway], middleware=[
|
||||
AgentPolicyMiddleware(context=_context(origin=ToolOrigin.AGENT_INTERACTIVE), tools=[gateway]),
|
||||
])
|
||||
result = await graph.ainvoke({"messages": [HumanMessage(content="修改此设置")]})
|
||||
assert executor.execute.await_count == 1
|
||||
assert any(isinstance(message, ToolMessage) and message.status == "success" for message in result["messages"])
|
||||
Reference in New Issue
Block a user