Compare commits

..

2 Commits

Author SHA1 Message Date
AstrBot
1063a45665 fix: restore session param but restrict to admin only
- Re-add the  parameter removed in the original PR
- Non-admin users can only send to their own session (current_session)
- Admin users can send to any session via the  param
- Uses  from computer_tools.util (same pattern as fs.py)
- Ref: https://github.com/AstrBotDevs/AstrBot/issues/7822

Co-authored-by: Soulter <soulter@astrbot.app>
2026-04-29 00:01:14 +08:00
Soulter
59fa9fdeaa fix(core): security fix - restrict send_message_to_user to current session only
Closes #7822

SECURITY: Remove the user-controlled 'session' parameter from the
send_message_to_user tool. Previously, a regular user could ask the
LLM to send messages to any arbitrary session (group chat) by
providing a crafted session string, which is a high-risk
vulnerability.

Changes:
- Remove 'session' parameter from tool schema (LLM can no longer
  propose it)
- Always use context.context.event.unified_msg_origin as the target
  session
- Update description to clearly state that messages can only be sent
  to the current user's session
2026-04-27 02:07:52 +08:00
6 changed files with 46 additions and 54 deletions

View File

@@ -183,10 +183,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats.end_time = time.time()
parts = []
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content or "",
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
@@ -876,10 +876,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 将结果添加到上下文中
parts = []
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content or "",
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
@@ -1361,10 +1361,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats.end_time = time.time()
parts = []
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content or "",
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)

View File

@@ -353,7 +353,7 @@ class LLMResponse:
"""Tool call IDs."""
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
"""Tool call extra content. tool_call_id -> extra_content dict"""
reasoning_content: str | None = None
reasoning_content: str = ""
"""The reasoning content extracted from the LLM, if any."""
reasoning_signature: str | None = None
"""The signature of the reasoning content, if any."""
@@ -404,6 +404,8 @@ class LLMResponse:
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
"""
if reasoning_content is None:
reasoning_content = ""
if tools_call_args is None:
tools_call_args = []
if tools_call_name is None:

View File

@@ -39,7 +39,7 @@ class ProviderAnthropic(Provider):
stop_reason: str | None = None,
) -> None:
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return

View File

@@ -462,7 +462,7 @@ class ProviderGoogleGenAI(Provider):
finish_reason: str | None = None,
) -> None:
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return

View File

@@ -671,9 +671,9 @@ class ProviderOpenAIOfficial(Provider):
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
llm_response.reasoning_content = None
llm_response.reasoning_content = ""
llm_response.completion_text = ""
if reasoning is not None:
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
if delta and delta.content:
@@ -701,28 +701,22 @@ class ProviderOpenAIOfficial(Provider):
def _extract_reasoning_content(
self,
completion: ChatCompletion | ChatCompletionChunk,
) -> str | None:
) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
def _get_reasoning_attr(obj: Any) -> str | None:
fields_set = getattr(obj, "model_fields_set", None)
if isinstance(fields_set, set) and self.reasoning_key in fields_set:
attr = getattr(obj, self.reasoning_key, "")
return "" if attr is None else str(attr)
attr = getattr(obj, self.reasoning_key, None)
return None if attr is None else str(attr)
reasoning_text = ""
if not completion.choices:
return None
return reasoning_text
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
reasoning_attr = _get_reasoning_attr(choice.message)
reasoning_attr = getattr(choice.message, self.reasoning_key, None)
if reasoning_attr:
reasoning_text = str(reasoning_attr)
elif isinstance(completion, ChatCompletionChunk):
delta = completion.choices[0].delta
reasoning_attr = _get_reasoning_attr(delta)
else:
return None
return reasoning_attr
reasoning_attr = getattr(delta, self.reasoning_key, None)
if reasoning_attr:
reasoning_text = str(reasoning_attr)
return reasoning_text
def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
ptd = getattr(usage, "prompt_tokens_details", None)
@@ -865,9 +859,7 @@ class ProviderOpenAIOfficial(Provider):
# parse the reasoning content if any
# the priority is higher than the <think> tag extraction
reasoning_content = self._extract_reasoning_content(completion)
if reasoning_content is not None:
llm_response.reasoning_content = reasoning_content
llm_response.reasoning_content = self._extract_reasoning_content(completion)
# parse tool calls if any
if choice.message.tool_calls and tools is not None:
@@ -914,7 +906,7 @@ class ProviderOpenAIOfficial(Provider):
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
)
has_text_output = bool((llm_response.completion_text or "").strip())
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_reasoning_output = bool(llm_response.reasoning_content.strip())
if (
not has_text_output
and not has_reasoning_output
@@ -990,39 +982,24 @@ class ProviderOpenAIOfficial(Provider):
"""Finally convert the payload. Such as think part conversion, tool inject."""
model = payloads.get("model", "").lower()
is_gemini = "gemini" in model
deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"}
is_deepseek_v4_reasoning = (
model in deepseek_reasoning_models
or "api.deepseek.com" in self.client.base_url.host
)
for message in payloads.get("messages", []):
if message.get("role") == "assistant" and isinstance(
message.get("content"), list
):
reasoning_content = ""
reasoning_content_present = False
new_content = [] # not including think part
for part in message["content"]:
if part.get("type") == "think":
reasoning_content_present = True
reasoning_content += str(part.get("think"))
else:
new_content.append(part)
# Some providers (Grok, etc.) reject empty content lists.
# When all parts were think blocks, fall back to None.
message["content"] = new_content or None
if reasoning_content_present:
if reasoning_content:
message["reasoning_content"] = reasoning_content
if (
message.get("role") == "assistant"
and is_deepseek_v4_reasoning
and "reasoning_content" not in message
):
# DeepSeek v4 reasoning models require the field on assistant
# history messages, even when the reasoning content is empty.
message["reasoning_content"] = ""
# Gemini 的 function_response 要求 google.protobuf.Struct即 JSON 对象),
# 纯文本会触发 400 Invalid argument需要包一层 JSON。
if is_gemini and message.get("role") == "tool":

View File

@@ -14,6 +14,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -26,7 +27,10 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
"or when you need to proactively message the user (such as cron job). "
"For normal text replies, you can output directly. "
"Optionally specify a `session` to send the message to a different session (admin only). "
"If no session is specified, the message is sent to the current user's session."
)
parameters: dict = Field(
default_factory=lambda: {
@@ -65,10 +69,10 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"required": ["type"],
},
},
"session": {
"type": "string",
"description": "Optional. Target session string. Defaults to current session.",
},
},
"session": {
"type": "string",
"description": "Optional. Target session string. Defaults to current session. Only AstrBot admins can send to other sessions.",
},
"required": ["messages"],
}
@@ -117,7 +121,16 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
session = kwargs.get("session") or context.context.event.unified_msg_origin
# Security: only AstrBot admins can send messages to other sessions.
# Non-admin users are always restricted to their own session.
# See https://github.com/AstrBotDevs/AstrBot/issues/7822
current_session = context.context.event.unified_msg_origin
session = kwargs.get("session") or current_session
if session != current_session:
if permission_error := check_admin_permission(
context, "Send message to another session"
):
return permission_error
messages = kwargs.get("messages")
if not isinstance(messages, list) or not messages:
return "error: messages parameter is empty or invalid."