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:
qing committed 2026-08-18 03:09:05 +00:00
1 parent 9fee9a1478
commit db54921066
6 files changed
+379 -23

No files matched your search

+21 -7
View File
@@ -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
View File
@@ -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,))
+19 -2
View File
@@ -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")