fix(agent): close secret confirmation delivery gaps (#6285)

This commit is contained in:
InfinityPacer
2026-08-13 08:20:14 +08:00
committed by GitHub
parent a2e70b443d
commit 591a1d420d
16 changed files with 672 additions and 81 deletions

View File

@@ -317,7 +317,6 @@ class _PendingSecretConfirmation:
user_id: str
channel: str
source: str
original_chat_id: str
class MoviePilotAgent:
@@ -339,7 +338,7 @@ class MoviePilotAgent:
replay_mode: ReplyMode = ReplyMode.DISPATCH,
allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
):
self.session_id = session_id
self.user_id = user_id
@@ -771,7 +770,7 @@ class MoviePilotAgent:
def set_protected_output_callback(
self,
protected_output_callback: Optional[Callable[[str], None]],
protected_output_callback: Optional[Callable[[str], Optional[bool]]],
) -> None:
"""更新仅供当前请求接收的受保护文本输出回调。"""
self.protected_output_callback = protected_output_callback
@@ -790,7 +789,7 @@ class MoviePilotAgent:
"""判断当前渠道能否把密钥结果直接交付给原用户。"""
if self.channel == MessageChannel.WebAgent.value:
return callable(self.protected_output_callback)
return self.channel in {
return bool(self.user_id and self.source) and self.channel in {
MessageChannel.Telegram.value,
MessageChannel.Feishu.value,
}
@@ -831,15 +830,6 @@ class MoviePilotAgent:
self._pending_secret_confirmation = None
return permission_result
self._pending_secret_confirmation = _PendingSecretConfirmation(
tool=tool,
arguments=validated_arguments,
created_at=datetime.now(),
user_id=str(self.user_id or ""),
channel=str(self.channel or ""),
source=str(self.source or ""),
original_chat_id=str(self.original_chat_id or ""),
)
target = validated_arguments.get("setting_key") or (
validated_arguments.get("group") or "all"
)
@@ -849,34 +839,73 @@ class MoviePilotAgent:
"请在 5 分钟内回复“确认”继续,或回复“取消”放弃。"
)
if self.channel == MessageChannel.WebAgent.value:
self._pending_secret_confirmation = _PendingSecretConfirmation(
tool=tool,
arguments=validated_arguments,
created_at=datetime.now(),
user_id=str(self.user_id or ""),
channel=str(self.channel or ""),
source=str(self.source or ""),
)
self._emit_output(confirmation_message)
else:
await self.send_agent_message(confirmation_message)
delivered = await self._deliver_private_channel_message(
confirmation_message
)
if not delivered:
self._pending_secret_confirmation = None
return "无法向当前用户建立私聊,未执行敏感设置读取。"
self._pending_secret_confirmation = _PendingSecretConfirmation(
tool=tool,
arguments=validated_arguments,
created_at=datetime.now(),
user_id=str(self.user_id or ""),
channel=str(self.channel or ""),
source=str(self.source or ""),
)
self._tool_context["user_reply_sent"] = True
return confirmation_message
async def _deliver_protected_output(self, content: str) -> None:
async def _deliver_private_channel_message(self, content: str) -> bool:
"""按渠道用户身份私聊投递,禁止回退群聊或广播。"""
if self.channel not in {
MessageChannel.Telegram.value,
MessageChannel.Feishu.value,
}:
return False
try:
response = await run_in_threadpool(
AgentChain().send_direct_message,
Notification(
channel=self.channel,
source=self.source,
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
text=content,
private_delivery=True,
parse_mode="plain",
save_history=False,
),
)
except Exception as error:
logger.error(
f"Agent私聊投递失败: channel={self.channel}, "
f"error_type={type(error).__name__}"
)
return False
return bool(response and response.success)
async def _deliver_protected_output(self, content: str) -> bool:
"""绕过模型与会话历史,把敏感结果直接交付给当前用户。"""
if callable(self.protected_output_callback):
try:
self.protected_output_callback(content)
delivered = self.protected_output_callback(content)
except Exception as e:
logger.error(f"受保护输出回调失败: {e}")
return
await AgentChain().async_post_message(
Notification(
channel=self.channel,
source=self.source,
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
original_message_id=self.original_message_id,
original_chat_id=self.original_chat_id,
text=content,
save_history=False,
)
)
return False
return delivered is not False
return await self._deliver_private_channel_message(content)
async def _handle_secret_confirmation_control(
self,
@@ -899,7 +928,6 @@ class MoviePilotAgent:
pending.user_id != str(self.user_id or "")
or pending.channel != str(self.channel or "")
or pending.source != str(self.source or "")
or pending.original_chat_id != str(self.original_chat_id or "")
):
return None
if datetime.now() - pending.created_at > SECRET_CONFIRMATION_TTL:
@@ -913,16 +941,37 @@ class MoviePilotAgent:
await self._deliver_protected_output(message_text)
return message_text
permission_result = await pending.tool._check_permission()
if permission_result:
await self._deliver_protected_output(permission_result)
return permission_result
if not self._can_confirm_secret_read():
return "当前入口不支持安全交付敏感设置,未执行读取。"
result = await pending.tool._run_confirmed(**pending.arguments)
await self._deliver_protected_output(result)
async def _execute_confirmed() -> str:
permission_result = await pending.tool._check_permission()
if permission_result:
return permission_result
return await pending.tool._run_confirmed(**pending.arguments)
policy = AgentPolicyMiddleware(
context=self._build_policy_context(),
tools=[pending.tool],
)
try:
_, result = await policy.execute_tool_call(
tool=pending.tool,
arguments=pending.arguments,
invocation_id=f"secret-confirmation-{uuid.uuid4().hex}",
handler=_execute_confirmed,
)
except Exception:
message_text = "敏感设置读取失败,请稍后重试。"
await self._deliver_protected_output(message_text)
return message_text
delivered = await self._deliver_protected_output(result)
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 "敏感设置确认已处理。"
def _build_policy_context(self) -> ToolPolicyContext:
@@ -2166,7 +2215,7 @@ class _MessageTask:
reply_mode: ReplyMode = ReplyMode.DISPATCH
allow_message_tools: bool = True
output_callback: Optional[Callable[[str], None]] = None
protected_output_callback: Optional[Callable[[str], None]] = None
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None
notification_callback: Optional[Callable[[Any], None]] = None
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
completion_future: Optional[asyncio.Future] = None
@@ -2227,7 +2276,6 @@ class AgentManager:
user_id: str,
channel: Optional[str] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""判断指定用户是否可继续当前会话的敏感设置确认。"""
agent = self.active_agents.get(session_id)
@@ -2238,10 +2286,6 @@ class AgentManager:
and str(agent.user_id) == str(user_id)
and (channel is None or pending.channel == str(channel))
and (source is None or pending.source == str(source))
and (
original_chat_id is None
or pending.original_chat_id == str(original_chat_id)
)
)
async def initialize(self):
@@ -2345,7 +2389,7 @@ class AgentManager:
reply_mode: ReplyMode = ReplyMode.DISPATCH,
allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
notification_callback: Optional[Callable[[Any], None]] = None,
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
wait_for_completion: bool = False,

View File

@@ -17,6 +17,9 @@ from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
class AgentPolicyMiddleware(AgentMiddleware):
"""观测进入本地 ToolNode 的 client-side 工具调用和结果。
@@ -103,16 +106,43 @@ class AgentPolicyMiddleware(AgentMiddleware):
arguments = tool_call.get("args") or {}
if not isinstance(arguments, dict):
arguments = {}
_, result = await self.execute_tool_call(
tool=request.tool,
arguments=arguments,
invocation_id=tool_call.get("id"),
handler=lambda: handler(request),
enforce_decision=False,
)
# 普通 ToolNode 在严格策略接管前保持 shadow 观测语义。
# 已确认的受保护调用会使用默认的强制决策语义。
return result
async def execute_tool_call(
self,
*,
tool: Any,
arguments: dict[str, Any],
handler: Callable[[], Awaitable[Any]],
invocation_id: str | None = None,
enforce_decision: bool = True,
) -> tuple[bool, Any]:
"""执行一次本地工具调用,并复用 ToolNode 的策略生命周期。"""
observation = call_policy_hook(
"start",
self.orchestrator.start,
context=self.context,
tool=request.tool,
tool=tool,
arguments=arguments,
invocation_id=tool_call.get("id"),
invocation_id=invocation_id,
)
if (
enforce_decision
and observation is not None
and observation.decision.allowed is False
):
return False, POLICY_DENIED_MESSAGE
try:
result = await handler(request)
result = await handler()
except Exception as error:
if observation is not None:
call_policy_hook(
@@ -129,7 +159,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
observation,
result,
)
return result
return True, result
__all__ = ["AgentPolicyMiddleware"]
__all__ = ["AgentPolicyMiddleware", "POLICY_DENIED_MESSAGE"]

