fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes
Remaining-item sweep (audit report §12): - N9: cap prompt file writes (identity/soul/status, 10k chars) to bound persistent-injection surface - F5: run_light_subagent switches to stream_chat + parent cancel_event (mid-stream interrupt); ExpressEmotionTool injects parent event - Q4: busy_timeout=10s on mark_as_triggered (multi-process DB contention) - Q5: malformed reminder_time tasks returned as due (were silently dropped by SQL-level string filter, which is now Python-side authoritative check) - Q7b: load_conversation realpath prefix check (../ traversal defense) - Q8: atomic JSON writes (tmp+os.replace) for index/current/archive - F6: dedicated ws command executor for /new, /reload_prompts - F9: --once hard-exit backstop after soft timeout - PromptManager singleton lock; limit=0 semantics (None = no LIMIT)
This commit is contained in:
1 parent
9fee9a1478
commit
db54921066
6 files changed
+379
-23
No files matched your search
@@ -15,6 +15,7 @@ subsystems.conversation_history - 对话历史管理器
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -68,10 +69,19 @@ class ConversationHistory:
|
||||
return {"conversations": [], "last_id": 0}
|
||||
return {"conversations": [], "last_id": 0}
|
||||
|
||||
@staticmethod
|
||||
def _atomic_write_json(path: Path, data) -> None:
|
||||
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
|
||||
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
|
||||
后仅静默置 None,历史内容丢失)"""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
def _save_history_index(self):
|
||||
"""保存历史索引"""
|
||||
with open(self.history_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
|
||||
self._atomic_write_json(self.history_file, self.history_index)
|
||||
|
||||
def _load_current_conversation(self):
|
||||
"""加载当前的对话(如果有)"""
|
||||
@@ -86,8 +96,7 @@ class ConversationHistory:
|
||||
def save_current_conversation(self):
|
||||
"""保存当前对话"""
|
||||
if self.current_conversation:
|
||||
with open(self.current_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
||||
self._atomic_write_json(self.current_file, self.current_conversation)
|
||||
|
||||
def start_new_conversation(self, topic: str = None) -> Dict:
|
||||
"""开始新对话"""
|
||||
@@ -145,8 +154,7 @@ class ConversationHistory:
|
||||
counter += 1
|
||||
archive_path = self.archive_dir / archive_filename
|
||||
|
||||
with open(archive_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
||||
self._atomic_write_json(archive_path, self.current_conversation)
|
||||
|
||||
self.history_index["conversations"].append({
|
||||
"id": self.current_conversation["id"],
|
||||
@@ -350,7 +358,13 @@ class ConversationHistory:
|
||||
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
|
||||
return self.current_conversation
|
||||
|
||||
file_path = self.archive_dir / file_name
|
||||
# I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦
|
||||
# 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验
|
||||
archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir)))
|
||||
target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name)))
|
||||
if not target.startswith(archive_root + os.sep):
|
||||
return None
|
||||
file_path = Path(target)
|
||||
if file_path.exists():
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
+22
-7
@@ -402,11 +402,14 @@ class MemoDatabase:
|
||||
# 如果传入了 before_time,使用它,否则使用当前时间
|
||||
compare_time = before_time if before_time else now
|
||||
|
||||
# I 批(审计 2026-08-18):去掉 SQL 层 reminder_time <= ? 预过滤——
|
||||
# 字符串比较会把格式异常的时间(如 "不是时间")静默排除在 SQL 层,
|
||||
# Python 二次验证永远看不到它们(Q5:格式异常任务按到期返回)。
|
||||
# 时间过滤改由下方 Python 解析做权威判断(本地小库全扫描可忽略)。
|
||||
query = '''
|
||||
SELECT * FROM memos
|
||||
WHERE is_completed = 0
|
||||
AND reminder_time IS NOT NULL
|
||||
AND reminder_time <= ? -- 严格小于等于比较时间
|
||||
AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
|
||||
'''
|
||||
|
||||
@@ -415,11 +418,13 @@ class MemoDatabase:
|
||||
|
||||
query += ' ORDER BY reminder_time ASC, priority DESC'
|
||||
|
||||
params = [compare_time, compare_time]
|
||||
params = [compare_time]
|
||||
|
||||
if limit:
|
||||
# I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
|
||||
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
|
||||
if limit is not None:
|
||||
query += ' LIMIT ?'
|
||||
params.append(int(limit))
|
||||
params.append(max(0, int(limit)))
|
||||
|
||||
cursor.execute(query, params)
|
||||
|
||||
@@ -440,7 +445,11 @@ class MemoDatabase:
|
||||
# 调试日志
|
||||
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}")
|
||||
# I 批(审计 2026-08-18):格式异常此前被静默丢弃——
|
||||
# 任务永不执行且用户无感知。改为按"到期"返回并告警,
|
||||
# 至少保证任务被处理而非永久丢失
|
||||
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败,按到期处理: {e}")
|
||||
results.append(memo)
|
||||
else:
|
||||
# 没有提醒时间的任务不应该在待执行列表中
|
||||
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
|
||||
@@ -461,9 +470,11 @@ class MemoDatabase:
|
||||
'''
|
||||
|
||||
params = []
|
||||
if limit:
|
||||
# I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
|
||||
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
|
||||
if limit is not None:
|
||||
query += ' LIMIT ?'
|
||||
params.append(int(limit))
|
||||
params.append(max(0, int(limit)))
|
||||
|
||||
cursor.execute(query, params)
|
||||
|
||||
@@ -525,6 +536,10 @@ class MemoDatabase:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
# I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度)
|
||||
# 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让
|
||||
# SQLite 等待锁释放(最长 10s)而不是立即失败
|
||||
cursor.execute("PRAGMA busy_timeout = 10000")
|
||||
cursor.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
|
||||
|
||||
@@ -18,6 +18,7 @@ subsystems.prompt_manager - 提示词管理器
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
@@ -35,6 +36,7 @@ class PromptManager:
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_instance_lock = threading.Lock() # I 批:并发首例化竞态防护
|
||||
_prompts = []
|
||||
_prompts_dir = None
|
||||
|
||||
@@ -43,12 +45,18 @@ class PromptManager:
|
||||
STATUS_FILE = "100_STATUS.md"
|
||||
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
|
||||
|
||||
# N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续
|
||||
# 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模,
|
||||
# 超长内容拒绝写入(工具层有更友好的提示)。
|
||||
MAX_CONTENT_LENGTH = 10000
|
||||
|
||||
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
|
||||
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
@@ -151,6 +159,9 @@ class PromptManager:
|
||||
|
||||
def update_identity(self, content: str) -> bool:
|
||||
"""更新用户身份信息"""
|
||||
if len(content or "") > self.MAX_CONTENT_LENGTH:
|
||||
print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
|
||||
return False
|
||||
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -171,6 +182,9 @@ class PromptManager:
|
||||
|
||||
def update_status(self, content: str) -> bool:
|
||||
"""更新智能体状态"""
|
||||
if len(content or "") > self.MAX_CONTENT_LENGTH:
|
||||
print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
|
||||
return False
|
||||
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -191,6 +205,9 @@ class PromptManager:
|
||||
|
||||
def update_soul(self, content: str) -> bool:
|
||||
"""更新智能体灵魂定义 - 所有角色定位内容"""
|
||||
if len(content or "") > self.MAX_CONTENT_LENGTH:
|
||||
print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
|
||||
return False
|
||||
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
Reference in new issue
Block a user