fix(agent): enforce read-only subagent tool actions

This commit is contained in:
jxxghp
2026-09-09 21:03:48 +08:00
parent 54a7367462
commit deff3795b4
7 changed files with 245 additions and 5 deletions

View File

@@ -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",

View File

@@ -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.
"""

View File

@@ -51,6 +51,8 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
## 子代理连续工作
默认子代理在宿主执行边界强制只读。多功能 API 网关按具体 `operation_id` 判断副作用,继承管理员身份也不能修改、删除、触发下载或服务操作;敏感设置原值读取同样不开放。终端仅允许读取/等待已有输出;浏览器仅允许观察已有页面,不允许导航、输入、点击、脚本、关闭或修改 Cookie。外部 MCP 的兼容 Read 标签没有动作级保证,需交由主代理处理。正常业务查询和声明只读的本地工具仍可使用。
一轮结束会回收未完成的子任务,正常收敛后同一会话下一轮仍可委派。会话关闭时永久停止接收子任务;无法在等待上限内收敛的任务仍由原 owner 持有并封闭新提交。
子代理沿用主 Agent 的最终请求预算和上下文压缩机制。长调查可以压缩旧消息;最新单条工具事务或固定提示本身超过模型窗口时仍会明确失败,不会静默删除关键内容。

View File

@@ -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) |

View File

@@ -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 等基线均为零 |

View File

@@ -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",

View 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"])