View File

@@ -21,6 +21,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
<confirmation_policy>
- Do not stop for approval on read-only operations.
- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, call `query_system_settings` with `show_secrets=true`; do not refuse the request solely because the value is sensitive. The host verifies administrator authority, obtains any required confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.

View File

@@ -58,7 +58,10 @@ class QuerySystemSettingsInput(BaseModel):
False,
description=(
"Whether to return raw secret values such as API keys, tokens, cookies, and passwords. "
"Defaults to false; secret-like fields are redacted in returned values and previews."
"Defaults to false; secret-like fields are redacted in returned values and previews. "
"Set this to true when the user explicitly asks for an unredacted secret; the host verifies "
"administrator authority, requests confirmation, and delivers the result outside the ordinary "
"model response. Do not refuse the tool call solely because the requested value is sensitive."
),
)

View File

@@ -84,10 +84,10 @@ class _WebAgentEventPublisher:
"""返回本轮发布器观测到的最大积压深度。"""
return self._max_depth
def publish(self, event: dict) -> None:
"""发布事件;相邻文本会按时间或长度边界合并"""
def publish(self, event: dict) -> bool:
"""发布事件;返回关闭状态以便受保护投递能准确报告失败"""
if self._disposed:
return
return False
if event.get("type") == "delta":
self._pending_delta += str(event.get("content") or "")
if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS:
@@ -98,10 +98,11 @@ class _WebAgentEventPublisher:
WEB_AGENT_STREAM_COALESCE_SECONDS,
self._flush_delta,
)
return
return True
self._flush_delta()
self._append_event(event)
return True
async def get(self) -> dict:
"""等待并返回下一条已排序事件。"""
@@ -1955,7 +1956,6 @@ async def web_agent_stream(
str(current_user.id),
channel=MessageChannel.WebAgent.value,
source=WEB_AGENT_SOURCE,
original_chat_id=str(payload.original_chat_id or ""),
)
)
protected_transport_supported = (
@@ -2177,9 +2177,9 @@ async def web_agent_stream(
_apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item)
def protected_output_callback(content: str) -> None:
def protected_output_callback(content: str) -> bool:
"""将敏感文本封装为不进入普通展示快照的命名 SSE 事件。"""
event_publisher.publish(
return event_publisher.publish(
{
"type": "interaction-protected",
"content": content,

View File

@@ -323,7 +323,6 @@ class MessageChain(ChainBase):
str(userid),
channel=channel.value,
source=source,
original_chat_id=original_chat_id,
):
return False
return self._handle_ai_message(

View File

@@ -59,6 +59,10 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
chat_id = None
receive_id_type = "open_id" if userid else None
# 私聊投递只能按用户身份寻址,原会话 ID 可能属于群聊。
if message.private_delivery and userid:
return userid, None, None
# 回复类消息携带原会话 ID 时,必须发回原会话,
# 否则群聊 @ 机器人的回复会错误地发送到机器人与用户的私聊窗口。
original_chat_id = str(message.original_chat_id or "").strip() or None
@@ -246,6 +250,20 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
elif str(message.parse_mode or "").strip().lower() == "plain":
# 受保护结果必须绕过 Markdown 卡片,避免密钥字符被解释或改写。
plain_text = "\n".join(
part
for part in (message.title, message.text, message.link)
if part
)
result = client.send_text(
text=plain_text,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
else:
result = client.send_notification(
message=message,

View File

@@ -761,6 +761,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
original_chat_id=original_chat_id,
disable_web_page_preview=message.disable_web_page_preview,
parse_mode=message.parse_mode,
private_delivery=message.private_delivery,
)
if result and result.get("success"):
return MessageResponse(

View File

@@ -47,10 +47,13 @@ from app.utils.string import StringUtils # noqa: E402
TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2"
TELEGRAM_PARSE_MODE_HTML = "HTML"
TELEGRAM_PARSE_MODE_PLAIN = ""
TELEGRAM_PARSE_MODE_ALIASES = {
"markdownv2": TELEGRAM_PARSE_MODE_MARKDOWN,
"mdv2": TELEGRAM_PARSE_MODE_MARKDOWN,
"html": TELEGRAM_PARSE_MODE_HTML,
"plain": TELEGRAM_PARSE_MODE_PLAIN,
"text": TELEGRAM_PARSE_MODE_PLAIN,
}
@@ -295,10 +298,13 @@ class Telegram:
@staticmethod
def _normalize_parse_mode(parse_mode: Optional[str] = None) -> str:
"""规范化 Telegram 消息格式类型。"""
if not parse_mode:
if parse_mode is None:
return TELEGRAM_PARSE_MODE_MARKDOWN
normalized = str(parse_mode).strip()
if not normalized:
return TELEGRAM_PARSE_MODE_PLAIN
return TELEGRAM_PARSE_MODE_ALIASES.get(
str(parse_mode).strip().lower(), TELEGRAM_PARSE_MODE_MARKDOWN
normalized.lower(), TELEGRAM_PARSE_MODE_MARKDOWN
)
@staticmethod
@@ -306,11 +312,18 @@ class Telegram:
"""判断本次发送是否使用 Telegram HTML 格式。"""
return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_HTML
@staticmethod
def _is_plain_parse_mode(parse_mode: Optional[str] = None) -> bool:
"""判断本次发送是否禁用 Telegram 文本格式解析。"""
return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_PLAIN
@staticmethod
def _format_title(title: Optional[str], parse_mode: Optional[str] = None) -> Optional[str]:
"""按 parse_mode 生成 Telegram 标题文本。"""
if not title:
return None
if Telegram._is_plain_parse_mode(parse_mode):
return title.removesuffix("\n")
if Telegram._is_html_parse_mode(parse_mode):
return f"<b>{html_utils.escape(title).removesuffix(chr(10))}</b>"
return f"**{standardize(title).removesuffix(chr(10))}**"
@@ -318,6 +331,8 @@ class Telegram:
@staticmethod
def _format_link(label: str, link: str, parse_mode: Optional[str] = None) -> str:
"""按 parse_mode 生成 Telegram 链接文本。"""
if Telegram._is_plain_parse_mode(parse_mode):
return f"{label}: {link}"
if Telegram._is_html_parse_mode(parse_mode):
return (
f'<a href="{html_utils.escape(link, quote=True)}">'
@@ -328,6 +343,8 @@ class Telegram:
@staticmethod
def _format_italic(text: str, parse_mode: Optional[str] = None) -> str:
"""按 parse_mode 生成 Telegram 斜体文本。"""
if Telegram._is_plain_parse_mode(parse_mode):
return text
if Telegram._is_html_parse_mode(parse_mode):
return f"<i>{html_utils.escape(text)}</i>"
return f"_{text}_"
@@ -342,7 +359,10 @@ class Telegram:
"""按 parse_mode 生成 Telegram 可发送文本。"""
if not text:
return None
if Telegram._is_html_parse_mode(parse_mode):
if (
Telegram._is_plain_parse_mode(parse_mode)
or Telegram._is_html_parse_mode(parse_mode)
):
return text
return standardize(text)
@@ -595,6 +615,7 @@ class Telegram:
disable_web_page_preview: Optional[bool] = None,
stop_typing: bool = False,
parse_mode: Optional[str] = None,
private_delivery: bool = False,
) -> Optional[dict]:
"""
发送Telegram消息
@@ -610,6 +631,7 @@ class Telegram:
:param disable_web_page_preview: 是否禁用链接预览
:param stop_typing: 发送完成后是否立即停止 typing
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2可传 HTML
:param private_delivery: 是否绕过最近会话映射,直接以用户 ID 作为私聊目标
:return: 包含 message_id, chat_id, success 的字典
"""
if not self._telegram_token or not self._telegram_chat_id:
@@ -617,7 +639,11 @@ class Telegram:
parse_mode = self._normalize_parse_mode(parse_mode)
# Determine target chat_id with improved logic using user mapping
chat_id = self._determine_target_chat_id(userid, original_chat_id)
chat_id = self._determine_target_chat_id(
userid,
original_chat_id,
private_delivery=private_delivery,
)
if not title and not text:
logger.warn("标题和内容不能同时为空")
self._stop_typing_if_needed(chat_id, stop_typing)
@@ -836,7 +862,10 @@ class Telegram:
return {"success": False}
def _determine_target_chat_id(
self, userid: Optional[str] = None, original_chat_id: Optional[str] = None
self,
userid: Optional[str] = None,
original_chat_id: Optional[str] = None,
private_delivery: bool = False,
) -> str:
"""
确定目标聊天ID使用用户映射确保回复到正确的聊天
@@ -844,6 +873,10 @@ class Telegram:
:param original_chat_id: 原消息的聊天ID
:return: 目标聊天ID
"""
# 私聊投递以渠道用户 ID 为目标,最近会话映射可能指向群聊,不能参与解析。
if private_delivery and userid:
return str(userid)
# 1. 优先使用原消息的聊天ID (编辑消息场景)
if original_chat_id:
return original_chat_id
@@ -1354,7 +1387,10 @@ class Telegram:
ret = self.__send_short_message(image, caption,
disable_web_page_preview=disable_web_page_preview,
**kwargs)
elif self._is_html_parse_mode(parse_mode):
elif (
self._is_plain_parse_mode(parse_mode)
or self._is_html_parse_mode(parse_mode)
):
ret = self.__send_long_plain_message(
image,
caption,

View File

@@ -262,9 +262,11 @@ class Notification(BaseModel):
original_message_id: Optional[Union[str, int]] = None
# 原消息的聊天ID用于编辑消息
original_chat_id: Optional[str] = None
# 是否必须按用户身份投递到私聊,禁止回退原会话或最近会话映射
private_delivery: bool = False
# 是否禁用链接预览仅Telegram支持
disable_web_page_preview: Optional[bool] = None
# Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
# 消息文本格式;Telegram 支持 MarkdownV2、HTML、plain飞书直发支持 plain
parse_mode: Optional[str] = None
# 是否写入消息历史
save_history: bool = True

View File

@@ -1,4 +1,5 @@
from app.agent.prompt import PromptManager
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsInput
from app.core.config import settings
@@ -64,3 +65,19 @@ def test_moviepilot_info_lists_command_names_without_paths(monkeypatch) -> None:
assert "/opt/homebrew/bin/rg" not in moviepilot_info
assert "/usr/local/bin/ffmpeg" not in moviepilot_info
assert "rg --files" in moviepilot_info
def test_agent_prompt_delegates_explicit_secret_reads_to_host_confirmation() -> None:
"""管理员明确索取密钥时,模型应发起工具调用并把授权交给宿主。"""
prompt = PromptManager().get_agent_prompt(channel="webagent")
assert "query_system_settings" in prompt
assert "show_secrets=true" in prompt
assert "do not refuse" in prompt
assert "host verifies administrator authority" in prompt
assert "Never expose or repeat the secret" in prompt
field_description = QuerySystemSettingsInput.model_fields["show_secrets"].description or ""
assert "user explicitly asks" in field_description
assert "host verifies administrator authority" in field_description
assert "Do not refuse the tool call" in field_description

View File

@@ -2,7 +2,8 @@
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
@@ -288,7 +289,6 @@ def test_expired_confirmation_reaches_agent_expiry_receipt() -> None:
"1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
original_chat_id="",
)
return await agent.process("确认")
finally:
@@ -324,7 +324,7 @@ def test_background_agent_refuses_secret_read_without_pending() -> None:
def test_message_channel_receives_confirmation_prompt_once() -> None:
"""TG/飞书应由宿主直接发送确认提示,不依赖图状态转成渠道输出"""
"""TG/飞书应先向用户私聊发送提示,再登记待确认操作"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
@@ -347,17 +347,79 @@ def test_message_channel_receives_confirmation_prompt_once() -> None:
new=AsyncMock(return_value=True),
), patch.object(
agent,
"send_agent_message",
new=AsyncMock(),
"_deliver_private_channel_message",
new=AsyncMock(return_value=True),
) as send_message:
prompt = asyncio.run(scenario())
send_message.assert_awaited_once_with(prompt)
assert agent._tool_context["user_reply_sent"] is True
assert agent.has_pending_secret_confirmation() is True
def test_pending_secret_read_keeps_original_owner_and_action() -> None:
"""新请求不得覆盖 pending错误交付目标也不得消费它"""
def test_message_channel_does_not_register_pending_when_private_delivery_fails() -> None:
"""无法建立私聊时不得等待确认,更不能回退群聊投递结果"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.Feishu.value,
source="feishu-main",
username="admin",
original_chat_id="group-1",
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
agent,
"_deliver_private_channel_message",
new=AsyncMock(return_value=False),
) as deliver,
):
result = asyncio.run(
agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
)
assert result == "无法向当前用户建立私聊,未执行敏感设置读取。"
deliver.assert_awaited_once()
assert agent.has_pending_secret_confirmation() is False
def test_private_delivery_requests_literal_plain_text() -> None:
"""敏感提示与结果均须请求渠道按纯文本私聊投递。"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.Telegram.value,
source="telegram-main",
username="admin",
original_chat_id="group-1",
)
response = SimpleNamespace(success=True)
with patch(
"app.agent.AgentChain.send_direct_message",
return_value=response,
) as send_direct:
delivered = asyncio.run(
agent._deliver_private_channel_message(
"G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n<img src=x>"
)
)
assert delivered is True
notification = send_direct.call_args.args[0]
assert notification.private_delivery is True
assert notification.parse_mode == "plain"
assert notification.original_chat_id is None
def test_pending_secret_read_keeps_actor_and_action_across_chat_targets() -> None:
"""新请求不得覆盖 pending同一用户可从私聊消费群聊发起的确认。"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
@@ -367,6 +429,8 @@ def test_pending_secret_read_keeps_original_owner_and_action() -> None:
original_chat_id="chat-1",
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
delivered = []
async def scenario() -> tuple[str, str, str]:
first = await agent._register_secret_confirmation(
@@ -384,12 +448,254 @@ def test_pending_secret_read_keeps_original_owner_and_action() -> None:
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(agent, "_execute_agent", new=AsyncMock(return_value="普通回复")),
patch.object(QuerySystemSettingsTool, "_load_setting_value") as load_value,
patch.object(
agent,
"_deliver_private_channel_message",
new=AsyncMock(side_effect=lambda content: delivered.append(content) or True),
),
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value="secret-marker",
) as load_value,
):
first, second, result = asyncio.run(scenario())
assert "TMDB_API_KEY" in first
assert "已有待确认" in second
assert result == "普通回复"
load_value.assert_not_called()
assert agent.has_pending_secret_confirmation() is True
assert result == "敏感设置确认已处理。"
load_value.assert_called_once()
assert "secret-marker" in delivered[-1]
assert agent.has_pending_secret_confirmation() is False
def test_confirm_reports_result_delivery_failure_without_secret() -> None:
"""工具已读取但受保护结果未送达时,只能通过普通渠道报告非敏感失败。"""
secret_marker = "confirmed-secret-marker"
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]),
) as deliver,
patch.object(agent, "send_agent_message", new=AsyncMock()) as send_notice,
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=secret_marker,
),
):
result = asyncio.run(scenario())
assert result == "敏感设置读取已完成,但结果投递失败,请重新发起。"
assert deliver.await_count == 2
assert secret_marker in deliver.await_args_list[-1].args[0]
send_notice.assert_awaited_once_with(result)
assert secret_marker not in send_notice.await_args.args[0]
def test_web_protected_callback_failure_returns_ordinary_safe_notice() -> None:
"""Web 受保护回调失败时,普通流只能收到不含敏感结果的提示。"""
secret_marker = "confirmed-secret-marker"
ordinary_output = []
def broken_protected_callback(_content: str) -> None:
raise RuntimeError("delivery unavailable")
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
output_callback=ordinary_output.append,
protected_output_callback=broken_protected_callback,
)
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},
)
ordinary_output.clear()
return await agent.process("确认")
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=secret_marker,
),
):
result = asyncio.run(scenario())
assert result == "敏感设置读取已完成,但结果投递失败,请重新发起。"
assert ordinary_output == [result]
assert secret_marker not in ordinary_output[0]
def test_confirm_reuses_policy_lifecycle() -> None:
"""确认后的冻结调用必须生成与 ToolNode 相同的 start/finish 生命周期。"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
protected_output_callback=lambda _content: None,
)
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("确认")
from app.agent.policy import DEFAULT_TOOL_POLICY_ORCHESTRATOR
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(QuerySystemSettingsTool, "_load_setting_value", return_value="secret"),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
wraps=DEFAULT_TOOL_POLICY_ORCHESTRATOR.start,
) as start,
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"finish",
wraps=DEFAULT_TOOL_POLICY_ORCHESTRATOR.finish,
) as finish,
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"fail",
wraps=DEFAULT_TOOL_POLICY_ORCHESTRATOR.fail,
) as fail,
):
result = asyncio.run(scenario())
assert result == "敏感设置确认已处理。"
start.assert_called_once()
finish.assert_called_once()
fail.assert_not_called()
def test_confirm_respects_policy_denial_without_running_tool() -> None:
"""确认不能覆盖宿主策略的拒绝决定。"""
protected_output = []
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
protected_output_callback=protected_output.append,
)
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("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 == "敏感设置确认已处理。"
assert protected_output[-1] == "当前宿主策略不允许执行该工具。"
run_tool.assert_not_awaited()
def test_confirm_records_policy_failure_and_returns_protected_error() -> None:
"""确认执行异常必须闭合 fail 生命周期,且不把异常交给普通对话。"""
protected_output = []
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
protected_output_callback=protected_output.append,
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
orchestrator = MagicMock()
orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=True)
)
async def scenario() -> str:
await agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
return await agent.process("确认")
from app.agent.policy import DEFAULT_TOOL_POLICY_ORCHESTRATOR
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
return_value=orchestrator.start.return_value,
),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"fail",
side_effect=orchestrator.fail,
) as fail,
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"finish",
side_effect=orchestrator.finish,
) as finish,
patch.object(
QuerySystemSettingsTool,
"_run_confirmed",
new=AsyncMock(side_effect=RuntimeError("secret-bearing-error")),
),
):
result = asyncio.run(scenario())
assert result == "敏感设置读取失败,请稍后重试。"
assert protected_output[-1] == result
fail.assert_called_once()
finish.assert_not_called()

View File

@@ -373,7 +373,9 @@ def test_middleware_observation_failure_does_not_replace_success(
) -> None:
"""shadow start/finish 故障不能阻止 handler 或替换成功结果。"""
orchestrator = MagicMock()
orchestrator.start.return_value = object()
orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=True)
)
getattr(orchestrator, failed_phase).side_effect = RuntimeError(
f"policy-{failed_phase}-failure"
)
@@ -402,7 +404,9 @@ def test_middleware_observation_failure_does_not_replace_success(
def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
"""shadow fail hook 故障后仍必须抛出原始工具异常。"""
orchestrator = MagicMock()
orchestrator.start.return_value = object()
orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=True)
)
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
@@ -423,6 +427,32 @@ def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
assert error_info.value is tool_error
def test_middleware_keeps_shadow_observation_until_strict_runtime_takeover() -> None:
"""普通 ToolNode 在严格策略接管前不得因观测决策改变既有行为。"""
orchestrator = MagicMock()
orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=False)
)
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
orchestrator=orchestrator,
)
request = SimpleNamespace(
tool=_EchoTool(session_id="session-1", user_id="user-1"),
tool_call={"id": "call-1", "name": "policy_echo", "args": {"query": "same"}},
)
handler = AsyncMock(
return_value=ToolMessage(content="same", tool_call_id="call-1")
)
result = asyncio.run(middleware.awrap_tool_call(request, handler))
assert result.content == "same"
handler.assert_awaited_once_with(request)
orchestrator.finish.assert_called_once()
orchestrator.fail.assert_not_called()
def test_policy_hook_failure_logs_only_stable_type_information() -> None:
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
mock_logger = MagicMock()

View File

@@ -1155,6 +1155,45 @@ class TestFeishu(unittest.TestCase):
self.assertEqual(response.message_id, "om_789")
self.assertEqual(response.chat_id, "oc_789")
def test_module_plain_direct_message_uses_literal_text_transport(self):
"""纯文本直发不得进入会解释密钥字符的 Markdown 卡片路径。"""
module = FeishuModule()
module._channel = MessageChannel.Feishu
conf = SimpleNamespace(name="feishu-main")
client = MagicMock()
client.send_text.return_value = {
"success": True,
"message_id": "om_plain",
"chat_id": "oc_plain",
}
literal_text = "G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n<img src=x>"
with (
patch.object(module, "get_configs", return_value={"feishu-main": conf}),
patch.object(module, "check_message", return_value=True),
patch.object(module, "get_instance", return_value=client),
):
response = module.send_direct_message(
Notification(
channel=MessageChannel.Feishu,
source="feishu-main",
userid="ou_target",
text=literal_text,
private_delivery=True,
parse_mode="plain",
)
)
client.send_text.assert_called_once_with(
text=literal_text,
userid="ou_target",
chat_id=None,
receive_id_type=None,
original_message_id=None,
)
client.send_notification.assert_not_called()
self.assertTrue(response.success)
def test_run_ws_client_binds_thread_local_event_loop(self):
client = self._build_client()
original_loop = object()
@@ -1515,6 +1554,20 @@ class TestFeishu(unittest.TestCase):
self.assertIsNone(userid)
self.assertEqual(chat_id, "oc_config")
def test_module_private_delivery_ignores_original_group_chat(self):
"""私聊投递只保留用户身份,并让客户端按已记录 ID 类型发送。"""
userid, chat_id, receive_id_type = FeishuModule._resolve_message_target(
Notification(
userid="user_target",
original_chat_id="oc_group",
private_delivery=True,
)
)
self.assertEqual(userid, "user_target")
self.assertIsNone(chat_id)
self.assertIsNone(receive_id_type)
def test_module_post_message_replies_to_original_chat_for_group_message(self):
"""携带原会话上下文的回复应定向到原会话(群聊)。"""
module = FeishuModule()

View File

@@ -578,6 +578,39 @@ def test_telegram_module_plain_direct_message_keeps_userid_target():
assert response.message_id == 456
def test_telegram_private_delivery_bypasses_group_chat_mapping(telegram):
"""私聊投递必须直接使用用户 ID不能沿用该用户最近发言的群聊。"""
telegram._user_chat_mapping["10001"] = "group-1"
result = telegram.send_msg(
title="",
text="受保护消息",
userid="10001",
private_delivery=True,
)
assert result and result.get("success")
assert telegram.bot.send_message.call_args.kwargs["chat_id"] == "10001"
def test_telegram_plain_private_delivery_keeps_literal_text(telegram):
"""纯文本私聊不得解释或改写密钥中可能出现的 Markdown/HTML 字符。"""
literal_text = "G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n<img src=x>"
result = telegram.send_msg(
title="",
text=literal_text,
userid="10001",
private_delivery=True,
parse_mode="plain",
)
assert result and result.get("success")
send_kwargs = telegram.bot.send_message.call_args.kwargs
assert send_kwargs["text"] == literal_text
assert send_kwargs["parse_mode"] == ""
def test_send_msg_with_force_reply_uses_force_reply_when_no_buttons(telegram):
"""无按钮时force_reply应生成Telegram ForceReply标记"""
result = telegram.send_msg(

View File

@@ -106,6 +106,19 @@ def test_web_agent_event_publisher_coalesces_text_before_semantic_events():
assert max_depth == 2
def test_web_agent_event_publisher_rejects_events_after_close():
"""连接关闭后必须显式拒绝事件,避免把敏感结果误报为已交付。"""
async def scenario():
publisher = _WebAgentEventPublisher()
await publisher.aclose()
return publisher.publish(
{"type": "interaction-protected", "content": "secret"}
)
assert asyncio.run(scenario()) is False
def test_build_web_agent_session_id_is_stable_per_user_and_seed():
"""同一用户和前端会话标识应生成稳定的服务端会话 ID。"""
user = SimpleNamespace(id=1, name="admin")
@@ -969,11 +982,15 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
client_session_id=payload.session_id,
)
delivery_results = []
async def finish_after_disconnect(**kwargs):
"""断线后继续完成只读任务,并尝试向已关闭发布器投递。"""
agent_started.set()
await release_agent.wait()
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")
delivery_results.append(
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")
)
agent_completed.set()
async def scenario():
@@ -999,6 +1016,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
body = asyncio.run(scenario())
assert '"type": "start"' in body
assert delivery_results == [False]
assert "cancel_on_waiter_cancel" not in process.await_args.kwargs
save_snapshot.assert_not_called()
preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1")