Merge remote-tracking branch 'origin/v3' into v3

This commit is contained in:
jxxghp
2026-08-12 10:53:15 +08:00
23 changed files with 4951 additions and 102 deletions

View File

@@ -2,7 +2,7 @@ import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
@@ -418,6 +418,67 @@ def test_activity_log_tool_call_records_streaming_summary(tmp_path):
]
def test_activity_log_middleware_sanitizes_its_own_logs(tmp_path):
"""活动日志中间件读取参数和异常写日志时必须脱敏。"""
async def _run_test():
secret_marker = "activity-secret-marker-6825"
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=MagicMock(),
)
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME),
tool_call={"args": {"keyword": f"token={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with patch("app.agent.middleware.activity_log.logger", mock_logger):
try:
await middleware.awrap_tool_call(request, _failing_handler)
except RuntimeError:
pass
else:
raise AssertionError("middleware should re-raise handler errors")
return secret_marker, mock_logger
secret_marker, mock_logger = asyncio.run(_run_test())
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
def test_activity_log_provider_error_does_not_echo_secret(tmp_path):
"""活动日志 provider 内部异常不能进入日志或模型错误结果。"""
secret_marker = "activity-provider-secret-3584"
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
mock_logger = MagicMock()
with (
patch(
"app.agent.middleware.activity_log.query_activity_logs",
side_effect=RuntimeError(f"OPENAI_API_KEY={secret_marker}"),
),
patch("app.agent.middleware.activity_log.logger", mock_logger),
):
result = asyncio.run(
middleware._tool_provider.query_activity_log(keyword="visible")
)
assert secret_marker not in result
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in result
assert "***" in str(mock_logger.method_calls)
def test_factory_does_not_register_activity_log_tool():
"""活动日志查询工具应由中间件注册,不应进入全局工具工厂。"""
with patch(

View File

@@ -431,6 +431,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
"AgentPolicyMiddleware",
"skills",
"jobs",
"runtime",
@@ -546,6 +547,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
"AgentPolicyMiddleware",
"skills",
"jobs",
"runtime",
@@ -749,6 +751,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
"AgentPolicyMiddleware",
"skills",
"jobs",
"runtime",

View File

@@ -1,5 +1,6 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from anyio import Path as AsyncPath
@@ -171,3 +172,59 @@ async def test_skill_tool_call_records_streaming_summary(tmp_path):
},
}
]
@pytest.mark.anyio
async def test_skill_middleware_sanitizes_its_own_logs(tmp_path):
"""Skill 中间件读取参数和异常写日志时必须脱敏。"""
secret_marker = "skill-secret-marker-2471"
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=MagicMock(),
)
middleware = SkillsMiddleware(
sources=[str(tmp_path)],
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SKILL_TOOL_NAME),
tool_call={"args": {"name": f"api_key={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with (
patch("app.agent.middleware.skills.logger", mock_logger),
pytest.raises(RuntimeError),
):
await middleware.awrap_tool_call(request, _failing_handler)
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
@pytest.mark.anyio
async def test_skill_provider_error_does_not_echo_secret(tmp_path):
"""Skill provider 内部捕获的异常不能进入日志或模型错误结果。"""
secret_marker = "skill-provider-secret-6518"
middleware = SkillsMiddleware(sources=[str(tmp_path)])
mock_logger = MagicMock()
with (
patch.object(
middleware._skill_provider,
"_find_skill",
new=AsyncMock(
side_effect=RuntimeError(f"DATABASE_PASSWORD={secret_marker}")
),
),
patch("app.agent.middleware.skills.logger", mock_logger),
):
result = await middleware._skill_provider.load_skill("visible-skill")
assert secret_marker not in result
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in result
assert "***" in str(mock_logger.method_calls)

View File

@@ -2,11 +2,12 @@ import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from langchain_core.language_models.fake_chat_models import FakeListChatModel
import app.agent.middleware.subagents as subagent_module
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.middleware.subagents import (
MoviePilotSubAgentMiddleware,
SUBAGENT_CONTROL_TOOL_NAME,
@@ -14,6 +15,7 @@ from app.agent.middleware.subagents import (
SubAgentTaskControlMiddleware,
create_subagent_middlewares,
)
from app.agent.policy import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
from app.agent.tools.tags import ToolTag
@@ -77,6 +79,40 @@ def test_subagent_tools_are_selected_by_tags():
assert [tool.name for tool in captured["tools"]] == ["custom_media_lookup"]
def test_subagent_graph_registers_policy_middleware_as_outermost():
"""懒加载的子代理图必须继承宿主上下文并先经过 policy middleware。"""
model = FakeListChatModel(responses=["ok"])
context = ToolPolicyContext(
session_id="subagent-session",
user_id="user-1",
origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT,
auth_source=AuthSource.INTERNAL,
agent_context={"is_admin": True},
channel="Telegram",
source="telegram",
)
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return kwargs
middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=[],
policy_context=context,
)
with patch.object(subagent_module, "create_agent", side_effect=_fake_create_agent):
middleware._get_agent("general-purpose")
assert isinstance(captured["middleware"][0], AgentPolicyMiddleware)
assert captured["middleware"][0].context is context
assert captured["middleware"][0].context.origin is ToolOrigin.SUBAGENT
def test_moviepilot_explorer_selects_code_and_settings_tools():
"""MoviePilot 探索子代理应能读取代码、目录、设置和命令诊断工具。"""
model = FakeListChatModel(responses=["ok"])
@@ -187,6 +223,51 @@ def test_task_tool_call_records_streaming_summary():
]
def test_task_middleware_sanitizes_its_own_logs():
"""子代理中间件读取任务参数和异常写日志时必须脱敏。"""
async def _run_test():
secret_marker = "subagent-secret-marker-7316"
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=MagicMock(),
)
middleware = MoviePilotSubAgentMiddleware(
model=FakeListChatModel(responses=["ok"]),
profiles=subagent_module._builtin_subagent_profiles(),
tools=[],
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SUBAGENT_TASK_TOOL_NAME),
tool_call={
"args": {
"description": f"password={secret_marker}",
"subagent_type": "media-researcher",
}
},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with patch.object(subagent_module, "logger", mock_logger):
try:
await middleware.awrap_tool_call(request, _failing_handler)
except RuntimeError:
pass
else:
raise AssertionError("middleware should re-raise handler errors")
return secret_marker, mock_logger
secret_marker, mock_logger = asyncio.run(_run_test())
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
def test_control_tool_call_records_streaming_summary():
"""subagent_task 子代理工具执行时应记录流式聚合摘要。"""
@@ -392,11 +473,14 @@ def test_control_tool_pipeline_stops_after_failed_step():
tools=[],
)
calls = []
secret_marker = "subagent-runtime-secret-9042"
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
calls.append(subagent_type)
if subagent_type == "download-diagnostician":
raise RuntimeError("下载器不可用")
raise RuntimeError(
f"下载器不可用 DATABASE_PASSWORD={secret_marker}"
)
return f"{subagent_type}:ok"
with patch.object(
@@ -433,6 +517,10 @@ def test_control_tool_pipeline_stops_after_failed_step():
"failed",
]
assert "下载器不可用" in payload["tasks"][1]["error"]
assert secret_marker not in payload["error"]
assert secret_marker not in payload["tasks"][1]["error"]
assert "***" in payload["error"]
assert "***" in payload["tasks"][1]["error"]
asyncio.run(_run_test())

