mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix(agent): harden secret confirmation failures (#6287)
This commit is contained in:
+29
-8
@@ -907,6 +907,20 @@ class MoviePilotAgent:
|
|||||||
return delivered is not False
|
return delivered is not False
|
||||||
return await self._deliver_private_channel_message(content)
|
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(
|
async def _handle_secret_confirmation_control(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -955,7 +969,7 @@ class MoviePilotAgent:
|
|||||||
tools=[pending.tool],
|
tools=[pending.tool],
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_, result = await policy.execute_tool_call(
|
executed, result = await policy.execute_tool_call(
|
||||||
tool=pending.tool,
|
tool=pending.tool,
|
||||||
arguments=pending.arguments,
|
arguments=pending.arguments,
|
||||||
invocation_id=f"secret-confirmation-{uuid.uuid4().hex}",
|
invocation_id=f"secret-confirmation-{uuid.uuid4().hex}",
|
||||||
@@ -963,15 +977,22 @@ class MoviePilotAgent:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
message_text = "敏感设置读取失败,请稍后重试。"
|
message_text = "敏感设置读取失败,请稍后重试。"
|
||||||
await self._deliver_protected_output(message_text)
|
await self._deliver_protected_output_with_fallback(
|
||||||
|
message_text,
|
||||||
|
message_text,
|
||||||
|
)
|
||||||
return 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:
|
if not delivered:
|
||||||
message_text = "敏感设置读取已完成,但结果投递失败,请重新发起。"
|
return fallback_message
|
||||||
self._emit_output(message_text)
|
|
||||||
if self.should_dispatch_reply:
|
|
||||||
await self.send_agent_message(message_text)
|
|
||||||
return message_text
|
|
||||||
return "敏感设置确认已处理。"
|
return "敏感设置确认已处理。"
|
||||||
|
|
||||||
def _build_policy_context(self) -> ToolPolicyContext:
|
def _build_policy_context(self) -> ToolPolicyContext:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
|||||||
|
|
||||||
|
|
||||||
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
|
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
|
||||||
|
POLICY_UNAVAILABLE_MESSAGE = "宿主策略暂时不可用,未执行该工具。"
|
||||||
|
|
||||||
|
|
||||||
class AgentPolicyMiddleware(AgentMiddleware):
|
class AgentPolicyMiddleware(AgentMiddleware):
|
||||||
@@ -135,9 +136,10 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
|||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
invocation_id=invocation_id,
|
invocation_id=invocation_id,
|
||||||
)
|
)
|
||||||
|
if enforce_decision and observation is None:
|
||||||
|
return False, POLICY_UNAVAILABLE_MESSAGE
|
||||||
if (
|
if (
|
||||||
enforce_decision
|
enforce_decision
|
||||||
and observation is not None
|
|
||||||
and observation.decision.allowed is False
|
and observation.decision.allowed is False
|
||||||
):
|
):
|
||||||
return False, POLICY_DENIED_MESSAGE
|
return False, POLICY_DENIED_MESSAGE
|
||||||
@@ -162,4 +164,8 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
|||||||
return True, result
|
return True, result
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["AgentPolicyMiddleware", "POLICY_DENIED_MESSAGE"]
|
__all__ = [
|
||||||
|
"AgentPolicyMiddleware",
|
||||||
|
"POLICY_DENIED_MESSAGE",
|
||||||
|
"POLICY_UNAVAILABLE_MESSAGE",
|
||||||
|
]
|
||||||
|
|||||||
@@ -642,6 +642,48 @@ def test_confirm_respects_policy_denial_without_running_tool() -> None:
|
|||||||
run_tool.assert_not_awaited()
|
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:
|
def test_confirm_records_policy_failure_and_returns_protected_error() -> None:
|
||||||
"""确认执行异常必须闭合 fail 生命周期,且不把异常交给普通对话。"""
|
"""确认执行异常必须闭合 fail 生命周期,且不把异常交给普通对话。"""
|
||||||
protected_output = []
|
protected_output = []
|
||||||
@@ -699,3 +741,44 @@ def test_confirm_records_policy_failure_and_returns_protected_error() -> None:
|
|||||||
assert protected_output[-1] == result
|
assert protected_output[-1] == result
|
||||||
fail.assert_called_once()
|
fail.assert_called_once()
|
||||||
finish.assert_not_called()
|
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]
|
||||||
|
|||||||
@@ -453,6 +453,31 @@ def test_middleware_keeps_shadow_observation_until_strict_runtime_takeover() ->
|
|||||||
orchestrator.fail.assert_not_called()
|
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:
|
def test_policy_hook_failure_logs_only_stable_type_information() -> None:
|
||||||
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
|
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
|
||||||
mock_logger = MagicMock()
|
mock_logger = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user