agent-Specialization/utils/conversation_manager/metadata_mixin.py
JOJO aedbee2b75 fix(security): 第二轮全量 API 审计修复(角色穿越/宿主 GUI/限流收口)
背景:对全部 206 个 HTTP 路由 + SocketIO 事件逐个排查(主审 + 2 个子
智能体初筛后复核),另按用户要求专项审计前端渲染面(无新发现)。
完整报告见 _experiments/security_audit_2026-09-02/REPORT.md 第八章。

新漏洞修复:
- 多智能体角色 role_id 路径穿越(高):save_custom_role 直接拼接
  "{role_id}.md",POST body 的 role_id 无任何校验,可 ../ 穿越覆盖
  其他用户角色文件(跨用户提示词注入),本地已复现写入成功。
  修复:新增 validate_role_id 白名单(^[a-z0-9][a-z0-9_-]{0,63}$),
  role_store 存储层 + multi_agent API 层(POST/PUT/DELETE)双保险
- open-in-file-manager 漏 _is_host_mode_request 检查(同文件其余三个
  端点都有):docker 模式下任意登录用户可触发宿主 GUI 弹窗+探测路径。
  修复:补 host 检查,docker 下 403
- /api/admin/secondary/verify 无限流且 CSRF 豁免:admin 会话前提下
  可在线爆破二级密码(纵深防御缺失)。修复:5 次/300s 用户维度限流
- WS client_chunk_log / client_stream_debug_log 无连接认证检查且
  无限流(日志写盘放大面)。修复:必须已认证连接 + 30 次/60s 滑窗
  + 桶表万级上限回收
- _get_conversation_file_path 直接拼 "{id}.json"(conv_ 前缀恰好阻碍
  直接穿越,属防御深度缺失)。修复:^conv_[A-Za-z0-9_-]+$ 白名单,
  已核验全部 5 个调用点与 temp_ 前缀排除路径不受影响
- /api/conversations/media/<id> 采信 entry mime_type 并 inline 返回。
  修复:text/html、image/svg+xml、xhtml 强制 octet-stream + attachment
  (与 file/content 的 SVG 策略对齐,防存储型 XSS 一致性收口)
- /api/status 每次心跳返回宿主绝对 project_path(泄露系统用户名与
  目录布局)。修复:docker 模式脱敏为容器视角 /workspace,host 不变

LLM 成本攻击止血(第一轮发现 5 的端点级落地):
- POST /api/tasks(GUI 发消息主入口):30 次/60s/user
- POST /api/conversations/<id>/compress(调 api_client.chat 做摘要):
  5 次/300s/user
- 注:对话回顾 review 端点实为纯本地 Markdown 生成(不调 LLM),
  其「发送给模型」模式走 /api/tasks,已被上述限流覆盖;
  按 token 计费的完整配额方案仍遗留待产品决策

验证:全部 py_compile 通过;冒烟测试 6/6;validate_role_id 8 个恶意
样本全拒 + 3 个合法样本放行 + 穿越写入拦截回归通过;conv_id 白名单
4 组样本符合预期。端点级行为待服务重启后实测。
2026-09-02 19:37:34 +08:00