View File

@@ -3,6 +3,8 @@ import json
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.agent.tools.impl._system_setting_utils import list_setting_specs
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
@@ -295,3 +297,44 @@ class TestAgentSystemSettingsTools(unittest.TestCase):
payload = json.loads(result)
self.assertIn("error", payload)
self.assertIn("系统管理员", payload["error"])
@pytest.mark.parametrize(
("setting_key", "should_redact"),
[
("API_TOKEN", True),
("LLM_API_KEY", True),
("COOKIECLOUD_KEY", True),
("COOKIECLOUD_AUTH_HEADER", True),
("SUPERUSER_PASSWORD", True),
("DB_POSTGRESQL_PASSWORD", True),
("GITHUB_TOKEN", True),
("FEISHU_VERIFICATION_TOKEN", True),
("SECRET_KEY", True),
("RESOURCE_SECRET_KEY", True),
("PROJECT_NAME", False),
("ACCESS_TOKEN_EXPIRE_MINUTES", False),
("LLM_MAX_CONTEXT_TOKENS", False),
("COOKIECLOUD_INTERVAL", False),
],
)
def test_query_system_settings_uses_precise_secret_identity_matrix(
setting_key: str,
should_redact: bool,
) -> None:
"""设置查询应隐藏真实凭据,同时保留仅名称相似的普通设置。"""
marker = "credential-marker" if should_redact else "visible-marker"
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
with patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=marker,
):
payload = json.loads(asyncio.run(tool.run(setting_key=setting_key)))
item = payload["settings"][0]
assert item["redacted"] is should_redact
assert item["value"] == ("***" if should_redact else marker)
if should_redact:
assert marker not in json.dumps(payload)

