diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 3305f5df8..9d578710c 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -907,6 +907,20 @@ class MoviePilotAgent: return delivered is not False return await self._deliver_private_channel_message(content) + async def _deliver_protected_output_with_fallback( + self, + content: str, + fallback_message: str, + ) -> bool: + """受保护投递失败时,仅通过普通回复报告不含敏感值的状态。""" + delivered = await self._deliver_protected_output(content) + if delivered: + return True + self._emit_output(fallback_message) + if self.should_dispatch_reply: + await self.send_agent_message(fallback_message) + return False + async def _handle_secret_confirmation_control( self, message: str, @@ -955,7 +969,7 @@ class MoviePilotAgent: tools=[pending.tool], ) try: - _, result = await policy.execute_tool_call( + executed, result = await policy.execute_tool_call( tool=pending.tool, arguments=pending.arguments, invocation_id=f"secret-confirmation-{uuid.uuid4().hex}", @@ -963,15 +977,22 @@ class MoviePilotAgent: ) except Exception: message_text = "敏感设置读取失败,请稍后重试。" - await self._deliver_protected_output(message_text) + await self._deliver_protected_output_with_fallback( + message_text, + message_text, + ) return message_text - delivered = await self._deliver_protected_output(result) + fallback_message = ( + "敏感设置读取已完成,但结果投递失败,请重新发起。" + if executed + else result + ) + delivered = await self._deliver_protected_output_with_fallback( + result, + fallback_message, + ) if not delivered: - message_text = "敏感设置读取已完成,但结果投递失败,请重新发起。" - self._emit_output(message_text) - if self.should_dispatch_reply: - await self.send_agent_message(message_text) - return message_text + return fallback_message return "敏感设置确认已处理。" def _build_policy_context(self) -> ToolPolicyContext: diff --git a/app/agent/middleware/policy.py b/app/agent/middleware/policy.py index c74c587ea..e140b551a 100644 --- a/app/agent/middleware/policy.py +++ b/app/agent/middleware/policy.py @@ -18,6 +18,7 @@ from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。" +POLICY_UNAVAILABLE_MESSAGE = "宿主策略暂时不可用,未执行该工具。" class AgentPolicyMiddleware(AgentMiddleware): @@ -135,9 +136,10 @@ class AgentPolicyMiddleware(AgentMiddleware): arguments=arguments, invocation_id=invocation_id, ) + if enforce_decision and observation is None: + return False, POLICY_UNAVAILABLE_MESSAGE if ( enforce_decision - and observation is not None and observation.decision.allowed is False ): return False, POLICY_DENIED_MESSAGE @@ -162,4 +164,8 @@ class AgentPolicyMiddleware(AgentMiddleware): return True, result -__all__ = ["AgentPolicyMiddleware", "POLICY_DENIED_MESSAGE"] +__all__ = [ + "AgentPolicyMiddleware", + "POLICY_DENIED_MESSAGE", + "POLICY_UNAVAILABLE_MESSAGE", +] diff --git a/tests/test_agent_secret_confirmation.py b/tests/test_agent_secret_confirmation.py index 7b670eaa9..6684bceb6 100644 --- a/tests/test_agent_secret_confirmation.py +++ b/tests/test_agent_secret_confirmation.py @@ -642,6 +642,48 @@ def test_confirm_respects_policy_denial_without_running_tool() -> None: run_tool.assert_not_awaited() +def test_policy_denial_delivery_failure_reports_not_executed() -> None: + """策略拒绝无法私聊送达时,普通提示不得把未执行操作报告为完成。""" + agent = MoviePilotAgent( + session_id="session-secret", + user_id="1", + channel=MessageChannel.Telegram.value, + source="telegram-main", + username="admin", + original_chat_id="group-1", + ) + tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1") + tool.set_agent_context(agent._tool_context) + denied = SimpleNamespace(decision=SimpleNamespace(allowed=False)) + + async def scenario() -> str: + await agent._register_secret_confirmation( + tool, + {"setting_key": "TMDB_API_KEY", "show_secrets": True}, + ) + return await agent.process("确认") + + with ( + patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)), + patch.object( + agent, + "_deliver_private_channel_message", + new=AsyncMock(side_effect=[True, False]), + ), + patch.object(agent, "send_agent_message", new=AsyncMock()) as send_notice, + patch( + "app.agent.middleware.policy.DEFAULT_TOOL_POLICY_ORCHESTRATOR.start", + return_value=denied, + ), + patch.object(QuerySystemSettingsTool, "_run_confirmed", new=AsyncMock()) as run_tool, + ): + result = asyncio.run(scenario()) + + assert result == "当前宿主策略不允许执行该工具。" + send_notice.assert_awaited_once_with(result) + run_tool.assert_not_awaited() + + def test_confirm_records_policy_failure_and_returns_protected_error() -> None: """确认执行异常必须闭合 fail 生命周期,且不把异常交给普通对话。""" protected_output = [] @@ -699,3 +741,44 @@ def test_confirm_records_policy_failure_and_returns_protected_error() -> None: assert protected_output[-1] == result fail.assert_called_once() finish.assert_not_called() + + +def test_execution_failure_delivery_failure_reports_safe_notice() -> None: + """执行和私聊投递同时失败时,普通渠道仍须收到不含敏感信息的提示。""" + agent = MoviePilotAgent( + session_id="session-secret", + user_id="1", + channel=MessageChannel.Telegram.value, + source="telegram-main", + username="admin", + original_chat_id="group-1", + ) + tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1") + tool.set_agent_context(agent._tool_context) + + async def scenario() -> str: + await agent._register_secret_confirmation( + tool, + {"setting_key": "TMDB_API_KEY", "show_secrets": True}, + ) + return await agent.process("确认") + + with ( + patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)), + patch.object( + agent, + "_deliver_private_channel_message", + new=AsyncMock(side_effect=[True, False]), + ), + patch.object(agent, "send_agent_message", new=AsyncMock()) as send_notice, + patch.object( + QuerySystemSettingsTool, + "_run_confirmed", + new=AsyncMock(side_effect=RuntimeError("secret-bearing-error")), + ), + ): + result = asyncio.run(scenario()) + + assert result == "敏感设置读取失败,请稍后重试。" + send_notice.assert_awaited_once_with(result) + assert "secret-bearing-error" not in send_notice.await_args.args[0] diff --git a/tests/test_agent_tool_policy.py b/tests/test_agent_tool_policy.py index afc2fd597..fecc2256e 100644 --- a/tests/test_agent_tool_policy.py +++ b/tests/test_agent_tool_policy.py @@ -453,6 +453,31 @@ def test_middleware_keeps_shadow_observation_until_strict_runtime_takeover() -> orchestrator.fail.assert_not_called() +def test_enforced_policy_start_failure_blocks_handler() -> None: + """强制策略无法形成决定时不得继续执行受保护工具。""" + orchestrator = MagicMock() + orchestrator.start.side_effect = RuntimeError("policy-start-failure") + middleware = AgentPolicyMiddleware( + context=_interactive_context(), + orchestrator=orchestrator, + ) + tool = _EchoTool(session_id="session-1", user_id="user-1") + handler = AsyncMock(return_value="secret-result") + + executed, result = asyncio.run( + middleware.execute_tool_call( + tool=tool, + arguments={"query": "secret"}, + invocation_id="strict-call-1", + handler=handler, + ) + ) + + assert executed is False + assert result == "宿主策略暂时不可用,未执行该工具。" + handler.assert_not_awaited() + + def test_policy_hook_failure_logs_only_stable_type_information() -> None: """fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。""" mock_logger = MagicMock()