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:
1 parent
ce142fe32f
commit
177a9ac9a6
6 files changed
+317
-223
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
|
||||
|
||||
+12
-153
@@ -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:
|
||||
|
||||
+10
-1
@@ -85,9 +85,18 @@ 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":
|
||||
|
||||
@@ -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 + 超时 + 中断"一项修复后为 ✅
|
||||
Reference in new issue
Block a user