187 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# utils/conversation_manager.py - 对话持久化管理器集成Token统计
import json
import os
import time
import tempfile
import threading
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
from modules.i18n import tr
try:
from config import DATA_DIR, HOST_WORKSPACES_FILE
except ImportError:
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import DATA_DIR, HOST_WORKSPACES_FILE
@dataclass
class ConversationMetadata:
"""对话元数据"""
id: str
title: str
created_at: str
updated_at: str
project_path: Optional[str]
project_relative_path: Optional[str]
thinking_mode: bool
total_messages: int
total_tools: int
run_mode: str = "fast"
model_key: Optional[str] = None
has_images: bool = False
has_videos: bool = False
status: str = "active" # active, archived, error
class MetadataMixin:
"""ConversationManager metadata mixin 能力 mixin。"""
def _generate_conversation_id(self) -> str:
"""生成唯一的对话ID"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# 添加毫秒确保唯一性
ms = int(time.time() * 1000) % 1000
return f"conv_{timestamp}_{ms:03d}"
def _get_conversation_file_path(self, conversation_id: str) -> Path:
"""获取对话文件路径(白名单校验 ID防止路径穿越读写数据目录外的文件"""
import re
if not re.fullmatch(r"conv_[A-Za-z0-9_-]+", conversation_id or ""):
raise ValueError(tr("conversation.invalid_conversation_id"))
return self.conversations_dir / f"{conversation_id}.json"
def _extract_title_from_messages(self, messages: List[Dict]) -> str:
"""从消息中提取标题"""
# 找到第一个用户消息作为标题
for msg in messages:
if msg.get("role") == "user":
metadata = msg.get("metadata") or {}
source = str(metadata.get("source") or metadata.get("message_source") or "").strip().lower()
if source == "skill" or metadata.get("hidden") is True:
continue
content = msg.get("content", "").strip()
if content:
# 取前50个字符作为标题
title = content[:50]
if len(content) > 50:
title += "..."
return title
return tr("conversation.default_title")
def _extract_text_content(self, content: Any) -> str:
"""从字符串/多模态列表/字典中提取纯文本。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
elif isinstance(item.get("text"), str):
parts.append(item.get("text") or "")
return "".join(parts)
if isinstance(content, dict):
return str(content.get("text") or "")
return ""
def _extract_first_user_message(self, messages: List[Dict], max_chars: int = 100) -> str:
"""提取首条用户消息纯文本,用于最近对话/搜索展示。"""
for msg in messages or []:
if msg.get("role") != "user":
continue
text = " ".join(self._extract_text_content(msg.get("content")).split())
if not text:
continue
if len(text) > max_chars:
return text[:max_chars] + "..."
return text
return ""
def _count_tools_in_messages(self, messages: List[Dict]) -> int:
"""统计消息中的工具调用数量(仅统计 assistant.tool_calls"""
tool_count = 0
for msg in messages:
if msg.get("role") == "assistant" and "tool_calls" in msg:
tool_calls = msg.get("tool_calls", [])
tool_count += len(tool_calls) if isinstance(tool_calls, list) else 0
return tool_count
def _prepare_project_path_metadata(self, project_path: Optional[str]) -> Dict[str, Optional[str]]:
"""
将项目路径规范化为绝对/相对形式,便于在不同机器间迁移
"""
normalized = {
"project_path": None,
"project_relative_path": None
}
if not project_path:
return normalized
try:
absolute_path = Path(project_path).expanduser().resolve()
normalized["project_path"] = str(absolute_path)
try:
relative_path = absolute_path.relative_to(self.workspace_root)
normalized["project_relative_path"] = relative_path.as_posix()
except ValueError:
normalized["project_relative_path"] = None
except Exception:
# 回退为原始字符串,至少不会阻止对话保存
normalized["project_path"] = str(project_path)
normalized["project_relative_path"] = None
return normalized
def _initialize_token_statistics(self) -> Dict:
"""初始化Token统计结构"""
now = datetime.now().isoformat()
return {
"total_input_tokens": 0,
"total_output_tokens": 0,
"total_tokens": 0,
"total_cached_input_tokens": 0,
# 豁免出命中率分母的冷启动输入累计(首轮未命中 + 压缩后首轮未命中的输入)
"cache_exempt_input_tokens": 0,
# 深度压缩后待判定标记:下一次真实调用若无缓存命中,其输入累加进豁免值
"cache_cold_start_pending": False,
"current_context_tokens": 0,
"updated_at": now
}
def _validate_token_statistics(self, data: Dict) -> Dict:
"""验证并修复Token统计数据"""
token_stats = data.get("token_statistics", {})
# 确保必要字段存在
defaults = self._initialize_token_statistics()
for key, default_value in defaults.items():
if key not in token_stats:
token_stats[key] = default_value
# 确保数值类型正确cache_cold_start_pending 为布尔,不在此转换)
try:
token_stats["total_input_tokens"] = int(token_stats.get("total_input_tokens", 0))
token_stats["total_output_tokens"] = int(token_stats.get("total_output_tokens", 0))
token_stats["total_tokens"] = int(token_stats.get("total_tokens", 0))
token_stats["total_cached_input_tokens"] = int(token_stats.get("total_cached_input_tokens", 0))
token_stats["cache_exempt_input_tokens"] = int(token_stats.get("cache_exempt_input_tokens", 0))
token_stats["current_context_tokens"] = int(token_stats.get("current_context_tokens", 0))
token_stats["cache_cold_start_pending"] = bool(token_stats.get("cache_cold_start_pending", False))
except (ValueError, TypeError):
print("⚠️ Token统计数据损坏重置为0")
token_stats = defaults
data["token_statistics"] = token_stats
return data