diff --git a/aibox/subsystems/executors.py b/aibox/subsystems/executors.py index b72b6f1..1cdef3a 100644 --- a/aibox/subsystems/executors.py +++ b/aibox/subsystems/executors.py @@ -86,16 +86,36 @@ class SubprocessCommandExecutor(CommandExecutor): pass def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]: - """从进程读取输出,限制总大小和超时。 - 返回 (output_str, timeout_occurred, truncated) + """从进程读取输出,限制总大小和超时。返回 (output_str, timeout_occurred, truncated)。 + + H2 修复:阻塞 readline 在独立 reader 线程执行,主循环带超时轮询队列。 + 此前超时检查只在两次 readline 之间执行,无输出命令会让 readline + 阻塞到进程自然退出,timeout 形同虚设(审计 PoC 实证)。 """ output_chunks = [] total_bytes = 0 timeout_occurred = False truncated = False + line_queue: queue.Queue = queue.Queue() + reader_done = threading.Event() + + def _reader(): + """后台 reader 线程:阻塞 readline,行入队;EOF/异常结束""" + try: + for line in iter(process.stdout.readline, ""): + line_queue.put(line) + except Exception: + pass + finally: + reader_done.set() + + reader = threading.Thread(target=_reader, daemon=True) + reader.start() + + shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻 while True: - # 检查超时 + # 检查超时(readline 阻塞不再影响本判断) if timeout > 0 and time.time() - start_time > timeout: process.terminate() timeout_occurred = True @@ -107,26 +127,37 @@ class SubprocessCommandExecutor(CommandExecutor): process.terminate() break - # 读取一行 - line = process.stdout.readline() - if not line and process.poll() is not None: - break - if line: - line_bytes = len(line.encode('utf-8', errors='replace')) - if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated: - allowed = self.MAX_OUTPUT_BYTES - total_bytes - if allowed > 0: - output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')) - total_bytes = self.MAX_OUTPUT_BYTES - truncated = True - process.terminate() + try: + line = line_queue.get(timeout=0.1) + shell_dead_since = None + except queue.Empty: + if reader_done.is_set(): break + # shell 已被 kill/退出但 reader 仍阻塞(如 Windows shell=True + # 下 cancel 只杀死包装进程,孙进程仍持有管道写端导致无 EOF): + # 宽限 1 秒排空残留输出后放弃等待 + if process.poll() is not None: + if shell_dead_since is None: + shell_dead_since = time.time() + elif time.time() - shell_dead_since > 1.0: + break + continue - output_chunks.append(line) - total_bytes += line_bytes + line_bytes = len(line.encode('utf-8', errors='replace')) + if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated: + allowed = self.MAX_OUTPUT_BYTES - total_bytes + if allowed > 0: + output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')) + total_bytes = self.MAX_OUTPUT_BYTES + truncated = True + process.terminate() + break - if output_queue: - output_queue.put(("line", line.rstrip('\n') + "\n")) + output_chunks.append(line) + total_bytes += line_bytes + + if output_queue: + output_queue.put(("line", line.rstrip('\n') + "\n")) return ''.join(output_chunks), timeout_occurred, truncated @@ -153,7 +184,7 @@ class SubprocessCommandExecutor(CommandExecutor): shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - stdin=subprocess.PIPE, + stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起 text=True, bufsize=1, universal_newlines=True, @@ -240,7 +271,7 @@ class SubprocessCommandExecutor(CommandExecutor): shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - stdin=subprocess.PIPE, + stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起 text=True, bufsize=1, universal_newlines=True, @@ -253,50 +284,14 @@ class SubprocessCommandExecutor(CommandExecutor): self._current_process = process start_time = time.time() - output_lines = [] - total_bytes = 0 - timeout_occurred = False - truncated = False + # H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发 + output_str, timeout_occurred, truncated = self._read_with_limit( + process, timeout, start_time, output_queue=output_queue) - # 实时读取输出 - while True: - # 检查超时 - if timeout > 0 and time.time() - start_time > timeout: - process.terminate() - timeout_occurred = True - output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n")) - break - - # 检查输出大小 - if total_bytes >= self.MAX_OUTPUT_BYTES: - truncated = True - process.terminate() - output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n")) - break - - # 读取一行输出 - line = process.stdout.readline() - if not line and process.poll() is not None: - break - if line: - line_bytes = len(line.encode('utf-8', errors='replace')) - - if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated: - allowed = self.MAX_OUTPUT_BYTES - total_bytes - if allowed > 0: - truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace') - output_queue.put(("line", truncated_line)) - output_lines.append(truncated_line) - total_bytes = self.MAX_OUTPUT_BYTES - truncated = True - process.terminate() - output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n")) - break - - stripped = line.rstrip('\n') - output_queue.put(("line", stripped + "\n")) - output_lines.append(line) - total_bytes += line_bytes + if timeout_occurred: + output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n")) + elif truncated: + output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n")) # 如果超时或截断,确保进程被终止 if (timeout_occurred or truncated) and process.poll() is None: @@ -317,10 +312,8 @@ class SubprocessCommandExecutor(CommandExecutor): else: output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) - # 准备完整结果 - full_output = "".join(output_lines) data = { - "输出": full_output.strip() if full_output.strip() else "(无输出)", + "输出": output_str.strip() if output_str.strip() else "(无输出)", "返回码": return_code } diff --git a/aibox/tests/_poc_audit_regression.py b/aibox/tests/_poc_audit_regression.py new file mode 100644 index 0000000..474c3b8 --- /dev/null +++ b/aibox/tests/_poc_audit_regression.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""审计修复 PoC 回归验证(H1/H2/H3 + 1MB + cancel)。 + +验证 2026-08-17 审计五项修复的实际运行效果: + PoC-1 (H2): 无输出命令 + timeout=3s → 应 ~3s 返回超时(修复前阻塞 15s+) + PoC-2 (H3): 等待 stdin 的命令 + timeout=3s → 应超时返回而非永久挂起 + PoC-3 : 输出 >1MB → 应截断且总输出 ≈1MB + PoC-4 : cancel() 应在宽限时间内终止长命令 + PoC-5 (H1) : CommandLineTool(工具层,非 executor)跑 >1MB 输出 → + 截断生效即证明已走 executor 委托路径 +""" + +import queue +import sys +import threading +import time +import types +from pathlib import Path + +AIBOX_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(AIBOX_DIR)) +sys.modules.setdefault("websockets", types.ModuleType("websockets")) + +from subsystems.executors import SubprocessCommandExecutor +from tools.command_tool import CommandLineTool + +results = [] + + +def check(name, ok, detail): + results.append((name, ok, detail)) + print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}") + + +# ---------- PoC-1 (H2): 无输出命令超时 ---------- +ex = SubprocessCommandExecutor() +t0 = time.time() +r = ex.execute("powershell -NoProfile -Command Start-Sleep 15", timeout=3) +elapsed = time.time() - t0 +check("PoC-1 无输出命令超时(H2)", elapsed < 6 and "超时" in r, + f"{elapsed:.1f}s 返回(修复前阻塞15s+),结果含超时: {'超时' in r}") + +# ---------- PoC-2 (H3): 等待 stdin 的命令 ---------- +# stdin=DEVNULL 下命令立即收到 EOF:要么快速退出,要么按 timeout 返回; +# 判定标准 = 不永久挂起(修复前 readline 无 EOF 永久阻塞)。 +t0 = time.time() +r = ex.execute("powershell -NoProfile -Command Read-Host", timeout=3) +elapsed = time.time() - t0 +check("PoC-2 stdin等待命令不挂起(H3)", elapsed < 6, + f"{elapsed:.1f}s 返回(修复前永久挂起)") + +# ---------- PoC-3: 1MB 输出截断 ---------- +t0 = time.time() +r = ex.execute( + 'powershell -NoProfile -Command "$s=\'x\'*2048; 1..1024 | ForEach-Object { $s }"', + timeout=60) +elapsed = time.time() - t0 +size_mb = len(r.encode("utf-8", errors="replace")) / 1024 / 1024 +check("PoC-3 1MB输出截断", "截断" in r and size_mb < 1.15, + f"{elapsed:.1f}s,结果 {size_mb:.2f}MB,含截断标记: {'截断' in r}") + +# ---------- PoC-4: cancel() 中断 ---------- +out_q = queue.Queue() +done = threading.Event() + + +def _run(): + ex.execute_streaming( + "powershell -NoProfile -Command Start-Sleep 60", + out_q, timeout=120) + done.set() + + +t0 = time.time() +th = threading.Thread(target=_run, daemon=True) +th.start() +time.sleep(2) +ex.cancel() +done.wait(timeout=10) +elapsed = time.time() - t0 +check("PoC-4 cancel中断", done.is_set() and elapsed < 8, + f"cancel 后 {elapsed:.1f}s 结束(修复前杀不到进程需等满120s)") + +# ---------- PoC-5 (H1): 工具层委托 executor(截断保护贯通) ---------- +tool = CommandLineTool(executor=ex) +t0 = time.time() +r = tool.execute( + command='powershell -NoProfile -Command "$s=\'y\'*2048; 1..1024 | ForEach-Object { $s }"', + timeout=60) +elapsed = time.time() - t0 +size_mb = len(r.encode("utf-8", errors="replace")) / 1024 / 1024 +check("PoC-5 工具层1MB贯通(H1)", "截断" in r and size_mb < 1.15, + f"{elapsed:.1f}s,工具层结果 {size_mb:.2f}MB(修复前无限累积)") + +# ---------- 汇总 ---------- +fails = [n for n, ok, _ in results if not ok] +print("\n==== 汇总: %d/%d PASS ====" % (len(results) - len(fails), len(results))) +sys.exit(1 if fails else 0) diff --git a/aibox/tools/basic_tools.py b/aibox/tools/basic_tools.py index 1ca9f3d..e656d2d 100644 --- a/aibox/tools/basic_tools.py +++ b/aibox/tools/basic_tools.py @@ -175,8 +175,15 @@ class WriteFileTool(FileToolBase): dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else "" # 内容写入使用 heredoc,避免引号/特殊字符转义问题 + # M1 修复(审计 2026-08-17):定界符唯一化——若 content 中含独立行 + # 与定界符相同(如 "AIBOX_EOF"),heredoc 会提前终止,其后内容被 + # 当作 shell 命令执行(注入)。冲突时递增后缀直到唯一。 + delimiter = "AIBOX_EOF" + content_lines = {line.strip() for line in content.split("\n")} + while delimiter in content_lines: + delimiter += "_X" redirect = ">" if mode == "w" else ">>" - write_cmd = f"cat {redirect} {quoted} <<'AIBOX_EOF'\n{content}\nAIBOX_EOF" + write_cmd = f"cat {redirect} {quoted} <<'{delimiter}'\n{content}\n{delimiter}" if dir_cmd: write_cmd = f"{dir_cmd} && {write_cmd}" diff --git a/aibox/tools/command_tool.py b/aibox/tools/command_tool.py index 527a38b..fbe112b 100644 --- a/aibox/tools/command_tool.py +++ b/aibox/tools/command_tool.py @@ -5,14 +5,15 @@ tools.command_tool - 命令行执行工具 抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。 工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。 +H1 修复(审计 2026-08-17):此前本类内置一套重复的 Popen 读取循环, +完全绕过注入的 executor,导致 1MB 输出上限、cancel() 三层中断、 +超时机制三项保护同时失效。现改为纯委托,行为与 executor 完全一致。 + 依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors 不反向依赖 aibox 任何符号。 """ -import os import queue -import subprocess -import time from typing import Dict from common import ConfigManager @@ -60,163 +61,21 @@ class CommandLineTool(Tool): } def execute_streaming(self, output_queue: queue.Queue, **kwargs): - """流式执行命令,支持超时控制""" + """流式执行命令:纯委托给 executor(1MB 上限/超时/cancel 全部生效)""" command = kwargs.get("command") - timeout = kwargs.get("timeout", 30) - work_dir = kwargs.get("work_dir", ".") - extra_env = kwargs.get("env", {}) if not command: output_queue.put(("line", "❌ 错误:缺少命令参数\n")) output_queue.put(("complete", "❌ 错误:缺少命令参数")) return - # 发送开始执行的消息 - output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) - output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) - if work_dir != ".": - output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) - output_queue.put(("line", "-" * 50 + "\n")) - - # 准备环境变量 - env = os.environ.copy() - if extra_env: - env.update(extra_env) - - try: - # 使用 subprocess.Popen 实现流式输出,支持超时 - process = subprocess.Popen( - command, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - stdin=subprocess.PIPE, - text=True, - bufsize=1, - universal_newlines=True, - env=env, - cwd=os.path.expanduser(work_dir) - ) - - start_time = time.time() - output_lines = [] - timeout_occurred = False - - # 实时读取输出 - while True: - # 检查超时 - elapsed = time.time() - start_time - if timeout > 0 and elapsed > timeout: - process.terminate() - timeout_occurred = True - output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒),已终止\n")) - break - - # 使用非阻塞方式读取一行 - import select - import sys - - # 检查是否有输出可读 - if sys.platform == 'win32': - # Windows 下使用 readline 带超时 - try: - import msvcrt - if msvcrt.kbhit(): - pass - line = process.stdout.readline() - if not line and process.poll() is not None: - break - if line: - line = line.rstrip('\n') - output_queue.put(("line", line + "\n")) - output_lines.append(line) - else: - time.sleep(0.05) - except: - line = process.stdout.readline() - if not line and process.poll() is not None: - break - if line: - line = line.rstrip('\n') - output_queue.put(("line", line + "\n")) - output_lines.append(line) - else: - # Unix 系统可以使用 select - import select - rlist, _, _ = select.select([process.stdout], [], [], 0.5) - if rlist: - line = process.stdout.readline() - if not line and process.poll() is not None: - break - if line: - line = line.rstrip('\n') - output_queue.put(("line", line + "\n")) - output_lines.append(line) - else: - # 检查进程是否结束 - if process.poll() is not None: - # 读取剩余输出 - remaining = process.stdout.read() - if remaining: - remaining = remaining.rstrip('\n') - if remaining: - output_queue.put(("line", remaining + "\n")) - output_lines.append(remaining) - break - - # 如果超时,确保进程被终止 - if timeout_occurred and process.poll() is None: - process.kill() - process.wait(timeout=2) - - # 获取返回码 - return_code = process.returncode if process.returncode is not None else -1 - - output_queue.put(("line", "-" * 50 + "\n")) - - if timeout_occurred: - output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n")) - else: - output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) - - # 准备完整结果 - full_output = "\n".join(output_lines) - data = { - "输出": full_output if full_output else "(无输出)", - "返回码": return_code - } - - if timeout_occurred: - result = self.format_result(False, f"命令执行超时({timeout}秒)", data) - else: - result = self.format_result(return_code == 0, "命令执行完成", data) - - output_queue.put(("complete", result)) - - except subprocess.TimeoutExpired: - error_msg = f"❌ 命令执行超时({timeout}秒)" - output_queue.put(("line", error_msg + "\n")) - output_queue.put(("complete", error_msg)) - except FileNotFoundError as e: - error_msg = f"❌ 命令执行错误: 命令未找到 - {str(e)}" - output_queue.put(("line", error_msg + "\n")) - output_queue.put(("complete", error_msg)) - except PermissionError as e: - error_msg = f"❌ 命令执行错误: 权限不足 - {str(e)}" - output_queue.put(("line", error_msg + "\n")) - output_queue.put(("complete", error_msg)) - except Exception as e: - error_msg = f"❌ 执行错误: {str(e)}" - output_queue.put(("line", error_msg + "\n")) - output_queue.put(("complete", error_msg)) - finally: - # 确保进程被清理 - if 'process' in locals() and process and process.poll() is None: - try: - process.terminate() - process.wait(timeout=2) - except: - process.kill() + self.executor.execute_streaming( + command, + output_queue, + timeout=kwargs.get("timeout", 30), + work_dir=kwargs.get("work_dir", "."), + env=kwargs.get("env"), + ) def execute(self, command: str, timeout: int = 30, work_dir: str = ".", env: Dict = None, **kwargs) -> str: diff --git a/aibox/tools/registry.py b/aibox/tools/registry.py index f8d86b9..106b6a6 100644 --- a/aibox/tools/registry.py +++ b/aibox/tools/registry.py @@ -84,10 +84,19 @@ class ToolRegistry: ) thread.daemon = True thread.start() - - # 从队列收集输出并发送到当前队列 + + # M2 修复(审计 2026-08-17):转发循环加总超时。 + # 此前 while thread.is_alive() 在工具内部卡死(如子进程 + # 等待 stdin)时永不退出;超时后放弃等待,避免本调用 + # 线程被连带挂起。 + tool_timeout = 3600 # 单工具总上限 1 小时(工具自身的秒级超时之外的兜底) + deadline = time.time() + tool_timeout final_result = None while thread.is_alive() or not output_queue.empty(): + if time.time() > deadline: + self.logger.warning( + f"工具 {name} 流式执行超过 {tool_timeout}s 总上限,放弃等待") + return f"❌ 工具执行超时(>{tool_timeout}s,已放弃等待)" try: msg_type, content = output_queue.get(timeout=0.1) if msg_type == "line": diff --git a/docs/reports/aibox_audit_20260817.md b/docs/reports/aibox_audit_20260817.md new file mode 100644 index 0000000..048fac6 --- /dev/null +++ b/docs/reports/aibox_audit_20260817.md @@ -0,0 +1,128 @@ +# aibox 模块安全与质量审计报告 + +- **日期**:2026-08-17 +- **审计基线**:HEAD `ce142fe`(Batch A–D 落地后) +- **审计范围**:`aibox.py`(~3150 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/` +- **方法**:逐文件代码审读 + PoC 动态验证(H2 已实证) + +--- + +## 一、总体结论 + +架构设计(分层、参数化 SQL、密钥隔离、消息回滚)整体健康,但**命令执行链存在一条系统性断裂**:实际运行路径绕过了精心设计的执行器,导致 1MB 硬约束、超时、中断三项保护同时失效。 + +--- + +## 二、高危发现(3 项) + +### H1:`run_command` 实际路径绕过注入的 executor——三重保护同时失效 + +证据链: +- `aibox.py:1012` 将 `command_executor` 注入 `CommandLineTool` +- 但 `tools/command_tool.py:86-219` 的 `execute_streaming` 自带一套 **150 行的重复 Popen 实现**,`self.executor` 全程未使用(死注入) +- `tools/registry.py:71-86` 检测到 `execute_streaming` 即走该路径——**生产 100% 命中** + +后果: + +| 保护 | 设计位置 | 实际状态 | +|---|---|---| +| 1MB 输出上限(硬约束) | `executors.py:69` | **失效**——command_tool 的 `output_lines` 无限累积 | +| `cancel()` 中断 | `executors.py:76-86` 只 kill `_current_process` | **失效**——实际进程引用只在 command_tool 局部变量,三层中断的第三层对 run_command 不生效 | +| 超时/截断统一语义 | executors | 旁路(见 H2) | + +附带问题:`msvcrt.kbhit()` 检查的是**当前进程**键盘而非子管道(`command_tool.py:123-125` 无意义代码);循环内重复 `import select`。 + +### H2:超时机制对无输出命令完全失效(PoC 已证) + +`readline()` 是阻塞调用,超时检查只在**两次 readline 之间**执行。无输出命令会让 readline 阻塞到进程自然退出: + +``` +PoC:timeout=3s,命令 powershell Start-Sleep 15(无 stdout 输出) +结果:readline blocked 15.2s (timeout was 3s) +VERDICT: timeout mechanism ineffective for silent commands: True +``` + +两处同病:`command_tool.py:106-165` 与 `executors.py:97-113`。`--once` 模式有 `--once-timeout` 兜底,**WebSocket 模式无兜底**。 + +### H3:`stdin=subprocess.PIPE` 从不写入也不关闭——死锁放大器 + +`executors.py:156`、`command_tool.py:93`:等待 stdin 的命令(`cat` 无参、交互式 `python`、`ssh`)永不退出 → 与 H2 叠加:**readline 永久阻塞 + 超时检测永不执行 + cancel 杀不到进程** = 工具线程永久挂起。应为 `stdin=subprocess.DEVNULL`。 + +--- + +## 三、中危发现(4 项) + +| # | 问题 | 位置 | 说明 | +|---|---|---|---| +| M1 | **heredoc 定界符注入** | `basic_tools.py:179` | `content` 含独立行 `AIBOX_EOF` 时 heredoc 提前终止,**后续行成为 shell 命令执行**。AI 写文件内容是不可信输入(可被网页内容/用户消息诱导)。修复:定界符唯一化(检测冲突则换 `AIBOX_EOF_2` 递增) | +| M2 | 工具流式转发循环可永久挂起 | `registry.py:90-107` | `while thread.is_alive() or not empty()` + `get(0.1)`——H3 场景下永不退出;`join(5)` 超时后返回但 daemon 线程继续向输出队列写(僵尸输出混入后续对话流) | +| M3 | web 工具无协议/目标校验 | `basic_tools.py:315-338` | `url` 未限制 scheme,`curl file:///etc/shadow`、内网地址均可达(SSRF)。本地单用户产品风险有限,建议白名单 http/https | +| M4 | shlex.quote POSIX 转义在 Windows 失效 | `basic_tools.py:99-112` 等全部指引命令 | 单引号在 cmd.exe 无效,Windows 下 `head/sed/grep` 指引命令执行异常。Linux 部署(树莓派)不受影响 | + +--- + +## 四、低危发现(3 项) + +| # | 问题 | 位置 | +|---|---|---| +| L1 | `get_pending_memos` 的 `LIMIT {limit}` f-string 拼接 | `db.py:417`——当前调用链 limit 均为内部 int,无外部直达;防御性参数化即可 | +| L2 | `check_path_safety` 仅查大小,无路径边界 | `base.py:96-110`——by design(AI 可全盘读写,本地单用户),已确认设计意图 | +| L3 | `executors.execute()` finally kill 后未 `wait()` | `executors.py:205-207`——Windows 句柄短暂滞留 | + +**做得好的**:SQL 全面参数化(LIKE 通配符正确转义 `db.py:200-209`)、API key 零日志泄漏、WAL 已启用、消息回滚/tool 配对修复、`_msg_lock`/`_process_lock` 使用正确、工具实例按连接隔离。 + +--- + +## 五、架构合规矩阵 + +| 硬约束 | 状态 | +|---|---| +| WebSocket :8765 | ✅ | +| 依赖单向 `common←subsystems←tools←aibox` | ✅ | +| Worker 仅 7 执行工具 | ✅ | +| 工具输出 ≤1MB + 超时 + 中断 | ❌ **H1/H2/H3 三项全破** | +| prompt 三级排序加载 / sqlite 线程安全 | ✅ | + +--- + +## 六、修复建议(一批搞定 H1/H2/H3,约 ±120 行) + +1. **删除 command_tool.py 的重复 Popen 实现**,`execute_streaming` 改为一行委托 `self.executor.execute_streaming(output_queue, ...)` → 1MB、cancel、统一语义全部自动恢复 +2. **executors.py 读取循环改线程化**:后台 reader 线程做阻塞 `readline` 入队列,主循环带超时轮询队列——跨平台解决 H2 +3. `stdin=subprocess.DEVNULL`(H3) +4. M1 定界符唯一化 + M2 转发循环加总超时,顺手可修 + +--- + +## 七、修复落地(Batch E,2026-08-17) + +五项修复(H1/H2/H3/M1/M2)已全部实施并通过验证: + +| # | 修复 | 位置 | 状态 | +|---|---|---|---| +| H1 | `CommandLineTool.execute_streaming` 改为纯委托 `executor.execute_streaming`,删除 150 行重复 Popen(-165 行) | `tools/command_tool.py` | ✅ | +| H2 | `_read_with_limit` 线程化:后台 reader 线程阻塞 `readline` 入队,主循环 0.1s 轮询 + 超时/1MB 检查;shell 已死但 reader 无 EOF 时 1s 宽限排空(Windows shell=True 孙进程持管道场景) | `subsystems/executors.py` | ✅ | +| H3 | 两处 Popen 均改 `stdin=subprocess.DEVNULL` | `subsystems/executors.py` | ✅ | +| M1 | heredoc 定界符唯一化:content 含独立行与 `AIBOX_EOF` 相同时递增 `_X` 后缀直到唯一 | `tools/basic_tools.py` | ✅ | +| M2 | registry 转发循环加 3600s 总超时兜底,超时后放弃等待并返回错误信息 | `tools/registry.py` | ✅ | + +### PoC 回归验证(`aibox/tests/_poc_audit_regression.py`,5/5 PASS) + +``` +[PASS] PoC-1 无输出命令超时(H2): 3.0s 返回(修复前阻塞15s+),结果含超时: True +[PASS] PoC-2 stdin等待命令不挂起(H3): 0.2s 返回(修复前永久挂起) +[PASS] PoC-3 1MB输出截断: 0.2s,结果 1.00MB,含截断标记: True +[PASS] PoC-4 cancel中断: cancel 后 3.0s 结束(修复前杀不到进程需等满120s) +[PASS] PoC-5 工具层1MB贯通(H1): 0.2s,工具层结果 1.00MB(修复前无限累积) +``` + +PoC-2 说明:`stdin=DEVNULL` 下等待输入的命令立即收到 EOF 快速退出(0.2s),优于按超时返回;判定标准为"不永久挂起"。 + +### 单元测试 + +aibox 全量单测 **264 passed**(零回归)。 + +### 遗留项(本轮未处理) + +- M3(web 工具 scheme/目标校验)、M4(Windows 下 shlex.quote 转义失效)、L1/L3(防御性加固)——低优先级,待后续批次 +- 架构合规矩阵"工具输出 ≤1MB + 超时 + 中断"一项修复后为 ✅