feat(windows): 沙箱最小根文件系统与崩溃排查调试埋点

This commit is contained in:
JOJO 2026-07-31 17:51:05 +08:00
parent 77be044c44
commit 04c9db8a5b
5 changed files with 206 additions and 37 deletions

View File

@ -252,15 +252,15 @@ class ModeMixin:
"- 命令解释器Linux bash例如可用 ls、cat、grep、find、git、python3、curl 等常见 Linux 工具)\n"
f"- 当前工作区:{ws_win}Windows 视角)= {ws_wsl}(沙箱内视角)\n"
f"- 命令的工作目录已默认落在 {ws_wsl},操作工作区内文件请直接使用相对路径\n"
"- 引用其他 Windows 路径时必须转换:盘符小写、反斜杠变正斜杠,例如 D:\\tools\\a.txt → /mnt/d/tools/a.txt\n"
"- 沙箱内只能访问当前工作区:其他 Windows 路径(如 D:\\tools\\a.txt未挂载进沙箱、根本不存在需要操作时请把文件复制进工作区或请用户调整工作区\n"
"- 【禁止】Windows 程序在沙箱内不存在cmd、powershell、bat 脚本、.exe、Windows 版 python/node 均无法运行\n"
"- 【禁止】不要写 Windows 风格路径bash 会把反斜杠当作转义符\n"
"\n"
"### 文件权限\n"
"- 工作区内:可读可写\n"
"- 工作区外:全部只读(包括 Windows 各盘与 Linux 系统目录),写入会报 Read-only file system\n"
"- /tmp 与 HOME 目录可写npm/pip 等工具的缓存可正常使用)\n"
"- 用户敏感目录(~/.ssh 等凭据)已被屏蔽,不可读取"
"- 工作区外:数据在沙箱内不存在Windows 各盘与其余目录均未挂载),访问报 No such file or directory仅通往工作区的空壳目录链可见\n"
"- Linux 系统目录(/bin /usr /lib /etc 等,沙箱自带工具链):只读\n"
"- /tmp 与 HOME 目录可写npm/pip 等工具的缓存可正常使用)"
)
def _build_windows_mode_rules(self, mode: str) -> str:
@ -268,7 +268,7 @@ class ModeMixin:
if mode == "sandbox":
return (
"- 所有命令默认在 WSL2 Linux 沙箱中执行\n"
"- 命令报 Read-only file system / Permission denied:说明操作超出沙箱授权边界,这是用户刻意设置的。不要尝试绕过(换路径、提权等),应向用户说明并请求调整权限或切换执行环境\n"
"- 命令报 Read-only file system / Permission denied,或访问工作区外路径报 No such file or directory:说明操作超出沙箱授权边界,这是用户刻意设置的。不要尝试绕过(换路径、提权等),应向用户说明并请求调整权限或切换执行环境\n"
"- 命令报 command not found先检查是否误用了 Windows 程序Linux 工具缺失时可建议用户安装\n"
"- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行"
)
@ -297,11 +297,11 @@ class ModeMixin:
"你后续的终端命令将不再在 Windows 中执行,而是在 WSL2 Linux 沙箱中以 bash 执行。请立即调整命令写法:\n"
"\n"
"1. 解释器变为 Linux bash使用 ls、cat、grep 等 Linux 命令cmd、powershell、.exe 等 Windows 程序从此刻起不可用\n"
f"2. 路径必须转换:当前工作区为 {ws_wsl}(即 Windows 的 {ws_win}),工作目录已默认落在此处,工作区内操作请用相对路径;其他 Windows 路径按 D:\\a\\b → /mnt/d/a/b 转换\n"
f"2. 路径必须转换:当前工作区为 {ws_wsl}(即 Windows 的 {ws_win}),工作目录已默认落在此处,工作区内操作请用相对路径;沙箱内只能访问工作区,其他 Windows 路径均未挂载、无法读写\n"
"3. 禁止再写 Windows 风格路径,反斜杠会被 bash 当作转义符\n"
"4. 写权限:仅工作区、/tmp、HOME 可写,其余全部只读\n"
"4. 写权限:仅工作区、/tmp、HOME 可写Linux 系统目录只读;其余路径在沙箱内不存在\n"
f"{network_part}"
f"{final_no}. 遇到 Read-only file system / Permission denied 不要绕过,向用户说明并请求调整\n"
f"{final_no}. 遇到 Read-only file system / Permission denied / 区外路径 No such file or directory 不要绕过,向用户说明并请求调整\n"
"\n"
"此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。"
)

