fix(aibox): close audit findings H1-H3/M1-M2 (command exec protection chain)

- H1: CommandLineTool.execute_streaming now purely delegates to injected
  executor; removes 150-line duplicate Popen loop that bypassed 1MB cap,
  cancel() and timeout protections
- H2: thread the blocking readline into a daemon reader thread; main loop
  polls queue with 0.1s ticks so timeout/1MB checks fire even for silent
  commands (verified: 3s timeout on 15s sleep, was 15s+ before)
- H3: stdin=DEVNULL on both Popen sites; stdin-waiting commands now hit
  EOF instead of deadlocking the tool thread forever
- M1: heredoc delimiter uniquification in write_file guidance (content
  line equal to AIBOX_EOF no longer truncates into shell injection)
- M2: 3600s total deadline on registry streaming forward loop

Verification: PoC regression 5/5 PASS (timeout/1MB/cancel/delegation),
full unit suite 264 passed. Audit report updated with Batch E results.
This commit is contained in:
qing committed 2026-08-17 17:28:41 +08:00
1 parent ce142fe32f
commit 177a9ac9a6
6 files changed
+317 -223

No files matched your search

+61 -68
View File
@@ -86,16 +86,36 @@ class SubprocessCommandExecutor(CommandExecutor):
pass pass
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]: 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 = [] output_chunks = []
total_bytes = 0 total_bytes = 0
timeout_occurred = False timeout_occurred = False
truncated = 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: while True:
# 检查超时 # 检查超时(readline 阻塞不再影响本判断)
if timeout > 0 and time.time() - start_time > timeout: if timeout > 0 and time.time() - start_time > timeout:
process.terminate() process.terminate()
timeout_occurred = True timeout_occurred = True
@@ -107,26 +127,37 @@ class SubprocessCommandExecutor(CommandExecutor):
process.terminate() process.terminate()
break break
# 读取一行 try:
line = process.stdout.readline() line = line_queue.get(timeout=0.1)
if not line and process.poll() is not None: shell_dead_since = None
break except queue.Empty:
if line: if reader_done.is_set():
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 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) line_bytes = len(line.encode('utf-8', errors='replace'))
total_bytes += line_bytes 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_chunks.append(line)
output_queue.put(("line", line.rstrip('\n') + "\n")) total_bytes += line_bytes
if output_queue:
output_queue.put(("line", line.rstrip('\n') + "\n"))
return ''.join(output_chunks), timeout_occurred, truncated return ''.join(output_chunks), timeout_occurred, truncated
@@ -153,7 +184,7 @@ class SubprocessCommandExecutor(CommandExecutor):
shell=True, shell=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE, stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True,
@@ -240,7 +271,7 @@ class SubprocessCommandExecutor(CommandExecutor):
shell=True, shell=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE, stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True,
@@ -253,50 +284,14 @@ class SubprocessCommandExecutor(CommandExecutor):
self._current_process = process self._current_process = process
start_time = time.time() start_time = time.time()
output_lines = [] # H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
total_bytes = 0 output_str, timeout_occurred, truncated = self._read_with_limit(
timeout_occurred = False process, timeout, start_time, output_queue=output_queue)
truncated = False
# 实时读取输出 if timeout_occurred:
while True: output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
# 检查超时 elif truncated:
if timeout > 0 and time.time() - start_time > timeout: output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
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 or truncated) and process.poll() is None: if (timeout_occurred or truncated) and process.poll() is None:
@@ -317,10 +312,8 @@ class SubprocessCommandExecutor(CommandExecutor):
else: else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
# 准备完整结果
full_output = "".join(output_lines)
data = { data = {
"输出": full_output.strip() if full_output.strip() else "(无输出)", "输出": output_str.strip() if output_str.strip() else "(无输出)",
"返回码": return_code "返回码": return_code
} }
+98
View File
@@ -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)
+8 -1
View File
@@ -175,8 +175,15 @@ class WriteFileTool(FileToolBase):
dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else "" dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else ""
# 内容写入使用 heredoc,避免引号/特殊字符转义问题 # 内容写入使用 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 ">>" 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: if dir_cmd:
write_cmd = f"{dir_cmd} && {write_cmd}" write_cmd = f"{dir_cmd} && {write_cmd}"
+12 -153
View File
@@ -5,14 +5,15 @@ tools.command_tool - 命令行执行工具
抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。 抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。
工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。 工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。
H1 修复(审计 2026-08-17):此前本类内置一套重复的 Popen 读取循环,
完全绕过注入的 executor,导致 1MB 输出上限、cancel() 三层中断、
超时机制三项保护同时失效。现改为纯委托,行为与 executor 完全一致。
依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors 依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors
不反向依赖 aibox 任何符号。 不反向依赖 aibox 任何符号。
""" """
import os
import queue import queue
import subprocess
import time
from typing import Dict from typing import Dict
from common import ConfigManager from common import ConfigManager
@@ -60,163 +61,21 @@ class CommandLineTool(Tool):
} }
def execute_streaming(self, output_queue: queue.Queue, **kwargs): def execute_streaming(self, output_queue: queue.Queue, **kwargs):
"""流式执行命令,支持超时控制""" """流式执行命令:纯委托给 executor(1MB 上限/超时/cancel 全部生效)"""
command = kwargs.get("command") command = kwargs.get("command")
timeout = kwargs.get("timeout", 30)
work_dir = kwargs.get("work_dir", ".")
extra_env = kwargs.get("env", {})
if not command: if not command:
output_queue.put(("line", "❌ 错误:缺少命令参数\n")) output_queue.put(("line", "❌ 错误:缺少命令参数\n"))
output_queue.put(("complete", "❌ 错误:缺少命令参数")) output_queue.put(("complete", "❌ 错误:缺少命令参数"))
return return
# 发送开始执行的消息 self.executor.execute_streaming(
output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) command,
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) output_queue,
if work_dir != ".": timeout=kwargs.get("timeout", 30),
output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) work_dir=kwargs.get("work_dir", "."),
output_queue.put(("line", "-" * 50 + "\n")) env=kwargs.get("env"),
)
# 准备环境变量
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()
def execute(self, command: str, timeout: int = 30, def execute(self, command: str, timeout: int = 30,
work_dir: str = ".", env: Dict = None, **kwargs) -> str: work_dir: str = ".", env: Dict = None, **kwargs) -> str:
+10 -1
View File
@@ -85,9 +85,18 @@ class ToolRegistry:
thread.daemon = True thread.daemon = True
thread.start() thread.start()
# 从队列收集输出并发送到当前队列 # M2 修复(审计 2026-08-17):转发循环加总超时。
# 此前 while thread.is_alive() 在工具内部卡死(如子进程
# 等待 stdin)时永不退出;超时后放弃等待,避免本调用
# 线程被连带挂起。
tool_timeout = 3600 # 单工具总上限 1 小时(工具自身的秒级超时之外的兜底)
deadline = time.time() + tool_timeout
final_result = None final_result = None
while thread.is_alive() or not output_queue.empty(): 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: try:
msg_type, content = output_queue.get(timeout=0.1) msg_type, content = output_queue.get(timeout=0.1)
if msg_type == "line": if msg_type == "line":
+128
View File
@@ -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 + 超时 + 中断"一项修复后为 ✅