View File

@@ -0,0 +1,744 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain.agents.middleware import SummarizationMiddleware
from langchain_core.messages import ToolMessage
from pydantic import BaseModel, Field
import app.agent as agent_module
from app.agent.middleware.activity_log import ActivityLogMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.policy import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
DEFAULT_TOOL_POLICY_REGISTRY,
ActionEffect,
AuthSource,
MigrationState,
PolicyPrincipal,
PrincipalRole,
PrincipalType,
ResultSensitivity,
ToolOrigin,
ToolPolicyContext,
call_policy_hook,
)
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.schemas.types import MessageChannel
class _EchoInput(BaseModel):
"""策略测试工具的输入契约。"""
query: str = Field(description="待回显文本")
class _EchoTool(MoviePilotTool):
"""返回输入文本的测试工具。"""
name: str = "policy_echo"
description: str = "Echo policy test input."
args_schema: type[BaseModel] = _EchoInput
async def run(self, query: str) -> str:
"""返回输入文本。"""
return query
class _OverrideArunTool(_EchoTool):
"""模拟插件覆盖宿主工具基类 `_arun` 的实现。"""
name: str = "plugin_override_arun"
def __init__(self, events: list[str], **kwargs):
super().__init__(**kwargs)
self._events = events
async def _arun(self, *args, **kwargs) -> str:
"""绕过基类实现并记录真实执行顺序。"""
self._events.append("tool")
return str(kwargs.get("query") or "ok")
class _FailingTool(_EchoTool):
"""抛出固定异常以验证 direct manager 的 observation fail-open。"""
name: str = "policy_failure"
async def run(self, query: str) -> str:
"""模拟真实工具失败。"""
raise ValueError(f"tool-error:{query}")
class _AdminSafeReadTool(_EchoTool):
"""复用 safe-read 名称并保留旧 require_admin 门禁的测试工具。"""
name: str = "list_slash_commands"
require_admin: bool = True
def __init__(self, events: list[str], **kwargs):
super().__init__(**kwargs)
self._events = events
async def run(self, query: str) -> str:
"""记录实际执行,区分旧门禁放行与拒绝。"""
self._events.append("run")
return query
def _tool_class_name(tool_class: type[MoviePilotTool]) -> str:
"""从 Pydantic 字段默认值读取工具类的稳定名称。"""
return str(tool_class.model_fields["name"].default)
def _interactive_context(*, is_admin: bool = True) -> ToolPolicyContext:
"""构造会随本轮管理员上下文刷新的交互式策略上下文。"""
return ToolPolicyContext(
session_id="session-1",
user_id="user-1",
origin=ToolOrigin.AGENT_INTERACTIVE,
principal_type=PrincipalType.HUMAN,
auth_source=AuthSource.CHANNEL,
channel="telegram",
source="user",
agent_context={"is_admin": is_admin},
)
def test_builtin_policy_registry_covers_every_fixed_tool() -> None:
"""固定内置工具必须全部具有显式 migration registry 条目。"""
fixed_tool_names = {
_tool_class_name(tool_class)
for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES
}
fixed_tool_names.update(
{
_tool_class_name(AskUserChoiceTool),
_tool_class_name(SendLocalFileTool),
_tool_class_name(SendVoiceMessageTool),
}
)
assert DEFAULT_TOOL_POLICY_REGISTRY.builtin_tool_names == fixed_tool_names
def test_registry_separates_safe_read_from_legacy_shadow() -> None:
"""少量安全读取可直接迁移,其余高影响工具保持 shadow allow。"""
safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="query_personas",
arguments={},
requires_admin=False,
)
admin_safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="list_slash_commands",
arguments={},
requires_admin=True,
)
shadow_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="update_system_settings",
arguments={"updates": []},
requires_admin=True,
)
dynamic_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="plugin_unknown_tool",
arguments={"action": "custom"},
requires_admin=False,
)
assert safe_policy.effect is ActionEffect.SAFE_READ
assert safe_policy.result_sensitivity is ResultSensitivity.NORMAL
assert safe_policy.migration_state is MigrationState.ENFORCED
assert admin_safe_policy.effect is ActionEffect.SAFE_READ
assert admin_safe_policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert admin_safe_policy.migration_state is MigrationState.LEGACY_SHADOW
assert shadow_policy.migration_state is MigrationState.LEGACY_SHADOW
assert shadow_policy.effect is ActionEffect.UNKNOWN
assert shadow_policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert dynamic_policy.migration_state is MigrationState.LEGACY_SHADOW
assert dynamic_policy.effect is ActionEffect.UNKNOWN
assert dynamic_policy.result_sensitivity is ResultSensitivity.UNKNOWN
def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None:
"""G1 的 shadow 决策只能观测,不能拒绝或要求确认。"""
context = _interactive_context()
tool = _EchoTool(session_id="session-1", user_id="user-1")
observation = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start(
context=context,
tool=tool,
arguments={"query": "hello"},
)
assert observation.decision.allowed is True
assert observation.decision.shadow is True
assert observation.decision.reason_code == "legacy_shadow_allow"
def test_policy_context_reads_mutable_admin_state_without_model_fields() -> None:
"""缓存图复用时权限取当前宿主状态,模型参数不能伪造 principal。"""
context = _interactive_context(is_admin=False)
forged_arguments = {
"query": "hello",
"origin": "operator_direct",
"principal_type": "system_admin_integration",
"is_admin": True,
}
assert context.principal.role is PrincipalRole.USER
context.agent_context["is_admin"] = True
assert context.principal.role is PrincipalRole.SYSTEM_ADMIN
assert "origin" not in PolicyPrincipal.__dataclass_fields__
assert forged_arguments["origin"] != context.origin.value
def test_policy_context_maps_trusted_host_origins() -> None:
"""各入口必须由宿主稳定映射 origin、主体类型和认证来源。"""
cases = [
(
{"channel": MessageChannel.Web.value, "source": "openai"},
ToolOrigin.AGENT_API,
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
AuthSource.API_TOKEN,
),
(
{"channel": MessageChannel.Web.value, "source": "openai.responses"},
ToolOrigin.AGENT_API,
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
AuthSource.API_TOKEN,
),
(
{"channel": MessageChannel.Web.value, "source": "anthropic"},
ToolOrigin.AGENT_API,
PrincipalType.SYSTEM_ADMIN_INTEGRATION,
AuthSource.API_TOKEN,
),
(
{"channel": MessageChannel.Web.value, "source": "browser"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.WEB_SESSION,
),
(
{"channel": MessageChannel.WebAgent.value, "source": "web-agent"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.WEB_SESSION,
),
(
{"channel": MessageChannel.Telegram.value, "source": "telegram"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.CHANNEL,
),
(
{"channel": MessageChannel.Feishu.value, "source": "feishu"},
ToolOrigin.AGENT_INTERACTIVE,
PrincipalType.HUMAN,
AuthSource.CHANNEL,
),
(
{"channel": None, "source": None, "output_callback": lambda _text: None},
ToolOrigin.BACKGROUND,
PrincipalType.BACKGROUND,
AuthSource.INTERNAL,
),
]
for kwargs, expected_origin, expected_principal, expected_auth_source in cases:
context = agent_module.MoviePilotAgent(
session_id="origin-session",
user_id="user-1",
**kwargs,
)._build_policy_context()
assert context.origin is expected_origin
assert context.principal_type is expected_principal
assert context.auth_source is expected_auth_source
subagent_context = agent_module.MoviePilotAgent(
session_id="subagent-session",
user_id="user-1",
channel=MessageChannel.Telegram.value,
source="telegram",
)._build_policy_context().for_subagent()
assert subagent_context.origin is ToolOrigin.SUBAGENT
assert subagent_context.principal_type is PrincipalType.SUBAGENT
assert subagent_context.auth_source is AuthSource.INTERNAL
def test_host_middleware_observes_plugin_before_overridden_arun() -> None:
"""插件覆盖 `_arun` 时,宿主 middleware 仍必须先执行策略。"""
events: list[str] = []
tool = _OverrideArunTool(
events,
session_id="session-1",
user_id="user-1",
)
middleware = AgentPolicyMiddleware(context=_interactive_context())
request = SimpleNamespace(
tool=tool,
tool_call={
"id": "call-1",
"name": tool.name,
"args": {"query": "ok"},
},
)
original_start = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start
def _record_start(**kwargs):
events.append("policy")
return original_start(**kwargs)
async def _handler(_request):
result = await tool._arun(query="ok")
return ToolMessage(content=result, tool_call_id="call-1")
with patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
side_effect=_record_start,
):
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
assert result.content == "ok"
assert events == ["policy", "tool"]
@pytest.mark.parametrize("failed_phase", ["start", "finish"])
def test_middleware_observation_failure_does_not_replace_success(
failed_phase: str,
) -> None:
"""shadow start/finish 故障不能阻止 handler 或替换成功结果。"""
orchestrator = MagicMock()
orchestrator.start.return_value = object()
getattr(orchestrator, failed_phase).side_effect = RuntimeError(
f"policy-{failed_phase}-failure"
)
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
orchestrator=orchestrator,
)
request = SimpleNamespace(
tool=_EchoTool(session_id="session-1", user_id="user-1"),
tool_call={"id": "call-1", "args": {"query": "same"}},
)
expected = ToolMessage(content="same", tool_call_id="call-1")
handler_called = False
async def _handler(_request):
nonlocal handler_called
handler_called = True
return expected
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
assert handler_called is True
assert result is expected
def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
"""shadow fail hook 故障后仍必须抛出原始工具异常。"""
orchestrator = MagicMock()
orchestrator.start.return_value = object()
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
orchestrator=orchestrator,
)
request = SimpleNamespace(
tool=_EchoTool(session_id="session-1", user_id="user-1"),
tool_call={"id": "call-1", "args": {"query": "same"}},
)
tool_error = ValueError("original-tool-failure")
async def _handler(_request):
raise tool_error
with pytest.raises(ValueError) as error_info:
asyncio.run(middleware.awrap_tool_call(request, _handler))
assert error_info.value is tool_error
def test_policy_hook_failure_logs_only_stable_type_information() -> None:
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
mock_logger = MagicMock()
def _fail() -> None:
raise RuntimeError("DATABASE_PASSWORD=policy-secret-marker")
with patch("app.agent.policy.orchestrator.logger", mock_logger):
result = call_policy_hook("start", _fail)
logged = "\n".join(str(call) for call in mock_logger.warning.call_args_list)
assert result is None
assert "phase=start" in logged
assert "RuntimeError" in logged
assert "policy-secret-marker" not in logged
def test_policy_hook_hostile_error_type_is_fail_open() -> None:
"""异常类型名协议不可信时,观测故障仍不得逃出 fail-open 边界。"""
secret_marker = "hostile-policy-type-secret-6274"
class _HostileMeta(type):
def __getattribute__(cls, name):
if name == "__name__":
raise RuntimeError(f"DATABASE_PASSWORD={secret_marker}")
return super().__getattribute__(name)
class _HostilePolicyError(RuntimeError, metaclass=_HostileMeta):
pass
mock_logger = MagicMock()
def _fail() -> None:
raise _HostilePolicyError("visible policy failure")
escaped = False
with patch("app.agent.policy.orchestrator.logger", mock_logger):
try:
result = call_policy_hook("start", _fail)
except BaseException:
escaped = True
result = None
logged = "\n".join(str(call) for call in mock_logger.warning.call_args_list)
assert escaped is False
assert result is None
assert "phase=start" in logged
assert secret_marker not in logged
@pytest.mark.parametrize(
("legacy_admin", "expected_result", "expected_run_count"),
[
(True, "same", 1),
(
False,
(
"抱歉,您没有执行此工具的权限。"
"只有渠道管理员或系统管理员才能执行工具操作。"
"如需执行工具请联系管理员将您的用户ID添加到渠道管理员列表中设定 -> 通知 -> 对应渠道配置 -> 管理员名单),"
"或联系系统管理员为您设置管理员权限。"
),
0,
),
],
)
def test_agent_admin_safe_read_keeps_legacy_authorization_authority(
legacy_admin: bool,
expected_result: str,
expected_run_count: int,
) -> None:
"""渠道管理员与普通用户结果保持旧门禁语义policy 只做 shadow passthrough。"""
events: list[str] = []
observations = []
original_start = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start
tool = _AdminSafeReadTool(
events,
session_id="session-1",
user_id="user-1",
)
tool.set_message_attr(
channel=MessageChannel.Telegram.value,
source="user",
username="member",
)
tool.set_agent_context({"is_admin": False})
middleware = AgentPolicyMiddleware(context=_interactive_context(is_admin=False))
request = SimpleNamespace(
tool=tool,
tool_call={"id": "call-1", "args": {"query": "same"}},
)
def _capture_start(**kwargs):
observation = original_start(**kwargs)
observations.append(observation)
return observation
async def _handler(_request):
content = await tool._arun(query="same")
return ToolMessage(content=content, tool_call_id="call-1")
with (
patch.object(
_AdminSafeReadTool,
"is_admin_user",
new=AsyncMock(return_value=legacy_admin),
),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
side_effect=_capture_start,
),
):
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
assert result.content == expected_result
assert events.count("run") == expected_run_count
assert len(observations) == 1
assert observations[0].policy.effect is ActionEffect.SAFE_READ
assert observations[0].policy.migration_state is MigrationState.LEGACY_SHADOW
assert observations[0].decision.allowed is True
assert observations[0].decision.shadow is True
assert observations[0].decision.reason_code == "legacy_shadow_allow"
def test_direct_non_admin_safe_read_rejects_before_policy_or_schema() -> None:
"""direct 未授权请求保持原 JSON 拒绝格式且不提前触发 policy/schema。"""
events: list[str] = []
orchestrator = MagicMock()
tool = _AdminSafeReadTool(
events,
session_id="session-1",
user_id="user-1",
)
manager = MoviePilotToolsManager(
is_admin=False,
policy_orchestrator=orchestrator,
)
manager.tools = [tool]
result = json.loads(
asyncio.run(manager.call_tool(tool.name, {"query": "same"}))
)
assert result == {
"error": "抱歉,您没有执行此工具的权限。只有系统管理员才能执行工具操作。"
}
assert events == []
orchestrator.start.assert_not_called()
@pytest.mark.parametrize("failed_phase", ["start", "finish"])
def test_direct_manager_observation_failure_does_not_replace_success(
failed_phase: str,
) -> None:
"""direct manager 的 shadow start/finish 故障不能改写真实返回值。"""
orchestrator = MagicMock()
orchestrator.start.return_value = object()
getattr(orchestrator, failed_phase).side_effect = RuntimeError(
f"policy-{failed_phase}-failure"
)
tool = _EchoTool(session_id="session-1", user_id="user-1")
manager = MoviePilotToolsManager(
is_admin=True,
policy_orchestrator=orchestrator,
)
manager.tools = [tool]
result = asyncio.run(manager.call_tool(tool.name, {"query": "same"}))
assert result == "same"
def test_direct_manager_fail_observation_does_not_mask_tool_error() -> None:
"""direct manager 的 fail hook 故障不能替换既有工具错误格式。"""
orchestrator = MagicMock()
orchestrator.start.return_value = object()
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
tool = _FailingTool(session_id="session-1", user_id="user-1")
manager = MoviePilotToolsManager(
is_admin=True,
policy_orchestrator=orchestrator,
)
manager.tools = [tool]
result = asyncio.run(manager.call_tool(tool.name, {"query": "same"}))
assert "ValueError" in result
assert "tool-error:same" in result
assert "policy-fail-hook-failure" not in result
def test_direct_manager_and_agent_middleware_share_policy_resolution() -> None:
"""相同工具参数在 Agent 与 direct 入口应获得相同动作策略。"""
observations = []
original_start = DEFAULT_TOOL_POLICY_ORCHESTRATOR.start
def _capture_start(**kwargs):
observation = original_start(**kwargs)
observations.append(observation)
return observation
tool = _EchoTool(session_id="session-1", user_id="user-1")
middleware = AgentPolicyMiddleware(context=_interactive_context())
request = SimpleNamespace(
tool=tool,
tool_call={
"id": "call-agent",
"name": tool.name,
"args": {"query": "same"},
},
)
async def _handler(_request):
return ToolMessage(content="same", tool_call_id="call-agent")
manager = MoviePilotToolsManager(
user_id="api-user",
session_id="api-session",
is_admin=True,
policy_orchestrator=DEFAULT_TOOL_POLICY_ORCHESTRATOR,
)
manager.tools = [tool]
async def _run_both():
await middleware.awrap_tool_call(request, _handler)
return await manager.call_tool(tool.name, {"query": "same"})
with patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
side_effect=_capture_start,
):
direct_result = asyncio.run(_run_both())
assert direct_result == "same"
assert [item.invocation.origin for item in observations] == [
ToolOrigin.AGENT_INTERACTIVE,
ToolOrigin.OPERATOR_DIRECT,
]
assert observations[0].policy == observations[1].policy
def test_agent_middleware_secret_setting_result_stays_out_of_receipt_logs() -> None:
"""Agent ToolNode 可接收管理员请求的原值,但策略回执不得记录该值。"""
secret_marker = "middleware-secret-setting-marker"
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
tool.set_agent_context({"is_admin": True})
middleware = AgentPolicyMiddleware(context=_interactive_context())
request = SimpleNamespace(
tool=tool,
tool_call={
"id": "call-secret-setting",
"name": tool.name,
"args": {"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
},
)
mock_logger = MagicMock()
async def _handler(_request):
result = await tool._arun(
setting_key="COOKIECLOUD_KEY",
show_secrets=True,
)
return ToolMessage(content=result, tool_call_id="call-secret-setting")
with (
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=secret_marker,
),
patch("app.agent.policy.orchestrator.logger", mock_logger),
):
result = asyncio.run(middleware.awrap_tool_call(request, _handler))
assert secret_marker in result.content
logged = "\n".join(
str(call)
for call in (
mock_logger.debug.call_args_list + mock_logger.info.call_args_list
)
)
assert secret_marker not in logged
assert '"value": "***"' in logged
assert '"value_preview": "***"' in logged
def test_main_agent_registers_policy_middleware_as_outermost() -> None:
"""主 Agent 必须把宿主策略中间件放在 middleware 链最外层。"""
agent = agent_module.MoviePilotAgent(session_id="session-1", user_id="user-1")
fake_llm = SimpleNamespace(
_llm_type="openai-chat",
model="fake",
profile={"max_input_tokens": 64000},
)
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return object()
with (
patch.object(agent, "_initialize_llm", return_value=fake_llm),
patch.object(agent, "_initialize_tools", return_value=[]),
patch.object(agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"),
patch.object(agent_module, "create_subagent_middlewares", return_value=([], [])),
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
):
asyncio.run(agent._create_agent(streaming=False))
assert isinstance(captured["middleware"][0], AgentPolicyMiddleware)
def test_main_agent_preserves_activity_log_middleware_order() -> None:
"""策略层加入后ActivityLog 仍应位于 Memory 后、摘要前。"""
agent = agent_module.MoviePilotAgent(
session_id="session-1",
user_id="user-1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
)
fake_llm = SimpleNamespace(
_llm_type="openai-chat",
model="fake",
profile={"max_input_tokens": 64000},
)
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return object()
with (
patch.object(agent, "_initialize_llm", return_value=fake_llm),
patch.object(agent, "_initialize_tools", return_value=[]),
patch.object(agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"),
patch.object(agent_module, "create_subagent_middlewares", return_value=([], [])),
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
):
asyncio.run(agent._create_agent(streaming=False))
middlewares = captured["middleware"]
policy_index = next(
index
for index, middleware in enumerate(middlewares)
if isinstance(middleware, AgentPolicyMiddleware)
)
memory_index = next(
index
for index, middleware in enumerate(middlewares)
if isinstance(middleware, MemoryMiddleware)
)
activity_index = next(
index
for index, middleware in enumerate(middlewares)
if isinstance(middleware, ActivityLogMiddleware)
)
summary_index = next(
index
for index, middleware in enumerate(middlewares)
if isinstance(middleware, SummarizationMiddleware)
)
assert policy_index == 0
assert activity_index == memory_index + 1
assert summary_index == activity_index + 1

File diff suppressed because it is too large Load Diff

View File

@@ -149,6 +149,24 @@ class TestAgentToolStreaming:
assert buffered_message == "处理中:\n\n(执行了 2 次搜索,读取了 2 个文件)\n\n继续分析"
def test_tool_summary_does_not_retain_secret_arguments(self):
"""流式聚合状态只能保留递归脱敏后的工具参数。"""
secret_marker = "stream-secret-marker-1947"
handler = StreamingHandler()
handler.record_tool_call(
tool_name="execute_command",
tool_message=f"Authorization: Bearer {secret_marker}",
tool_kwargs={
"command": f"curl -H 'Authorization: Bearer {secret_marker}'",
"headers": {"X-API-Key": secret_marker},
},
)
pending_state = str(handler._pending_tool_stats)
assert secret_marker not in pending_state
assert "***" in pending_state
def test_non_verbose_tool_summary_counts_subagents(self):
"""校验非详细模式统计子代理调用次数。"""
async def _run():