View File

@ -18,6 +18,11 @@ from modules.host_sandbox_runner import (
build_host_sandbox_plan,
host_sandbox_enabled,
)
from modules import sbx_debug # [SBX-DEBUG]
try: # [SBX-DEBUG]
sbx_debug.install_ctrl_event_probes("bg_manager") # [SBX-DEBUG]
except Exception: # [SBX-DEBUG]
pass # [SBX-DEBUG]
TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"}
@ -289,6 +294,7 @@ class BackgroundCommandManager:
rec["pid"] = process.pid
rec["updated_at"] = time.time()
self._processes[command_id] = process
sbx_debug.sbx_log("BG-SPAWNED", pid=getattr(process, "pid", None), command=str(command)[:200], timeout=timeout, console_pids=sbx_debug.console_pids()) # [SBX-DEBUG]
def _reader(stream, collector, rec_key: str):
try:
@ -322,6 +328,7 @@ class BackgroundCommandManager:
status = "timeout"
message = f"命令执行超时 ({timeout}秒)"
# 跨平台终止POSIX killpg(SIGINT→SIGKILL)Windows CTRL_BREAK→taskkill
sbx_debug.sbx_log_stack("BG-TIMEOUT-TERMINATE", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
self._terminate_pid(process.pid)
try:
process.wait(timeout=2)
@ -429,6 +436,7 @@ class BackgroundCommandManager:
return True
def _terminate_pid(self, pid: Any) -> bool:
sbx_debug.sbx_log_stack("TERMINATE-PID", pid=pid) # [SBX-DEBUG]
normalized = self._coerce_pid(pid)
if not normalized:
return False

View File

@ -7,12 +7,11 @@ import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional
from modules.host_sandbox_policy import (
get_macos_writable_paths,
get_macos_deny_read_paths,
get_macos_deny_read_regexes,
get_windows_deny_read_paths,
)
@ -395,13 +394,21 @@ def _build_linux_common_plan(
# ──────────────────────────────────────────────────────────────
# WindowsWSL2 + bubblewrap 沙箱
#
# 设计要点(依据 .wsl-poc 概念验证,见 wsl2-sandbox-poc-report.md
# 设计要点(依据 .wsl-poc 与 .wsl-exp 两轮实验,见 wsl2-sandbox-poc-report.md
# 与项目记忆 wsl_sandbox_minimal_root
# - 使用专用沙箱发行版(默认 astrion-sandbox必须关闭 interop
# 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机;
# - 隔离原语与 Linux 方案同构bwrap --unshare-all + ro-bind / + 工作区 bind
# - 最小根文件系统:只挂载发行版的 Linux 系统目录(/bin /sbin /usr /lib /etc
# 纯工具链、无用户数据)+ 工作区 bind不挂载 / 本身与任何 /mnt/<盘>
# 工作区外的数据在命名空间内根本不存在(报 No such file or directory
# bwrap 是挂载命名空间构造器而非访问过滤器,因此可以实现 macOS Seatbelt
# 做不到的“指令正常运行 + 默认拒绝的完美读限制”;
# - bwrap 为挂载点自动创建的中间父目录是会话内可写 tmpfs不落盘、无安全
# 问题但缺报错语义),启动后先 mount -o remount,ro / 恢复只读报错;
# - 网络档位full → --share-netrestricted仅回环/ none → 不 share-net
# unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted
# - 敏感路径用 --tmpfs目录/ --ro-bind /dev/null文件掩蔽
# - 敏感路径掩蔽清单windows_deny_read_paths不再需要数据根本不进命名
# 空间;该清单仍由 server/chat/permission.py 用于原生读工具的禁读判断;
# - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。
# ──────────────────────────────────────────────────────────────
@ -468,26 +475,6 @@ def _ensure_wsl_sandbox_distro() -> str:
return name
def _windows_deny_read_targets() -> List[Tuple[str, bool]]:
"""敏感路径掩蔽清单:返回 ``(wsl路径, 是否目录)``,仅包含实际存在的路径。"""
targets: List[Tuple[str, bool]] = []
for raw in get_windows_deny_read_paths():
try:
p = Path(raw).expanduser()
except Exception:
continue
if not p.is_absolute():
continue
try:
if p.is_dir():
targets.append((_win_path_to_wsl(p), True))
elif p.exists():
targets.append((_win_path_to_wsl(p), False))
except OSError:
continue
return targets
def _build_windows_bwrap_argv(
ws_wsl: str,
shell_cmd: List[str],
@ -504,16 +491,26 @@ def _build_windows_bwrap_argv(
# full → 共享网络restricted仅回环与 none → unshare-netlo 仍可用)
if permission == NETWORK_PERMISSION_FULL:
argv.append("--share-net")
argv += ["--ro-bind", "/", "/"]
# 最小根文件系统:只挂沙箱发行版的 Linux 系统目录(纯工具链、无用户数据)。
# 不挂载 / 本身与任何 /mnt/<盘>——工作区外的数据在命名空间内不存在。
# 注意:若日后改用 glibc 发行版(如 Ubuntu需补 --ro-bind /lib64 /lib64。
for sysdir in ("/bin", "/sbin", "/usr", "/lib", "/etc"):
argv += ["--ro-bind", sysdir, sysdir]
argv += (["--ro-bind"] if readonly else ["--bind"]) + [ws_wsl, ws_wsl]
for target, is_dir in _windows_deny_read_targets():
argv += ["--tmpfs", target] if is_dir else ["--ro-bind", "/dev/null", target]
argv += [
"--chdir", ws_wsl,
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--tmpfs", "/var/tmp",
"--dir", "/root",
"--",
# bwrap 为挂载点自动创建的中间父目录(如 /mnt/e是会话内可写 tmpfs
# (写入不落盘、退出即消失,无安全问题),但写入不报错、与 mac 的审批
# 关键词语义不一致;启动后先把根 remount 为只读,再 exec 真正的命令。
# $0="bwrap-sh" 仅作占位,$@ 从 shell_cmd 开始exec "$@" 按 argv 原样
# 透传,避免对用户命令做字符串拼接(切勿加 shift否则会丢掉 argv[0])。
"bash", "-c", 'mount -o remount,ro / 2>/dev/null; exec "$@"', "bwrap-sh",
*shell_cmd,
]
return argv
@ -534,8 +531,11 @@ def _build_windows_wsl_plan(
argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission)
plan_env = dict(env or {})
plan_env["WSL_UTF8"] = "1"
# 必须用 -eexec不经默认 shell而非 ---- 形式会把尾部交给 /bin/sh 重新解析,
# 带空格/引号的参数会被拆散bash -c 后的位置参数全部丢失),-e 原样传递 argv
# .wsl-exp/test_argprobe2.py 实测P4/P6(--) 参数丢失P5/P8(-e) 完整)。
return SandboxPlan(
command=[wsl, "-d", distro, "--", *argv],
command=[wsl, "-d", distro, "-e", *argv],
env=plan_env,
cwd=str(work_path),
stderr_ignore_regexes=list(_WSL_STDERR_IGNORE),

147
modules/sbx_debug.py Normal file
View File

@ -0,0 +1,147 @@
# modules/sbx_debug.py - 沙箱崩溃排查的临时调试日志模块(诊断完成后移除)
#
# 双通道日志:
# 1) UDP 发送到独立监控进程(后端死亡前消息已送达,监控进程独立存活)
# 2) 文件追加 .wsl-exp/sbx_debug.log每行立即落盘保底冗余
# 环境变量:
# SBX_DEBUG=0 关闭全部调试日志
# SBX_DEBUG_PORT=9956 UDP 监控端口
# SBX_DEBUG_SWALLOW_CTRL=0 控制台事件不吞掉(记录后恢复默认行为=真实死亡)
from __future__ import annotations
import datetime
import os
import signal
import socket
import threading
import traceback
_ENABLED = os.environ.get("SBX_DEBUG", "1").strip().lower() not in {"0", "false", "no", "off"}
_PORT = int(os.environ.get("SBX_DEBUG_PORT", "8092") or "8092") # UDP与后端 TCP 端口不冲突
_SWALLOW = os.environ.get("SBX_DEBUG_SWALLOW_CTRL", "1").strip().lower() not in {"0", "false", "no", "off"}
_LOCK = threading.Lock()
_SOCK = None
_PROBES_INSTALLED = False
def _log_path() -> str:
try:
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
except Exception:
root = os.getcwd()
d = os.path.join(root, ".wsl-exp")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return os.path.join(d, "sbx_debug.log")
def _get_sock():
global _SOCK
if _SOCK is None:
try:
_SOCK = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
except Exception:
_SOCK = False
return _SOCK or None
def sbx_log(event: str, **fields) -> None:
"""写一条调试日志(任何异常都吞掉,绝不影响主流程)。"""
if not _ENABLED:
return
try:
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
parts = [f"{k}={fields[k]!r}" for k in sorted(fields)]
line = f"[{ts}] [pid={os.getpid()}] [th={threading.current_thread().name}] {event}"
if parts:
line += " | " + " ".join(parts)
data = (line + "\n").encode("utf-8", "replace")
with _LOCK:
sock = _get_sock()
if sock is not None:
try:
sock.sendto(data, ("127.0.0.1", _PORT))
except Exception:
pass
try:
with open(_log_path(), "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
except Exception:
pass
def sbx_log_stack(event: str, **fields) -> None:
"""写一条带调用堆栈的调试日志。"""
try:
stack = "".join(traceback.format_stack(limit=14)[:-2])
except Exception:
stack = "<no stack>"
fields["stack"] = stack
sbx_log(event, **fields)
def console_pids():
"""返回当前控制台附加的进程 pid 列表Windows用于判断子进程是否共享后端控制台。"""
if os.name != "nt":
return []
try:
import ctypes
arr = (ctypes.c_ulong * 64)()
got = ctypes.windll.kernel32.GetConsoleProcessList(arr, 64)
if not got:
return []
return [int(arr[i]) for i in range(min(got, 64))]
except Exception:
return ["?"]
def install_ctrl_event_probes(tag: str = "backend") -> None:
"""安装 SIGINT/SIGBREAK 探针:控制台事件到达时先记录日志。
SBX_DEBUG_SWALLOW_CTRL=1默认记录后吞掉事件进程存活可继续观察后续行为
=0记录后恢复原处理器语义SIGINTKeyboardInterruptSIGBREAK默认终止
只能在主线程安装失败静默
"""
global _PROBES_INSTALLED
if not _ENABLED or os.name != "nt" or _PROBES_INSTALLED:
return
if threading.current_thread() is not threading.main_thread():
sbx_log("CTRL-PROBES-SKIPPED", reason="not main thread", tag=tag)
return
def _make_handler(sig, name):
prev = signal.getsignal(sig)
def _handler(signum, frame):
sbx_log_stack("CTRL-EVENT-RECEIVED", signal=name, tag=tag, swallowed=_SWALLOW)
if _SWALLOW:
return
if callable(prev):
prev(signum, frame)
return
try:
signal.signal(sig, signal.SIG_DFL)
os.kill(os.getpid(), sig)
except Exception:
os._exit(128 + int(signum))
return _handler
try:
signal.signal(signal.SIGINT, _make_handler(signal.SIGINT, "SIGINT(CTRL_C)"))
except Exception:
pass
try:
sigbreak = getattr(signal, "SIGBREAK", None)
if sigbreak is not None:
signal.signal(sigbreak, _make_handler(sigbreak, "SIGBREAK(CTRL_BREAK)"))
except Exception:
pass
_PROBES_INSTALLED = True
sbx_log("CTRL-PROBES-INSTALLED", tag=tag, swallow=_SWALLOW, port=_PORT)

View File

@ -40,6 +40,11 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan,
host_sandbox_enabled,
)
from modules import sbx_debug # [SBX-DEBUG]
try: # [SBX-DEBUG]
sbx_debug.install_ctrl_event_probes("terminal_ops") # [SBX-DEBUG]
except Exception: # [SBX-DEBUG]
pass # [SBX-DEBUG]
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
@ -56,6 +61,7 @@ class RunMixin:
子进程以 start_new_session=True 启动映射为 CREATE_NEW_PROCESS_GROUP
可用 CTRL_BREAK_EVENT 发送到该进程组失败则直接强杀
"""
sbx_debug.sbx_log_stack("INTERRUPT-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG]
if os.name == "nt":
try:
process.send_signal(signal.CTRL_BREAK_EVENT)
@ -79,6 +85,7 @@ class RunMixin:
POSIX killpg(SIGKILL)Windows 没有 SIGKILL taskkill /F /T 终止整棵进程树
避免 shell 子进程被杀后孙进程残留为孤儿
"""
sbx_debug.sbx_log_stack("KILL-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG]
if os.name == "nt":
try:
await asyncio.to_thread(
@ -161,6 +168,7 @@ class RunMixin:
network_permission: Optional[str] = None,
) -> Dict:
start_ts = time.time()
sbx_debug.sbx_log("RUN-CMD-ENTER", command=str(command)[:200], timeout=timeout, mode=getattr(self, "host_execution_mode", None), sandbox_write=sandbox_write_access) # [SBX-DEBUG]
try:
process = None
exec_cmd = None
@ -234,6 +242,7 @@ class RunMixin:
os.close(seccomp_fd)
except OSError:
pass
sbx_debug.sbx_log("SANDBOX-SPAWNED", pid=getattr(process, "pid", None), argv0=(cmd_args[0] if cmd_args else None), argv_tail=str(cmd_args[-3:])[:160], console_pids=sbx_debug.console_pids()) # [SBX-DEBUG]
elif use_host_sandbox:
return {
"success": False,
@ -298,11 +307,13 @@ class RunMixin:
pass
timed_out = False
sbx_debug.sbx_log("WAIT-BEGIN", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
try:
try:
await asyncio.wait_for(process.wait(), timeout=timeout)
except asyncio.TimeoutError:
timed_out = True
sbx_debug.sbx_log_stack("TIMEOUT-INTERRUPT", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
await self._interrupt_subprocess(process)
try:
await asyncio.wait_for(process.wait(), timeout=2)
@ -311,6 +322,7 @@ class RunMixin:
await process.wait()
except asyncio.CancelledError:
# 用户主动停止任务或会话断开,立即终止子进程
sbx_debug.sbx_log_stack("CANCELLED-KILL", pid=getattr(process, "pid", None)) # [SBX-DEBUG]
await self._kill_subprocess(process)
raise
finally:
@ -319,6 +331,7 @@ class RunMixin:
# Task was destroyed but it is pending / unclosed transport 告警)
await _finish_reader_tasks(force=timed_out)
sbx_debug.sbx_log("WAIT-END", pid=getattr(process, "pid", None), returncode=getattr(process, "returncode", None), timed_out=timed_out, stdout_len=sum(len(c) for c in stdout_buf), stderr_len=sum(len(c) for c in stderr_buf)) # [SBX-DEBUG]
# 非超时场景下兜底再读一次,防止剩余缓冲未被读取
if not timed_out:
try:
@ -385,6 +398,7 @@ class RunMixin:
"elapsed_ms": int((time.time() - start_ts) * 1000)
}
except Exception as exc:
sbx_debug.sbx_log_stack("RUN-EXCEPTION", error=str(exc)[:200]) # [SBX-DEBUG]
return {
"success": False,
"status": "error",