feat(windows): WSL2 沙箱执行环境与执行环境提示词平台拆分

- Windows 宿主机模式基于 WSL2 实现沙箱执行:只读/批准/自动审核识别、
  工作区可写+区外只读挂载、网络档位(受限/开放/禁止)、敏感目录屏蔽
- 新增 scripts/setup-wsl-sandbox.ps1 一键准备 WSL 沙箱发行版
- 执行环境提示词按平台拆分骨架:prompts/execution_mode/macos.txt
  (原 execution_mode.txt,mac/dockerweb 沿用)与 windows.txt
  (骨架+动态注入环境与路径、当前规则、网络档位、切换一致性说明)
- Windows 执行环境切换通知改为完整命令写法说明(双向),mac 保持原样
- 附调研与 PoC 验证报告:wsl2-sandbox-research.md、
  windows-sandbox-research.md、wsl2-sandbox-poc-report.md
This commit is contained in:
JOJO 2026-07-30 13:06:34 +08:00
parent a0ea599c05
commit 42cd99d2d8
13 changed files with 876 additions and 31 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ venv/
# Runtime data (main agent) # Runtime data (main agent)
# 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion/<mode>/。 # 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion/<mode>/。
# 以下条目用于忽略「通过具体目录变量指回源码树」或历史遗留的运行态目录。 # 以下条目用于忽略「通过具体目录变量指回源码树」或历史遗留的运行态目录。
.wsl-poc/
.astrion/ .astrion/
.agents/ .agents/
logs/ logs/

View File

@ -340,7 +340,11 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
if pending_execution != current_exec: if pending_execution != current_exec:
self.set_execution_mode(pending_execution) self.set_execution_mode(pending_execution)
label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(pending_execution, pending_execution) label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(pending_execution, pending_execution)
notices.append({"text": f"执行环境被用户修改为 {label}", "source": "执行环境变更"}) if hasattr(self, "_build_execution_mode_switch_notice"):
notice_text = self._build_execution_mode_switch_notice(pending_execution)
else:
notice_text = f"执行环境被用户修改为 {label}"
notices.append({"text": notice_text, "source": "执行环境变更"})
updates["execution_mode"] = pending_execution updates["execution_mode"] = pending_execution
updates["pending_execution_mode"] = None updates["pending_execution_mode"] = None
self.pending_execution_mode = None self.pending_execution_mode = None

View File

@ -165,9 +165,6 @@ class ModeMixin:
return None return None
except Exception: except Exception:
return None return None
template = self.load_prompt("execution_mode")
if not template:
return None
state = {} state = {}
if hasattr(self, "get_execution_mode_state"): if hasattr(self, "get_execution_mode_state"):
try: try:
@ -176,30 +173,148 @@ class ModeMixin:
state = {} state = {}
mode = str(state.get("mode") or "sandbox").strip().lower() mode = str(state.get("mode") or "sandbox").strip().lower()
mode_label = self._EXECUTION_MODE_LABEL.get(mode, mode) mode_label = self._EXECUTION_MODE_LABEL.get(mode, mode)
import platform
if platform.system() == "Windows":
template = self.load_prompt("execution_mode/windows")
if not template:
return None
return template.format(
execution_mode=mode,
execution_mode_label=mode_label,
environment_rules=self._build_windows_environment_rules(mode),
rules=self._build_windows_mode_rules(mode),
network_rules=self._build_network_rules_line() if mode == "sandbox" else "",
switch_invariant=(
"注意:执行环境可能在对话过程中被用户切换。切换时你会收到一条新的环境说明,"
"与本文不一致时,永远以最新的环境说明为准。"
),
)
template = self.load_prompt("execution_mode/macos")
if not template:
return None
if mode == "sandbox": if mode == "sandbox":
rules = ( rules = (
"- 所有命令默认在系统 OS 沙箱中执行\n" "- 所有命令默认在系统 OS 沙箱中执行\n"
"- 若遇到权限问题:这是用户刻意设置导致的结果,证明用户在当前状况下不允许你执行此命令。不要尝试绕过,你应立刻询问用户并解释原因,请求用户更换执行环境(如切换为“完全访问权限”)" "- 若遇到权限问题:这是用户刻意设置导致的结果,证明用户在当前状况下不允许你执行此命令。不要尝试绕过,你应立刻询问用户并解释原因,请求用户更换执行环境(如切换为“完全访问权限”)"
) )
net = getattr(self, "host_network_permission", "restricted")
if net == "restricted":
network_rules = "- 网络:受限(仅允许 localhost外部网络不可达"
elif net == "full":
network_rules = "- 网络:完全开放"
else:
network_rules = ""
else: else:
rules = ( rules = (
"- 当前为宿主机直接执行模式(完全访问权限)\n" "- 当前为宿主机直接执行模式(完全访问权限)\n"
"- 仅在必须时执行高权限操作,保持最小化命令范围\n" "- 仅在必须时执行高权限操作,保持最小化命令范围\n"
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行" "- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
) )
network_rules = ""
return template.format( return template.format(
execution_mode=mode, execution_mode=mode,
execution_mode_label=mode_label, execution_mode_label=mode_label,
rules=rules, rules=rules,
network_rules=network_rules, network_rules=self._build_network_rules_line() if mode == "sandbox" else "",
)
def _build_network_rules_line(self) -> str:
"""网络档位说明行(两平台共用)。"""
net = getattr(self, "host_network_permission", "restricted")
if net == "restricted":
return "- 网络:受限(仅允许 localhost外部网络不可达"
if net == "full":
return "- 网络:完全开放"
if net == "none":
return "- 网络:完全禁止"
return ""
def _workspace_win_path(self) -> str:
path = str(getattr(self, "project_path", "") or "").strip()
if not path:
path = str(getattr(getattr(self, "context_manager", None), "project_path", "") or "").strip()
return path
def _workspace_wsl_path(self) -> str:
try:
from modules.host_sandbox_runner import _win_path_to_wsl
return _win_path_to_wsl(Path(self._workspace_win_path()).resolve())
except Exception:
return ""
def _build_windows_environment_rules(self, mode: str) -> str:
"""Windows 执行环境说明(系统提示词与切换通知共用,保证两处一致)。"""
ws_win = self._workspace_win_path() or "<工作区>"
if mode != "sandbox":
return (
"### 环境与路径Windows 原生)\n"
"- 命令解释器Windows cmd可用 dir、type、findstr 及系统已安装的所有程序)\n"
f"- 当前工作区:{ws_win}\n"
"- 路径使用 Windows 风格,注意引号内反斜杠的转义"
)
ws_wsl = self._workspace_wsl_path() or "<工作区 WSL 路径>"
return (
"### 环境与路径WSL2 Linux 沙箱)\n"
"- 命令解释器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 程序在沙箱内不存在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 等凭据)已被屏蔽,不可读取"
)
def _build_windows_mode_rules(self, mode: str) -> str:
"""Windows 当前规则段(与 mac 版语义对齐)。"""
if mode == "sandbox":
return (
"- 所有命令默认在 WSL2 Linux 沙箱中执行\n"
"- 命令报 Read-only file system / Permission denied说明操作超出沙箱授权边界这是用户刻意设置的。不要尝试绕过换路径、提权等应向用户说明并请求调整权限或切换执行环境\n"
"- 命令报 command not found先检查是否误用了 Windows 程序Linux 工具缺失时可建议用户安装\n"
"- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行"
)
return (
"- 当前为宿主机直接执行模式(完全访问权限)\n"
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行\n"
"- 操作尽量限制在当前工作区内"
)
def _build_execution_mode_switch_notice(self, mode: str) -> str:
"""执行环境切换通知文本。mac 为一句话Windows 为完整环境说明(与系统提示词共用构建)。"""
label = self._EXECUTION_MODE_LABEL.get(mode, mode)
import platform
if platform.system() != "Windows":
return f"执行环境被用户修改为 {label}"
ws_win = self._workspace_win_path() or "<工作区>"
if mode == "sandbox":
ws_wsl = self._workspace_wsl_path() or "<工作区 WSL 路径>"
network_line = self._build_network_rules_line().lstrip("- ").strip()
network_part = f"5. {network_line}\n" if network_line else ""
final_no = 6 if network_line else 5
return (
"【执行环境已切换】用户已将执行环境切换为沙箱WSL2 Linux\n"
"\n"
"你后续的终端命令将不再在 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"
"3. 禁止再写 Windows 风格路径,反斜杠会被 bash 当作转义符\n"
"4. 写权限:仅工作区、/tmp、HOME 可写,其余全部只读\n"
f"{network_part}"
f"{final_no}. 遇到 Read-only file system / Permission denied 不要绕过,向用户说明并请求调整\n"
"\n"
"此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。"
)
return (
"【执行环境已切换】用户已将执行环境切换为完全访问权限Windows 原生)。\n"
"\n"
"你后续的终端命令将直接在 Windows 宿主机上执行,拥有用户的完整权限。请立即调整命令写法:\n"
"\n"
"1. 解释器为 Windows cmd可使用 dir、type、findstr 及系统已安装的所有程序;未单独安装的 Linux 命令ls、grep 等)不可用\n"
f"2. 路径恢复 Windows 风格:当前工作区为 {ws_win},不要再使用 /mnt/ 形式的 WSL 路径\n"
"3. 你不再处于沙箱边界内:请保持最小化命令范围,操作尽量限制在工作区内;涉及删除/覆盖/系统级变更前,先向用户说明风险\n"
"\n"
"此前的命令如果是按 Linux 沙箱环境编写的,请按上述规则改写后重新执行。"
) )
def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]: def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:

View File

@ -36,6 +36,22 @@ DEFAULT_MACOS_DENY_READ_REGEXES = [
r"^/.*\.env(\.[^/]*)?$", r"^/.*\.env(\.[^/]*)?$",
] ]
# WindowsWSL2+bwrap 沙箱)默认掩蔽的敏感路径(支持 ~/ 前缀)。
# 目录用 tmpfs 整体掩蔽,文件用 /dev/null 掩蔽;仅掩蔽实际存在的路径。
DEFAULT_WINDOWS_DENY_READ_PATHS = [
"~/.ssh",
"~/.aws",
"~/.azure",
"~/.gcp",
"~/.google",
"~/.kube",
"~/.docker",
"~/.gnupg",
"~/.npmrc",
"~/.netrc",
"~/.git-credentials",
]
_LOCK = threading.Lock() _LOCK = threading.Lock()
# 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.astrion/<mode>/config # 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.astrion/<mode>/config
_POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json")) _POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json"))
@ -47,6 +63,7 @@ def _default_policy() -> Dict:
"macos_readable_extra_paths": [], "macos_readable_extra_paths": [],
"macos_deny_read_paths": list(DEFAULT_MACOS_DENY_READ_PATHS), "macos_deny_read_paths": list(DEFAULT_MACOS_DENY_READ_PATHS),
"macos_deny_read_regexes": list(DEFAULT_MACOS_DENY_READ_REGEXES), "macos_deny_read_regexes": list(DEFAULT_MACOS_DENY_READ_REGEXES),
"windows_deny_read_paths": list(DEFAULT_WINDOWS_DENY_READ_PATHS),
} }
@ -81,6 +98,7 @@ def load_policy() -> Dict:
data["macos_readable_extra_paths"] = [str(x).strip() for x in data["macos_readable_extra_paths"] if str(x).strip()] data["macos_readable_extra_paths"] = [str(x).strip() for x in data["macos_readable_extra_paths"] if str(x).strip()]
data["macos_deny_read_paths"] = [str(x).strip() for x in data["macos_deny_read_paths"] if str(x).strip()] data["macos_deny_read_paths"] = [str(x).strip() for x in data["macos_deny_read_paths"] if str(x).strip()]
data["macos_deny_read_regexes"] = [str(x).strip() for x in data["macos_deny_read_regexes"] if str(x).strip()] data["macos_deny_read_regexes"] = [str(x).strip() for x in data["macos_deny_read_regexes"] if str(x).strip()]
data["windows_deny_read_paths"] = [str(x).strip() for x in data["windows_deny_read_paths"] if str(x).strip()]
if needs_save: if needs_save:
try: try:
@ -145,3 +163,15 @@ def get_macos_deny_read_regexes() -> List[str]:
if val and val not in merged: if val and val not in merged:
merged.append(val) merged.append(val)
return merged return merged
def get_windows_deny_read_paths() -> List[str]:
"""WindowsWSL2+bwrap沙箱需要掩蔽的敏感路径列表。"""
data = load_policy()
paths = data.get("windows_deny_read_paths", [])
merged: List[str] = []
for raw in list(DEFAULT_WINDOWS_DENY_READ_PATHS) + list(paths or []):
val = str(raw).strip()
if val and val not in merged:
merged.append(val)
return merged

View File

@ -2,15 +2,17 @@ from __future__ import annotations
import os import os
import platform import platform
import re
import shutil import shutil
import shlex import subprocess
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple
from modules.host_sandbox_policy import ( from modules.host_sandbox_policy import (
get_macos_writable_paths, get_macos_writable_paths,
get_macos_deny_read_paths, get_macos_deny_read_paths,
get_macos_deny_read_regexes, get_macos_deny_read_regexes,
get_windows_deny_read_paths,
) )
@ -20,6 +22,8 @@ class SandboxPlan:
env: Dict[str, str] env: Dict[str, str]
cwd: Optional[str] = None cwd: Optional[str] = None
seccomp_bpf_path: Optional[str] = None seccomp_bpf_path: Optional[str] = None
# 执行器需从 stderr 中过滤的行模式(如 wsl.exe 的 localhost 代理警告)
stderr_ignore_regexes: List[str] = field(default_factory=list)
class HostSandboxError(RuntimeError): class HostSandboxError(RuntimeError):
@ -170,7 +174,7 @@ def build_host_sandbox_readonly_plan(
if system == "Linux": if system == "Linux":
return _build_linux_readonly_plan(command, work_path, env, network_permission) return _build_linux_readonly_plan(command, work_path, env, network_permission)
if system == "Windows": if system == "Windows":
return _build_windows_plan(command, work_path, env, network_permission) return _build_windows_readonly_plan(command, work_path, env, network_permission)
raise HostSandboxError(f"不支持的宿主机系统: {system}") raise HostSandboxError(f"不支持的宿主机系统: {system}")
@ -388,19 +392,178 @@ def _build_linux_common_plan(
return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path)) return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path))
# ──────────────────────────────────────────────────────────────
# WindowsWSL2 + bubblewrap 沙箱
#
# 设计要点(依据 .wsl-poc 概念验证,见 wsl2-sandbox-poc-report.md
# - 使用专用沙箱发行版(默认 astrion-sandbox必须关闭 interop
# 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机;
# - 隔离原语与 Linux 方案同构bwrap --unshare-all + ro-bind / + 工作区 bind
# - 网络档位full → --share-netrestricted仅回环/ none → 不 share-net
# unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted
# - 敏感路径用 --tmpfs目录/ --ro-bind /dev/null文件掩蔽
# - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。
# ──────────────────────────────────────────────────────────────
WSL_DEFAULT_SANDBOX_DISTRO = "astrion-sandbox"
_WSL_STDERR_IGNORE = [r"localhost 代理", r"localhost proxy"]
# 模块级探测缓存:发行版名 -> 是否已验证可用
_wsl_distro_verified: Dict[str, bool] = {}
def _wsl_distro_name() -> str:
return (os.environ.get("HOST_SANDBOX_WSL_DISTRO", "") or "").strip() or WSL_DEFAULT_SANDBOX_DISTRO
def _win_path_to_wsl(path) -> str:
"""Windows 路径转 WSL 路径:``E:\\a\\b`` → ``/mnt/e/a/b``。"""
raw = str(path)
m = re.match(r"^([A-Za-z]):[\\/](.*)$", raw)
if not m:
raise HostSandboxError(f"无法转换为 WSL 路径(仅支持盘符路径): {raw}")
drive = m.group(1).lower()
rest = m.group(2).replace("\\", "/").rstrip("/")
return f"/mnt/{drive}/{rest}" if rest else f"/mnt/{drive}"
def _ensure_wsl_sandbox_distro() -> str:
"""探测专用沙箱发行版与 bwrap 是否可用,结果按发行版名缓存。"""
name = _wsl_distro_name()
if _wsl_distro_verified.get(name):
return name
wsl = shutil.which("wsl.exe")
if not wsl:
raise HostSandboxError("Windows 未找到 wsl.exeWSL2拒绝执行宿主机命令。")
env = dict(os.environ)
env["WSL_UTF8"] = "1"
setup_hint = (
f"未找到可用的 WSL 沙箱发行版 '{name}'"
"请先运行 scripts/setup-wsl-sandbox.ps1 创建专用沙箱发行版"
"(必须关闭 interop不可用 docker-desktop 或日常 Ubuntu 代替,"
"详见 wsl2-sandbox-poc-report.md"
)
try:
probe = subprocess.run(
[wsl, "-d", name, "-e", "true"],
capture_output=True, timeout=30, env=env,
)
except Exception as exc:
raise HostSandboxError(f"WSL 沙箱发行版探测失败: {exc}{setup_hint}")
if probe.returncode != 0:
raise HostSandboxError(setup_hint)
try:
probe_bwrap = subprocess.run(
[wsl, "-d", name, "-e", "bwrap", "--version"],
capture_output=True, timeout=30, env=env,
)
except Exception as exc:
raise HostSandboxError(f"WSL 沙箱 bwrap 探测失败: {exc}{setup_hint}")
if probe_bwrap.returncode != 0:
raise HostSandboxError(
f"WSL 沙箱发行版 '{name}' 内未安装 bubblewrap请重新运行 "
"scripts/setup-wsl-sandbox.ps1 或在发行版内执行 apk add bubblewrap。"
)
_wsl_distro_verified[name] = True
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],
readonly: bool,
network_permission: Optional[str],
) -> List[str]:
permission = _normalize_network_permission(network_permission)
argv: List[str] = [
"bwrap",
"--die-with-parent",
"--new-session",
"--unshare-all",
]
# full → 共享网络restricted仅回环与 none → unshare-netlo 仍可用)
if permission == NETWORK_PERMISSION_FULL:
argv.append("--share-net")
argv += ["--ro-bind", "/", "/"]
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",
"--",
*shell_cmd,
]
return argv
def _build_windows_wsl_plan(
work_path: Path,
env: Dict[str, str],
shell_cmd: List[str],
readonly: bool,
network_permission: Optional[str],
) -> SandboxPlan:
wsl = shutil.which("wsl.exe")
if not wsl:
raise HostSandboxError("Windows 未找到 wsl.exeWSL2拒绝执行宿主机命令。")
distro = _ensure_wsl_sandbox_distro()
ws_wsl = _win_path_to_wsl(work_path.resolve())
argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission)
plan_env = dict(env or {})
plan_env["WSL_UTF8"] = "1"
return SandboxPlan(
command=[wsl, "-d", distro, "--", *argv],
env=plan_env,
cwd=str(work_path),
stderr_ignore_regexes=list(_WSL_STDERR_IGNORE),
)
def _build_windows_plan( def _build_windows_plan(
command: str, command: str,
work_path: Path, work_path: Path,
env: Dict[str, str], env: Dict[str, str],
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
) -> SandboxPlan: ) -> SandboxPlan:
wsl = shutil.which("wsl.exe") return _build_windows_wsl_plan(
if not wsl: work_path, env, ["bash", "-lc", command], readonly=False,
raise HostSandboxError("Windows 未找到 wsl.exeWSL2拒绝执行宿主机命令。") network_permission=network_permission,
work = shlex.quote(str(work_path)) )
shell = f"cd {work} && {command}"
cmd = [wsl, "bash", "-lc", shell]
return SandboxPlan(command=cmd, env=env, cwd=str(work_path)) def _build_windows_readonly_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
return _build_windows_wsl_plan(
work_path, env, ["bash", "-lc", command], readonly=True,
network_permission=network_permission,
)
def _build_windows_shell_plan( def _build_windows_shell_plan(
@ -408,10 +571,7 @@ def _build_windows_shell_plan(
env: Dict[str, str], env: Dict[str, str],
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
) -> SandboxPlan: ) -> SandboxPlan:
wsl = shutil.which("wsl.exe") return _build_windows_wsl_plan(
if not wsl: work_path, env, ["bash", "-i"], readonly=False,
raise HostSandboxError("Windows 未找到 wsl.exeWSL2拒绝启动宿主机沙箱终端。") network_permission=network_permission,
work = shlex.quote(str(work_path)) )
shell = f"cd {work} && exec bash -i"
cmd = [wsl, "bash", "-lc", shell]
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))

View File

@ -63,3 +63,14 @@ class MiscMixin:
fd_num = seccomp_fd fd_num = seccomp_fd
cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command] cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command]
return cmd, (fd_num,), seccomp_fd return cmd, (fd_num,), seccomp_fd
@staticmethod
def _filter_ignored_stderr_lines(text: str, patterns: list[str]) -> str:
"""按行过滤 stderr 中匹配指定模式的噪音(如 wsl.exe 的 localhost 代理警告)。"""
if not text or not patterns:
return text
kept = [
line for line in text.splitlines(keepends=True)
if not any(re.search(pattern, line) for pattern in patterns)
]
return "".join(kept)

View File

@ -3,6 +3,7 @@
import os import os
import sys import sys
import asyncio import asyncio
import re
import subprocess import subprocess
import shutil import shutil
import time import time
@ -110,6 +111,7 @@ class RunMixin:
process = None process = None
exec_cmd = None exec_cmd = None
use_shell = True use_shell = True
stderr_ignore_regexes: list = []
session = session_override or self.container_session session = session_override or self.container_session
# 如果存在容器会话且模式为docker则在容器内执行 # 如果存在容器会话且模式为docker则在容器内执行
@ -161,6 +163,7 @@ class RunMixin:
plan.command, plan.command,
plan.seccomp_bpf_path, plan.seccomp_bpf_path,
) )
stderr_ignore_regexes = list(getattr(plan, "stderr_ignore_regexes", None) or [])
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd_args, *cmd_args,
@ -298,6 +301,8 @@ class RunMixin:
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else "" stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else "" stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
if stderr_ignore_regexes and stderr_text:
stderr_text = self._filter_ignored_stderr_lines(stderr_text, stderr_ignore_regexes)
success = (process.returncode == 0) and not timed_out success = (process.returncode == 0) and not timed_out
status = "completed" if success else ("timeout" if timed_out else "error") status = "completed" if success else ("timeout" if timed_out else "error")

View File

@ -0,0 +1,9 @@
## 执行环境:{execution_mode_label}{execution_mode}
{environment_rules}
### 当前规则
{rules}
{network_rules}
{switch_invariant}

View File

@ -0,0 +1,83 @@
# setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版
#
# 用法PowerShell普通用户即可无需管理员
# powershell -ExecutionPolicy Bypass -File scripts\setup-wsl-sandbox.ps1
# 可选参数:
# -DistroName 发行版名称(默认 astrion-sandbox需与 HOST_SANDBOX_WSL_DISTRO 一致)
# -InstallDir 发行版 VHDX 安装目录(默认 ~\.astrion\wsl-sandbox
# -RootfsUrl Alpine minirootfs 下载地址(默认阿里云镜像)
# -ApkMirror apk 软件源镜像(默认阿里云)
#
# 设计依据wsl2-sandbox-poc-report.md
# - 必须是专用发行版并关闭 interop否则沙箱内可经 cmd.exe 逃逸到宿主机;
# - 固化公共 DNS规避 localhost 代理导致的 WSL NAT DNS 失效;
# - 内置国内 apk 镜像,避免官方源大包下载卡死。
param(
[string]$DistroName = "astrion-sandbox",
[string]$InstallDir = (Join-Path $env:USERPROFILE ".astrion\wsl-sandbox"),
[string]$RootfsUrl = "https://mirrors.aliyun.com/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.3-x86_64.tar.gz",
[string]$ApkMirror = "https://mirrors.aliyun.com/alpine/v3.21"
)
$ErrorActionPreference = "Stop"
$env:WSL_UTF8 = "1"
function Invoke-Wsl {
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Args)
$output = & wsl.exe @Args 2>&1 | Where-Object { $_ -notmatch "localhost 代理|localhost proxy" }
return @{ Code = $LASTEXITCODE; Output = ($output -join "`n") }
}
Write-Host "==> [1/6] 检查 WSL 环境"
$null = Invoke-Wsl --status
if ($LASTEXITCODE -ne 0) {
throw "WSL 不可用。请先启用 WSL2wsl --install --no-distribution 或安装 Docker Desktop 后重试)。"
}
Write-Host "==> [2/6] 检查发行版 '$DistroName' 是否已存在"
$probe = Invoke-Wsl -d $DistroName -e true
if ($probe.Code -eq 0) {
Write-Host " 发行版已存在跳过导入如需重建wsl --unregister $DistroName"
} else {
Write-Host "==> [3/6] 下载 Alpine rootfs"
$rootfs = Join-Path $env:TEMP "astrion-alpine-minirootfs.tar.gz"
if (-not (Test-Path $rootfs)) {
Invoke-WebRequest -Uri $RootfsUrl -OutFile $rootfs -UseBasicParsing
}
Write-Host " rootfs: $rootfs ($([math]::Round((Get-Item $rootfs).Length/1MB,1)) MB)"
Write-Host "==> [4/6] 导入为 WSL2 发行版"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$null = Invoke-Wsl --import $DistroName $InstallDir $rootfs --version 2
$check = Invoke-Wsl -d $DistroName -e true
if ($check.Code -ne 0) { throw "发行版导入失败: $($check.Output)" }
}
Write-Host "==> [5/6] 写入沙箱配置(关闭 interop / 固定 DNS / 国内镜像)"
# 关闭 Windows 互操作:防止沙箱内经 cmd.exe 逃逸宿主机PoC 已验证该逃逸路径)
$null = Invoke-Wsl -d $DistroName -- sh -c "printf '[network]\ngenerateResolvConf = false\n\n[interop]\nenabled = false\nappendWindowsPath = false\n' > /etc/wsl.conf"
$null = Invoke-Wsl -d $DistroName -- sh -c "rm -f /etc/resolv.conf && printf 'nameserver 223.5.5.5\nnameserver 119.29.29.29\n' > /etc/resolv.conf"
$null = Invoke-Wsl -d $DistroName -- sh -c "printf '$ApkMirror/main\n$ApkMirror/community\n' > /etc/apk/repositories"
# 使 wsl.conf 生效
$null = Invoke-Wsl --terminate $DistroName
Start-Sleep -Seconds 2
Write-Host "==> [6/6] 安装沙箱工具链bubblewrap / bash / python3 / git"
$apk = Invoke-Wsl -d $DistroName -- sh -c "apk update && apk add bubblewrap bash ncurses-libs python3 git"
if ($apk.Code -ne 0) { throw "apk 安装失败: $($apk.Output)" }
# 验收
$verify = Invoke-Wsl -d $DistroName -- sh -c "bwrap --version && bash -c 'echo bash-ok' && python3 --version && git --version"
Write-Host $verify.Output
$escape = Invoke-Wsl -d $DistroName -- bwrap --die-with-parent --new-session --unshare-all --ro-bind / / --proc /proc --dev /dev -- bash -c "cmd.exe /c echo escape 2>&1 || echo interop-blocked"
if ($escape.Output -match "interop-blocked|not found") {
Write-Host " interop 逃逸封堵验证: OK"
} else {
Write-Warning " interop 可能未关闭,请检查 /etc/wsl.conf 后执行 wsl --terminate $DistroName"
}
Write-Host ""
Write-Host "完成。沙箱发行版 '$DistroName' 就绪。"
Write-Host "若使用了非默认名称,请设置环境变量 HOST_SANDBOX_WSL_DISTRO=$DistroName"

168
windows-sandbox-research.md Normal file
View File

@ -0,0 +1,168 @@
# Windows 沙箱执行不可信命令:开源组件/项目调研报告
> 调研日期2026-07以实际检索为准目标场景Python 智能体运行时在 Windows 上执行不可信命令(一次性命令 + 交互式 shell
> 需求速记:**R1** 文件只读模式(工作区以外只读)、**R2** 工作区可写、**R3** 敏感路径禁读、**R4** 网络三档(禁网/仅回环/全开)、**R5** Python asyncio subprocess 可集成。
> 所有结论均来自本轮实际检索,检索不到的已明确标注。
---
## 0. 结论速览(先看这个)
| 方案 | 一句话结论 |
|---|---|
| **OpenAI Codex 的 windows-sandbox-rs** | 需求匹配度最高的"参考答案"restricted token + ACL + WFP 防火墙五条需求全覆盖Apache-2.0,但是 Rust 代码Python 侧需要借鉴重写或把 codex CLI 当子进程调用 |
| **GRR `unprivileged/windows/sandbox_lib.py`** | 唯一一个**纯 Pythonpywin32+ctypes的 AppContainer 沙箱实现**Apache-2.0,核心代码量小,可直接 vendor但无网络控制、写授权模型需补齐 |
| **Sandboxie-Plus** | 隔离粒度最细(文件/注册表/网络逐规则配置命令行可驱动GPL-3.0 且依赖内核驱动,**不能嵌入闭源产品**,只能作为"用户自行安装的外部依赖"调用 |
| **Windows Sandbox.wsb** | 微软官方一次性 VM映射文件夹只读/可写、可禁网,但**无 stdout/stderr 捕获管道、无编程 API**,不适合 agent 自动化 |
| **Windows Containerscontainerd/runhcs** | 不用 Docker 可以跑,但镜像 260MB~5GB+、process isolation 要求镜像与宿主 build 严格匹配,运维成本高,不适合桌面 agent 场景 |
| **Dokan/WinFsp 只读视图** | 只解决 R1只读视图不提供进程隔离与网络控制需叠加 token/ACL属于"可选增强"而非独立方案 |
| **winjail / Firejail Windows 版** | **查不到**,不存在可用品(详见第 3 节) |
**推荐路线**:以 Codex 的 Windows 沙箱设计restricted token + 合成 SID + ACL 授权 + WFP 网络过滤)为蓝本,用 pywin32/ctypes 在 Python 内实现GRR 代码可作起点);把 Sandboxie 作为可选的外部加固后端。
---
## 1. Python 可用的 Windows 沙箱库
### 1.1 GRR `grr_response_client.unprivileged.windows`(本轮最大发现)
- 链接https://github.com/google/grr (模块路径 `grr/client/grr_response_client/unprivileged/windows/sandbox_lib.py`
- 维护状态google/grr 仓库 5084 starApache-2.0,最近提交 2026-05活跃
- 这是什么:一个**纯 Python 的 Windows AppContainer 沙箱库**docstring 原文:"Windows Sandboxing library based on AppContainers… works only on Windows >= 8"。用 ctypes 调 `userenv.CreateAppContainerProfile` / `DeriveAppContainerSidFromAppContainerName`,用 pywin32`win32security`/`win32service`/`ntsecuritycon`)做 ACL 授权,并创建独立的 window station/desktop。
- 能力:`InitSandbox(name, paths_read_only)` 创建 AppContainer 并对指定路径授予 `GENERIC_READ|GENERIC_EXECUTE``Sandbox` 类管理 window station/desktop 生命周期;同目录 `process.py`9KB负责在沙箱里拉起进程。PyInstaller issue #8289 中有真实用户用它把 exe 跑进 AppContainer 的实例。
- 需求覆盖R1 ✅未授权路径默认拒读写、R3 ✅不授权即禁读、R5 ✅(纯 Python可与 asyncio 集成R2 ⚠️(示例只演示只读授权,可写授权需要同样用 ACL 加 `GENERIC_WRITE`代码路径现成R4 ❌(无网络控制,需自行叠加 WFP
- 注意:它不是独立 PyPI 包,需要 vendor 该文件Apache-2.0 允许),依赖 pywin32。
### 1.2 pywin32 本身
- 链接https://github.com/mhammond/pywin32
- 已核实源码包含 `win32job.i`Job ObjectCreateJobObject/SetInformationJobObject/AssignProcessToJobObject、`win32security.i`restricted token、ACL、`win32process.i`。即"restricted token + Job Object + ACL"三件套在 Python 里**不需要写 C 扩展**就能拼出来——这是自研路线的地基。
- 许可PSF 风格,闭源友好。
### 1.3 PyPI 上的候选包(均已核实)
| 包名 | 状态 |
|---|---|
| `sandbox` 0.3.5 | 存在,但简介为 "The Sandbox Libraries (Python)"——是早年 Linux seccomp-nurse 的绑定,**与 Windows 无关** |
| `pyjail` 0.1.4 | 存在MIT是**进程内** Python 代码沙箱(非 OS 级),不满足需求 |
| `winsandbox` / `appcontainer` / `winjail` / `seccomp` | **404不存在** |
结论PyPI 上**没有**成熟的、封装 AppContainer/Job Object/restricted token 的 Windows 沙箱包。成熟度最高的可直接借鉴物就是 GRR 的那个模块。
---
## 2. Sandboxie / Sandboxie-Plus
- 链接https://github.com/sandboxie-plus/Sandboxie 19034 star**GPL-3.0**2026-07-29 仍有提交,非常活跃;社区 fork维护者 David Xanatos项目历史 Ronen Tzur → Invincea → Sophos → 2020 开源)
- 命令行驱动:✅ 有官方文档https://sandboxie-plus.github.io/sandboxie-docs/Content/StartCommandLine.html
- `"C:\Program Files\Sandboxie\Start.exe" /box:MyBox /wait /hide_window cmd.exe /c xxx`——`/wait` 会等进程结束并**透传退出码**
- `/terminate` / `/terminate_all` 可清空沙箱内进程。
- **stdout 捕获有坑**:文档明确 "Start.exe is a Win32 application and not a console application",即它**不转发子进程 stdio**。可行做法是在沙箱内重定向:`Start.exe /box:X /wait cmd /c "cmdline > C:\boxpath\out.txt 2>&1"`,然后从宿主侧读沙箱文件系统根目录下的输出文件。交互式 shell 同理需要走管道桥接,做不到 `asyncio.create_subprocess_exec` 那种原生 PIPE 体验。
- 隔离粒度(文档核实):
- 文件:`ClosedFilePath`**禁读禁写**,官方文档明确 "deny all access by sandboxed programs, including read")、`ReadFilePath`(可读、写入被虚拟化)、`OpenFilePath`直通直写默认所有写都落在沙箱虚拟层copy-on-write不污染宿主。
- 注册表:`ClosedKeyPath` 等同类规则。
- 网络Plus 版内置**基于 WFP 的每沙箱防火墙**`NetworkEnableWFP=y` + `NetworkAccess=<program>,Block/Allow;Protocol=...`。Issue #5109https://github.com/sandboxie-plus/Sandboxie/issues/5109 )正是"禁外网但放行本地代理(回环)"的配置讨论——三档网络均可表达。
- 许可与嵌入GPL-3.0 + 需要安装**签名内核驱动 + 系统服务**SbieDrv/SbieSvc。结论**不能以库形式嵌入闭源产品**GPL 传染性 + 驱动安装要求)。合法用法是检测用户机器上是否已装 Sandboxie有则调用没有则提示安装——属于外部可选依赖。
- 其他 agent 项目使用情况:没有找到把 Sandboxie 作为嵌入式沙箱后端的知名 agent 项目;但有用户自发把 Cursor 整个 IDE 跑进 Sandboxie 的实践Discussion #5077https://github.com/sandboxie-plus/Sandboxie/discussions/5077 )。
- 需求覆盖R1 ✅ReadFilePath+虚拟化、R2 ✅OpenFilePath 或沙箱内写+回收、R3 ✅ClosedFilePath、R4 ✅WFP 三档可配、R5 ⚠️(一次性命令可行但 stdout 要走文件;交互式 shell 需自建桥接)。
---
## 3. nsjail / minijail / bwrap / Firejail 的 Windows 等价物
- **winjail**GitHub 全库搜索API 实测)只命中一个不相关的玩笑项目 `netcrawlerr/WinJail`2 starWinForms 弹窗玩具)。**作为沙箱项目的 winjail 查不到,不存在。**PyPI 同名也 404。
- **Firejail**https://github.com/netblue30/firejail 明确是 "Linux namespaces and seccomp-bpf sandbox"**没有 Windows 移植**issue 区讨论也确认无 Windows 支持。
- **nsjail**https://github.com/google/nsjail ):基于 Linux namespaces/cgroups/seccomp-bpfLinux 专用。
- **minijail**ChromiumOS 项目,同样 Linux 专用。
- Windows 生态里事实上的"等价物"是三样东西的组合:**AppContainer能力沙箱+ restricted token/完整性级别MIC+ Job Object资源限制**。Chromium 的 Windows sandbox 是这套组合最著名的工程实现2026 年 GSoC 有个面向 Gemini CLI 的 AppContainer PoChttps://github.com/AnushkaPandit-21/appcontainer-node-sandbox ,学生 PoCC++/Node-API 实现,验证了 `CreateAppContainerProfile`+ACL+`PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES` 全链路),可参考但成熟度低。
---
## 4. 容器路线Windows Containers不装 Docker
- 官方文档https://learn.microsoft.com/en-us/virtualization/windowscontainers/manage-containers/hyperv-container ——process isolation 与 Hyper-V isolation 两种。
- **不装 Docker 能不能跑**能。Windows 10 1809+ / Server 2019+ 内置 HCSHost Compute Service可用 **containerd + runhcs** 直接跑 OCI 容器TechTarget 综述、James Sturtevant 的实操博客、docker/roadmap#151 均有 walkthrough。hcsshimGo 库)是底层 API 封装。
- 坑(做命令沙箱的硬伤):
1. **process isolation 要求镜像与宿主内核 build 严格匹配**https://jfreeman.dev/blog/2021/09/01/how-to-run-a-windows-container-from-a-windows-10-virtual-machine/ ),用户机器 build 号千奇百怪,镜像要么全量预置要么 fallback 到 Hyper-V isolation需要启用 Hyper-V/虚拟机平台,专业版以上)。
2. **镜像体积**Nano Server ~260290MB、Server Core 解压后 ~4.86.9GBltsc2022 5.16GB、ltsc2025 6.86GBhttps://github.com/microsoft/Windows-Containers/issues/584 )。首次拉取 GB 级,冷启动数秒到十数秒。
3. Python 集成只能走 `ctr`/`runhcs` CLI 子进程或自研 hcsshim 绑定。
- 需求覆盖R1/R2/R3 ✅volume mount 可 read-only、R4 ⚠️(`--network none` 可禁网,"仅回环"需自建 HNS 网络策略、R5 ⚠️(经 CLI 间接集成)。**结论:技术上可行,但对"桌面 agent 跑一条命令"的场景过重,不推荐。**
---
## 5. 特殊文件系统过滤Dokan / WinFsp 只读视图
- WinFsphttps://github.com/winfsp/winfsp 8774 star活跃许可为 **GPLv3 + 商业双许可**GitHub API 返回 NOASSERTION 即自定义/双许可)
- Dokanyhttps://github.com/dokan-dev/dokany (仓库内含 `license.lgpl.txt``license.mit.txt`,用户态库 LGPL、部分组件 MIT
- 可行性:两者都能在用户态实现一个"把宿主目录映射成只读视图"的虚拟盘WinFsp 官方 passthrough 教程就是现成起点;只读语义由 FS 实现对写操作返回拒绝即可cgofuse issue #15 讨论了 Windows 上 `-o ro` 的实现方式)。
- **但作为沙箱手段有关键缺陷**:只读视图管不住"进程直接打开原始路径"——沙箱内进程只要还有权限访问原路径就能绕过挂载点。所以它**不能替代 token/ACL 级隔离**,只能做:把敏感路径"藏起来不给视图"+ 配合 AppContainer/restricted token 收回原路径访问权。此外还需要安装内核驱动(分发负担 + WinFsp 的 GPL/商业许可成本)。
- 需求覆盖R1 ✅仅视图层面、R2 ⚠️(叠加 overlay 可实现写重定向、R3 ⚠️(需配合 token、R4 ❌、R5 ❌(与进程管理无关)。
- **结论:可选增强项,不是独立方案。**若最终走 ACL 路线,本项可以跳过。
---
## 6. 微软官方 20232025 的新东西
1. **Win32 App Isolation**Build 2023 宣布、2024 持续推):
- Windows Blog 2023-06 公测公告https://blogs.windows.com/windowsdeveloper/2023/06/14/public-preview-improve-win32-app-security-via-app-isolation/ "built on AppContainers";微软还专门发文 Sandboxing Python with Win32 App Isolationhttps://blogs.windows.com/windowsdeveloper/2024/03/06/sandboxing-python-with-win32-app-isolation/ 2024-03
- 要点:面向 **MSIX 打包应用**的隔离(能力声明式授权、隐私式提示),不是一个"给任意命令行进程套沙箱"的 API。对 agent 运行时场景:理念可借鉴,接口形态不匹配。
2. **Windows Sandbox消费级功能+ .wsb 配置**
- MS Learn 配置文档https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/windows-sandbox-configure-using-wsb-file ,本轮全文核实):`<MappedFolders>` 支持 `ReadOnly=true/false`、`<Networking>Enable/Disable`、`<LogonCommand>` 开机执行一条命令、内存上限。Win10 18342+/Win11**仅 Pro/Enterprise/EducationHome 不可用**。
- 致命短板:**没有任何 stdout/stderr/退出码回传机制**,是 RDP 交互桌面;启动是完整 VM秒级~十秒级);无编程 API。适合人工试毒不适合 agent 自动化管道。
3. **Agent WorkspaceWindows 112025-10/11 起面向 Insider 推出)**
- 来源https://4sysops.com/archives/what-is-agent-workspace-in-windows-11/ 、https://support.microsoft.com/en-us/windows/ai/ai-features/experimental-agentic-features 、https://techcommunity.microsoft.com/blog/windows-itpro-blog/evolving-windows-new-copilot-and-ai-experiences-at-ignite-2025/4469466 。
- 要点:给 AI agent 一个**独立的 Windows 会话**——每个 agent 有自己的账户、桌面,"runtime isolation and scoped authorization",策略可控、可审计。这是微软官方对"agent 沙箱"的回答,方向上与 Codex 的"专用本地账户"思路一致。目前为实验性、Insider 通道,**尚无公开的第三方可编程 API**,属于"盯着看"项。
- 旁证Codex 评估过 Windows Sandbox 和 MIC 后放弃(见第 7 节),说明微软现有官方原语确实拼不出开箱即用的 agent 沙箱。
---
## 7. Codex 的 Windows 实现openai/codex 仓库核实)
- 仓库https://github.com/openai/codex 102379 star**Apache-2.0**,极活跃)
- 源码位置:`codex-rs/windows-sandbox-rs/`,本轮用 GitHub API 核实了完整文件清单,关键模块:`acl.rs`(27KB)、`token.rs`(18KB)、`cap.rs`、`proc_thread_attr.rs`、`process.rs`、`desktop.rs`、`identity.rs`、`elevated/elevated_impl.rs`、`wfp.rs`(16KB)+`wfp_setup.rs`、`deny_read_*.rs`、`setup.rs`(77KB)、`conpty/`、`unified_exec/`,外加 `sandbox_smoketests.py`Python 冒烟测试)。
- 架构InfoQ 2026-06 报道 https://www.infoq.com/news/2026/06/codex-windows-sandbox-design/ + 仓库 issue/讨论交叉验证):
- **评估后放弃** Windows Sandbox要直接访问开发者工作区、且非全 SKU 可用)与单纯 MIC。
- **unelevated 沙箱**(第一版):当前用户上下文下用 **restricted token + 合成 SID "sandbox-write"**,只对工作区和显式配置的目录授写权限;`.git` 等敏感元数据用 ACL 拒读(`deny_read_*` 系列文件就是干这个的)。
- **elevated 沙箱**(现行版):安装时创建**专用本地账户**CodexSandboxOffline / CodexSandboxOnline命令以这些账户 + restricted token 运行(`CreateProcessWithLogonW`**网络用 WFP 防火墙规则**控制——offline 账户禁网、online 账户放网,正好对应"禁网/全开";配置项 `[windows] sandbox = "elevated"|"unelevated"`issue #25566、#33073 等可佐证)。
- `conpty/``unified_exec/` 目录的存在说明他们 ship 了**交互式 PTY 会话**能力(对应"交互式 shell"需求)。
- 工程教训(从 issue 区看到的真实代价setup helper 需要条件 ACE 规避 UACissue #26087 的 740 错误)、配置文件损坏导致 `CreateProcessWithLogonW: 1168`#33073、helper 进程泄漏(#32194。说明这条路**能走通但边角多**,自研需要预留测试预算。
- 需求覆盖:**R1 ✅ R2 ✅ R3 ✅ R4 ✅WFP 三档,仅回环可用 WFP 规则表达R5 ⚠️**Rust crate不是 Python 库Python 集成路径有两条:① 把 `codex sandbox -- <cmd>` 当外部子进程调用——最省事但引入重依赖;② 以它为蓝本用 pywin32/ctypes 重写核心——token+ACL 部分 GRR 已给出 Python 模板WFP 部分(`fwpuclnt.dll`)需自行绑定,工作量集中在网络档)。
---
## 8. 需求 × 方案 覆盖矩阵
| 方案 | R1 只读 | R2 工作区可写 | R3 敏感路径禁读 | R4 网络三档 | R5 asyncio 集成 | 许可 | 嵌入闭源产品 |
|---|---|---|---|---|---|---|---|
| 自研Codex 蓝本 + pywin32/ctypes | ✅ ACL | ✅ 授权写 | ✅ deny ACL | ✅ WFP | ✅ 原生 | 自研 | ✅ |
| GRR sandbox_libvendor | ✅ | ⚠️ 需补写授权 | ✅ | ❌ 需补 WFP | ✅ | Apache-2.0 | ✅ |
| Sandboxie-Plus外部调用 | ✅ | ✅ | ✅ | ✅ WFP | ⚠️ stdout 走文件 | GPL-3.0 + 驱动 | ❌ 仅外部依赖 |
| Windows Sandbox (.wsb) | ✅ | ✅ | ⚠️ 只映射该映射的 | ⚠️ 只有开/关 | ❌ 无 stdio | OS 内置 | — |
| Windows Containers (runhcs) | ✅ | ✅ | ✅ | ⚠️ 回环档麻烦 | ⚠️ CLI 间接 | Apache-2.0 组件 | ⚠️ 运维重 |
| Dokan/WinFsp 只读视图 | ✅ 仅视图 | ⚠️ overlay | ⚠️ 需配合 token | ❌ | ❌ | GPL/商业 或 LGPL/MIT | ⚠️ |
| PyPI 现成包 | — | — | — | — | — | — | **不存在** |
## 9. 落地建议(给运行时的技术选型)
1. **主路线(自研,强烈推荐)**:以 Codex windows-sandbox-rs 为设计蓝本、以 GRR sandbox_lib.py 为 Python 代码起点:
- 文件隔离AppContainer 或 restricted token + 合成 SID工作区授 `GENERIC_ALL`,其余默认拒写;敏感路径(`~/.ssh`、`~/.aws`、浏览器凭据目录等)显式 deny-read ACE
- 网络三档ctypes 绑 `fwpuclnt.dll` 加 WFP 过滤器(禁网=block all、仅回环=allow 127.0.0.1/::1、全开=不加规则Codex 的 wfp.rs 可作逻辑参照;
- 进程管理Job Objectpywin32 `win32job` 现成)做内存/进程数限制与树杀;
- asyncio 集成:`asyncio.create_subprocess_exec` 无法直接带 token 启动,需要先用同步 API CreateProcess* 再包装句柄,或封装成 `loop.run_in_executor` + 管道线程;交互 shell 参考 Codex 的 ConPTY 方案Python 侧有 `pywinpty` 可用)。
2. **可选加固后端**:检测用户已安装 Sandboxie 时提供"在 Sandboxie 中运行"选项GPL 不构成对闭源主程序的传染,因为是外部进程调用而非链接)。
3. **不要选**Windows Containers、Windows Sandbox无 stdio、WinFsp/Dokan 单用(不隔离进程)、等待 winjail 类项目(不存在)。
4. **值得关注**Windows 11 Agent Workspace 的 API 开放情况——一旦微软开放第三方接入,可能成为官方托管方案。
---
## 附:主要信息来源
- OpenAI Codex 仓库https://github.com/openai/codex InfoQ 报道https://www.infoq.com/news/2026/06/codex-windows-sandbox-design/
- GRRhttps://github.com/google/grr sandbox_lib.py 源码https://raw.githubusercontent.com/google/grr/master/grr/client/grr_response_client/unprivileged/windows/sandbox_lib.py
- Sandboxiehttps://github.com/sandboxie-plus/Sandboxie 命令行文档https://sandboxie-plus.github.io/sandboxie-docs/Content/StartCommandLine.html ClosedFilePathhttps://sandboxie-plus.github.io/sandboxie-docs/Content/ClosedFilePath.html WFP 网络配置 issuehttps://github.com/sandboxie-plus/Sandboxie/issues/5109
- Windows Sandbox 配置https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/windows-sandbox-configure-using-wsb-file
- Windows Containershttps://learn.microsoft.com/en-us/virtualization/windowscontainers/manage-containers/hyperv-container 镜像尺寸https://github.com/microsoft/Windows-Containers/issues/584
- Win32 App Isolationhttps://blogs.windows.com/windowsdeveloper/2023/06/14/public-preview-improve-win32-app-security-via-app-isolation/ Python 沙箱化https://blogs.windows.com/windowsdeveloper/2024/03/06/sandboxing-python-with-win32-app-isolation/
- Agent Workspacehttps://4sysops.com/archives/what-is-agent-workspace-in-windows-11/ https://techcommunity.microsoft.com/blog/windows-itpro-blog/evolving-windows-new-copilot-and-ai-experiences-at-ignite-2025/4469466
- WinFsphttps://github.com/winfsp/winfsp Dokanyhttps://github.com/dokan-dev/dokany
- GSoC AppContainer PoChttps://github.com/AnushkaPandit-21/appcontainer-node-sandbox

View File

@ -0,0 +1,90 @@
# WSL2 + bwrap 沙箱概念验证PoC实验报告
> 日期2026-07-30 · 实验机Windows 11 (26200) · WSL2 内核 5.15.167.4
> 结论先行:**路线可行,隔离强度达到调研预期,可以进入源码改造。**
> 前提条件:必须使用专用发行版并关闭 Windows 互操作interop否则存在沙箱逃逸见 E8
## 1. 实验环境搭建(已验证的部署流程)
```cmd
:: 1. 下载 Alpine minirootfs~3.5MB
curl -L -o alpine-minirootfs.tar.gz https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.3-x86_64.tar.gz
:: 2. 导入为专用沙箱发行版(非交互,可编程)
wsl --import astrion-sandbox <安装目录> alpine-minirootfs.tar.gz --version 2
:: 3. 发行版内配置(详见 §3 的发现)
:: - /etc/wsl.conf: [interop] enabled=false / appendWindowsPath=false[network] generateResolvConf=false
:: - /etc/resolv.conf: 固定公共 DNS223.5.5.5 / 119.29.29.29
:: - /etc/apk/repositories: 换国内镜像mirrors.aliyun.com
:: - apk add bubblewrap bash ncurses-libs python3 git
```
## 2. 实验结果总表24 项23 PASS / 1 INFO
沙箱命令模板(与项目现有 `_build_linux_common_plan` 同构):
```
只读模式: bwrap --die-with-parent --new-session --unshare-all --share-net \
--ro-bind / / --ro-bind <ws> <ws> --chdir <ws> \
--proc /proc --dev /dev --tmpfs /tmp -- bash -lc <cmd>
工作区可写: 同上,工作区改为 --bind <ws> <ws>
网络全禁/仅回环: 去掉 --share-net--unshare-all 自带的 unshare-netlo 自动可用)
```
| 分组 | 实验 | 结果 | 说明 |
|---|---|---|---|
| E0 基础 | 发行版执行 / bwrap 0.11 / user namespace | ✅ | WSL2 满足 bwrap 全部前提 |
| E0 路径 | `E:\...``/mnt/e/...` 转换 + 工作目录 | ✅ | 机械转换即可,含空格路径正常 |
| E0 编码 | `WSL_UTF8=1` 解决 wsl.exe UTF-16 乱码 | ✅ | 必须设置 |
| E0 输出 | localhost 代理警告仅在 stderr | ✅ | 可在执行器层过滤 |
| E1 只读 | 工作区内写入 → `Read-only file system` | ✅ | 报错关键词与 mac 语义一致,审批关键词匹配可直接复用 |
| E1 只读 | 系统/工作区读取正常 | ✅ | |
| E1 只读 | 写 Linux 系统目录被拒 | ✅ | |
| E1 只读 | **写工作区外 Windows 盘9P 子挂载)被拒** | ✅ | 关键项ro-bind / 对 9P 挂载同样生效 |
| E2 可写 | 工作区内写入真实落盘 Windows | ✅ | |
| E2 可写 | 工作区外 Windows 盘写入被拒 | ✅ | |
| E2 可写 | Linux 系统目录写入被拒tmpfs /tmp 可写 | ✅ | 对齐 macOS /tmp 语义 |
| E3 禁读 | 未掩蔽时敏感文件可读(风险确认) | ✅ | 前置确认 |
| E3 禁读 | `--ro-bind /dev/null <file>` 掩蔽单文件 | ✅ | |
| E3 禁读 | `--tmpfs <dir>` 掩蔽整目录 | ✅ | 可实现 macOS deny-read 等价 |
| E4 网络 | share-net 外网可达 | ✅ | |
| E4 网络 | unshare-net 外网不可达 | ✅ | |
| E4 网络 | unshare-net 下 lo 可用、127.0.0.1 实际通信 | ✅ | 等价 macOS restricted仅回环语义 |
| E5 综合 | 只读模式 `rm -rf` 工作区被拦截 | ✅ | |
| E5 综合 | 可写沙箱 `rm -rf /mnt/c/Windows/...` 全部 Read-only | ✅ | |
| E6 交互 | bwrap 内 `bash -i` stdio 逐条往返 | ✅ | 持久终端方案成立 |
| E7 性能 | 单次 wsl.exe 调用 ~950-1000ms | | 后续需长驻进程复用优化 |
| E8 工具链 | git init/add/commit 全流程、python3 写文件 | ✅ | 工作区内正常,工作区外 OSError 拒绝 |
## 3. 关键发现(实验中新暴露、调研未覆盖的)
### E8-逃逸(最严重):必须关闭 Windows interop
- **未关闭时**:只读 bwrap 沙箱内执行 `cmd.exe /c echo pwned > E:\...\file.txt` **成功写入 Windows 盘**。interop 进程由 WSL 会话 init 在命名空间外拉起bwrap 完全管不到 → 沙箱形同虚设。
- **封堵方式**(已验证):专用发行版 `/etc/wsl.conf``[interop] enabled=false` + `appendWindowsPath=false`,重启发行版后 `cmd.exe: command not found`,逃逸封死。
- 结论:沙箱发行版必须是**专用、受控配置**的,绝不能复用用户自己的 Ubuntu 等发行版。Claude Code 的 WSL 沙箱同样禁 interop属行业共识做法。
### 网络环境适配(本机实测暴露)
- 宿主使用 localhost 代理时WSL NAT 模式的 DNS 转发10.255.255.254 → 宿主解析器)**会失效**(时好时坏);直连公共 DNS223.5.5.5)正常 → 发行版需固化 `generateResolvConf=false` + 公共 DNS。
- apk 官方源大包下载会卡死,换阿里云镜像后秒装 → 发行版应内置国内镜像(或做成可配置)。
- wsl.exe 每次启动都在 stderr 打 localhost 代理警告 → 执行器需过滤 stderr 该行(或在 `.wslconfig` 全局设 `autoProxy=false`,影响面大,不推荐默认做)。
### 工程细节
- Git Bash 调用 wsl.exe 会被 MSYS2 路径转换毁掉 Unix 参数(`/etc/...` → `E:/KimiData/...`)→ 仅影响开发调试(设 `MSYS2_ARG_CONV_EXCL='*'`Python subprocess 不受影响。
- `bash` 需显式安装Alpine 默认 busybox sh且需 `ncurses-libs` 才能跑。
## 4. 与 macOS 沙箱的能力对照(最终形态)
| 能力 | macOS现状 | Windows WSL2+bwrapPoC 验证后) |
|---|---|---|
| 只读模式 | sandbox-exec deny-write | bwrap 全 ro-bind ✅ |
| 工作区可写 | profile writable paths | ro-bind / + bind ws ✅ |
| 敏感路径禁读 | deny subpath / regex | tmpfs/dev-null 掩蔽(无 regex需逐个路径 |
| 网络三档 | profile 网络规则 | share-net / unshare-netrestricted≈仅回环✅ |
| 审批链语境 | Unix 报错关键词 | 完全一致Read-only file system / Permission denied✅ |
| 只读命令白名单 | Unix 命令集 | 完全一致(同为 Linux bash✅ |
| 交互终端 | sandbox-exec bash -i | bwrap bash -i ✅ |
遗留限制(二期):单次调用 ~950ms 开销需长驻 WSL 进程复用;禁读不支持 regex可改为启动前枚举 .env 类文件逐个掩蔽seccomp 过滤器暂未接入bwrap 本身已满足 FS/网络隔离需求)。
## 5. 原始实验日志
完整 24 项原始输出见 `.wsl-poc/results.md`(含 E5.2 逐文件 Read-only 报错洪流,可佐证 9P 挂载保护的真实性)。实验脚本:`.wsl-poc/run_experiments.sh`。

169
wsl2-sandbox-research.md Normal file
View File

@ -0,0 +1,169 @@
# WSL2 等价强度沙箱可行性调研报告
调研日期2026本轮。结论先行**WSL2 可以做出与 macOS sandbox-exec 等价强度的沙箱**,核心路径是「专用 WSL2 发行版 + 发行版内 user/mount/network namespaceunshare 或 bubblewrap」。因为 WSL2 跑的是真实 Linux 内核(如 6.6.x-microsoft-standard-WSL2Linux 侧的方案unshare / bwrap基本可以完全复用最大的工程成本在 Windows 侧的发行版供给、路径转换和 wsl.exe 的编码/开销问题。
---
## 1. 发行版供给:可行(编程式部署专用沙箱发行版)
### 结论
**可行**。不要依赖用户已有的发行版,而是用 `wsl --import` 导入一个官方 rootfs tar创建专用 distro`astrion-sandbox`)。这是官方支持的、完全非交互的路径。
### 具体做法
**rootfs 官方来源:**
- Ubuntu WSL 专用 rootfs`https://cloud-images.ubuntu.com/wsl/<release>/current/*-wsl.rootfs.tar.gz`。Ubuntu 官方明确提供 tar-based WSL 发行格式用于「distribute, install, and manage Ubuntu WSL instances」[Ubuntu 官方博客](https://ubuntu.com/blog/ubuntu-wsl-new-format-available))。社区实践即用 `wsl --import` 导入这些 rootfs[buildroot GettingStarted](https://github.com/TiMaMi-GmbH/buildroot-external-timami/blob/main/GettingStarted.md)、[Zenn 教程](https://zenn.dev/dozo/articles/a633794f6d7575))。
- Alpine minirootfs更轻量~3MB`https://dl-cdn.alpinelinux.org/alpine/v3.xx/releases/x86_64/alpine-minirootfs-*-x86_64.tar.gz`,官方下载页提供 MINI ROOTFS社区有完整 WSL2 导入实践([tonym.us: Porting Alpine Linux RootFS to WSL2](https://tonym.us/porting-alpine-linux-rootfs-wsl2.html)、[PowerShell-Wsl-Alpine](https://github.com/antoinemartin/PowerShell-Wsl-Alpine)、[nathanchance: 从 LXC 镜像创建 WSL2 发行版](https://nathanchance.dev/posts/wsl2-distros-from-lxc-images/))。
- 许可Ubuntu/Alpine 均为自由软件rootfs tarball 由官方公开发布供再分发与导入,无许可障碍。
**导入命令序列(官方文档,[Microsoft Learn — Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands)**
```powershell
wsl --import astrion-sandbox C:\Path\To\InstallDir ubuntu-wsl.rootfs.tar.gz --version 2
```
`--import` 完全非交互,导入的 distro 默认 root 登录无首启用户创建向导Store 版 distro 才有 OOBE。注意import 的 distro 没有 launcher exe改默认用户要靠发行版内 `/etc/wsl.conf``[user] default=`
**`wsl --install` 的交互问题:** Store 渠道安装的 distro 首启会进入交互式用户创建,且 `--install` 本身支持 `--no-launch` / `--no-distribution` 选项规避部分交互([Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/basic-commands),另见 [WSL issue #10386](https://github.com/microsoft/WSL/issues/10386) 讨论 `--no-launch` 后自行脚本化配置的做法)。但对沙箱场景,**推荐完全绕开 `--install`,用 `--import`**:可控、可重复、无 OOBE、与用户的其他发行版零耦合。
**检测并排除 docker-desktop 等不可用 distro**
- 已安装发行版记录在注册表 `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss`(每个 GUID 子键含 `DistributionName`、`BasePath`、`Version` 等)([Learning in the Open: Lxss 注册表结构](https://learningintheopen.org/tag/hkcusoftwaremicrosoftwindowscurrentversionlxss/))。
- `docker-desktop` / `docker-desktop-data` 会出现在 `wsl -l -v` 里,但它们是 Docker Desktop 的内部基础设施 distro[Super User 讨论](https://superuser.com/questions/1729811/why-are-these-multiple-wsl-distributions-on-windows))。
- 程序化排除策略(建议多层):① 名字黑名单(`docker-desktop*`);② 试运行探测 `wsl -d <name> -e true`,失败即排除;③ 最稳妥:根本不枚举用户 distro只认自己创建的 `astrion-sandbox`(先 `wsl -l -q` 检查是否已存在,存在则直接用或校验后重建)。注意 `wsl -l` 的输出是 UTF-16见第 7 节)。
---
## 2. Windows 路径访问与 drvfs 语义:可行,但要注意 9P 元数据语义
### 结论
**可行**。`E:\path` → `/mnt/e/path` 是机械转换(盘符小写、反斜杠转正斜杠)。权限语义取决于 drvfs 挂载选项WSL2 下 Windows 盘实际以 9p 协议挂载。
### 要点
- WSL2 中 Windows 盘的 mount 输出形如:`C:\ on /mnt/c type 9p (rw,...,aname=drvfs;path=C:;...)`[WSL issue #4774](https://github.com/microsoft/WSL/issues/4774)——WSL1 是 drvfs 内核驱动WSL2 是 9P over virtio语义基本对齐。
- `/etc/wsl.conf``[automount]` 可配 `options = "metadata,uid=...,gid=...,umask=...,fmask=...,dmask=...,case=off|dir|force"`[Microsoft Learn — Advanced settings configuration in WSL](https://learn.microsoft.com/en-us/windows/wsl/wsl-config))。
- **关键坑:`metadata` 默认关闭**。未开 metadata 时9p/drvfs 上 `chmod` 不生效、权限由 umask/fmask 统一伪造;开 metadata 后 chmod 结果会持久化到 NTFS 扩展属性([Super User](https://superuser.com/questions/1323645/unable-to-change-file-permissions-on-ubuntu-bash-for-windows-10)、[akr.am](https://akr.am/blog/posts/fix-wsl-file-system-behavior))。因此**不要用 chmod 作为 /mnt 下文件的只读控制手段**——权限语义不可靠。只读控制应走 mount 层(见第 3 节的 ro bind mount那是 VFS 层强制,与底层 9p 无关。
- 另一坑9p 不支持 inotify 跨 Windows 侧变更Rolldown 文档明确说明 [Windows 侧修改文件 WSL2 内 watch 收不到事件](https://rolldown.rs/reference/inputoptions.watch)),与本任务关系不大但值得知道。
- 反向保护:如果希望沙箱内根本看不到其他 Windows 盘,可以在专用发行版的 `/etc/wsl.conf` 里设 `[automount] enabled=false`再按需只挂载工作区所在盘fstab 里也支持 ro 挂载([tonym.us: Improve WSL Security with Read-Only Filesystem](https://tonym.us/improve-wsl-security-with-read-only-filesystem.html))。
---
## 3. 文件只读与写范围mount namespace / bubblewrap可行
### 结论
**可行,且是整套方案的核心**。WSL2 是真实 Linux 内核user/mount/network namespace 均可用;`unshare --mount` + ro bind 链条成立;**bubblewrap 可以直接装进 WSL2 使用**Linux 方案可完全复用。
### 证据
- OpenAI Codex CLI 的 Linux 沙箱就是 bubblewrap其在 WSL2 上实际运行([codex issue #22469](https://github.com/openai/codex/issues/22469):环境明确为 `WSL2 (kernel 6.6.114.1-microsoft-standard-WSL2)`,问题只是 bwrap 的 `--dev /dev` 没暴露 `/dev/dxg` GPU 节点——说明 bwrap 沙箱本体在 WSL2 工作正常)。
- WSL2 内核支持 user namespace 与 network namespace内核 6.6.87.2-microsoft-standard-WSL2 上 `unshare(CLONE_NEWUSER|CLONE_NEWNET)` 可用([CVE-2026-43284 PoC 的实测环境记录](https://sploitus.com/exploit?id=9B3D53CA-E954-5110-8888-1957B931A61B)`unshare -Urm` + bind + remount ro 的完整链条在 codex issue #9254 中有逐命令验证([链接](https://github.com/openai/codex/issues/9254))。
- **WSL1 不可用**WSL1 内核不支持 user namespace`unshare -Ur` 直接 EINVALbwrap 报 "kernel does not support user namespaces"[codex issue #16076](https://github.com/openai/codex/issues/16076))。另外 WSL1 有 ro bind mount 在有可写 fd 时 EBUSY 的 bug[WSL issue #3549](https://github.com/microsoft/WSL/issues/3549))。**方案必须强制 WSL2**(导入时 `--version 2`,启动前 `wsl -l -v` 校验)。
- 已知小限制:`unshare --time` 在 WSL 上不支持([WSL issue #9223](https://github.com/microsoft/WSL/issues/9223)),与本需求无关。
### 推荐命令链发行版内root 或 userns 均可)
方案 A纯 unshare无需装包
```bash
wsl -d astrion-sandbox -u root -- bash -c '
unshare --mount --propagation private bash -c "
mount --make-rprivate / # 关键:先切断 shared 传播,避免污染宿主/其他会话
mount --bind / / # 如需整树 ro先 bind 再 remount,robind+ro 一步到位有内核老 bug必须两步
mount -o remount,bind,ro /
mount --bind /mnt/e/workspace /mnt/e/workspace
mount -o remount,bind,rw /mnt/e/workspace # 工作区 rw 放回去
mount --bind /tmp /tmp && mount -o remount,bind,rw /tmp
exec sudo -u sandboxuser bash # 降权后 exec 目标 shell
"
'
```
方案 Bbubblewrap推荐与 macOS/Linux 共用同一套策略描述):
```bash
bwrap \
--ro-bind / / \
--bind /mnt/e/workspace /mnt/e/workspace \
--bind /tmp /tmp \
--unshare-net \
--die-with-parent \
-- bash
```
bwrap 的 `--unshare-net` 会在新 netns 里创建 loopback[fromager issue #472](https://github.com/python-wheel-build/fromager/issues/472)),天然实现「禁网但保留 lo」。
### 两个必须注意的工程细节
1. **mount 传播**WSL2 的挂载默认是 shared propagation。在新 mount namespace 里 remount ro 之前如果不 `mount --make-rprivate /`ro 事件可能传播回初始 namespace影响同 VM 里的其他会话/发行版。先私有化再操作是标准做法。
2. **bind ro 要两步**`mount --bind` 本身不继承 ro 选项,必须 `mount -o remount,bind,ro`(经典内核行为,[Unix.SE](https://unix.stackexchange.com/questions/128336/why-doesnt-mount-respect-the-read-only-option-for-bind-mounts)、[LWN: Read-only bind mounts](https://lwn.net/Articles/281157/))。
3. 只读是 VFS 层强制9p 后端无法绕过(进程在 Linux 侧的一切写都被 VFS 挡掉);但**从 Windows 侧的直接写入不受此约束**——沙箱只防 WSL 内的不可信进程。
---
## 4. 网络隔离:可行(三档都能实现)
### 结论
**可行**。三档映射:
- **禁网**`unshare --net`(新 netns 无任何外部接口)或 `bwrap --unshare-net`
- **仅回环**`unshare --net` 后 `ip link set lo up`(在新 ns 内有 CAP_NET_ADMIN可用或直接用 bwrap自动带 lo
- **全开**:不加 net namespace。
### 依据与细节
- `unshare -rn` 创建无外部连通性的 netns是构建工具的常用隔离手段[fromager issue #472](https://github.com/python-wheel-build/fromager/issues/472));进程隔离教学也覆盖 `ip netns`/`unshare` 路径([oneuptime 教程](https://oneuptime.com/blog/post/2026-03-20-isolate-process-network-namespace/view))。
- 新 netns 里 lo 存在但初始 DOWN需要 `ip link set lo up`bwrap 较新版本自动完成这步。个别环境 bwrap 的 loopback 配置会失败RTM_NEWADDR EPERM[codex issue #15982](https://github.com/openai/codex/issues/15982))——若在目标环境复现,降级为 `unshare --net` + 手动 `ip link set lo up`
- 发行版级 iptables/nftables 也可行WSL2 是完整内核,实例内 root 有完整 netfilter 能力),但 netns 方案更彻底(连路由表都不存在,无法绕过),优先推荐。
- **localhostForwarding 的影响**:默认 `localhostForwarding=true`[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config)Windows 侧可通过 localhost 访问 WSL 内监听的端口。对隔离的含义:① 沙箱内进程监听端口可能被 Windows 宿主触达(本地跨进程攻击面);② NAT 模式下 WSL 内可经宿主网关 IP 访问 Windows 上的 localhost 服务(如代理 7890 端口)。**`unshare --net` 把这两条路一起断掉**(新 ns 里根本没有到宿主的链路),所以 netns 方案天然覆盖该问题;若走 iptables 方案则必须额外阻断到宿主网关 IP 的流量。
- 另可在 `.wslconfig``networkingMode=none` 做 VM 级断网,但它是全局开关、影响所有发行版,不适合按需沙箱。
---
## 5. 交互式 shell持久会话可行
### 结论
**可行**。namespace 隔离是进程树属性,把沙箱包装器作为长驻 shell 的父进程即可,对一次性命令和持久终端同样成立。
### 做法
- 一次性命令:`wsl -d astrion-sandbox -u root -- bwrap <flags> -- bash -lc "cd ... && cmd"`。
- 持久交互终端:把同样的包装器包在交互 shell 外面——`wsl -d astrion-sandbox -- bwrap <flags> -- bash`(或先 `unshare` 进 namespace 再 exec bash。bwrap 是 exec 语义,整个会话生命周期都活在沙箱里;加 `--die-with-parent` 保证 wsl.exe 断开时沙箱内进程树随之回收。
- 会话内多条命令复用同一沙箱:沙箱内起 tmux 或直接复用该 bash 的 stdin/stdout 管道host 侧通过持有 wsl.exe 进程的 stdio 持续下发命令。
- 若要「先配置一次隔离、后续多次进入」,也可以 `unshare --mount --net --fork` 后用 `nsenter -t <pid> -m -n` 反复进入同一组 namespacensenter 是标准 Linux 能力WSL2 可用)。
---
## 6. 性能:有限(每次起 wsl.exe 有数百 ms 开销,必须做进程复用)
### 结论
**可行但需要工程优化**。实测/社区数据一致指向wsl.exe 每次调用有数百毫秒级固定开销,高频命令场景必须保活 + 复用长驻进程。
### 数据点
- 从 Windows 侧经 wsl.exe 调用 Linux 程序,单次约 500800ms[Super User #1663063](https://superuser.com/questions/1663063/slow-bash-under-wsl))。
- 极端病态案例wsl.exe 会话内命令 >5s 而 SSH 进同一 WSL <0.1s[WSL issue #4712](https://github.com/microsoft/WSL/issues/4712)说明 wsl.exe 通道本身可能是瓶颈
- 第三方 cold boot 基准:基于 WSL2 的 agent 运行时冷启动约 3500ms[ZeroClaw 对比基准](https://skywork.ai/skypage/en/openclaw-windows-compatibility-guide/2048651699598520321),社区数据,仅供参考量级)。
- 对照Linux 原生 bwrap/容器级沙箱开销约 300ms 以内([Julia Evans 的 benchmark](https://jvns.ca/blog/2022/06/28/some-notes-on-bubblewrap/)),即大部分开销在 wsl.exe 桥接层而非沙箱本身。
### 优化手段(都已验证可行)
1. **防 VM 休眠**`.wslconfig` 里 `vmIdleTimeout=-1`(默认 60000ms 空闲即关 VM[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config));新版 WSL 还有 `[general] instanceIdleTimeout`(默认 15000ms[GreenGorych .wslconfig 参考](https://greengorych.io/blog/complete-wslconfig-reference-and-template/?q=))。
2. **保活**:后台跑一个无害长驻命令,如 `wsl --exec dbus-launch true`[知乎实践](https://www.zhihu.com/question/662699218/answer/3578847718))。
3. **复用**host 侧持有一条长驻的 `wsl -d astrion-sandbox -- bwrap ... bash` 进程,通过其 stdin/stdout 反复下发命令推荐或沙箱内起一个小的命令服务FIFO/socket后续命令经 wsl.exe -e 投递。
---
## 7. 编码与输出污染:有限(有开关,但需逐项处理)
### UTF-16 输出
- **结论:可控。** wsl.exe 自身输出(如 `--list`)是 UTF-16LE 无 BOM不尊重系统代码页[WSL issue #4607](https://github.com/microsoft/WSL/issues/4607))。
- **解法:设置环境变量 `WSL_UTF8=1`**wsl.exe 即改用 UTF-8 输出微软官方支持的开关VS Code 曾因此产生解析 bug反向证实了该行为[vscode issue #276253](https://github.com/microsoft/vscode/issues/276253)。host 侧启动 wsl.exe 子进程时统一注入 `WSL_UTF8=1` 即可。
### localhost 代理警告
- **结论:可抑制,但最佳路径建议实测确认。** 警告 `wsl: A localhost proxy configuration was detected but not mirrored into WSL. WSL in NAT mode does not support localhost proxies.` 由 wsl.exe 在每次启动时打印([WSL issue #13456](https://github.com/microsoft/WSL/issues/13456)、[MathWorks 论坛](https://fr.mathworks.com/matlabcentral/answers/2174804-wsl-proxy-issue-affecting-compiler-package-microservicedockerimage-in-matlab)、[kevinskii.dev](https://kevinskii.dev/posts/wsl-networking/))。触发条件是 `autoProxy=true`(默认)+ Windows 配了监听 127.0.0.1 的代理 + NAT 模式。
- 抑制手段:
1. `.wslconfig``[wsl2] autoProxy=false`官方文档确认该键控制「Enforces WSL to use Windows' HTTP proxy information」[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config);社区模板即推荐关代理时设 false[feamcor gist](https://gist.github.com/feamcor/46687e86513c106c438bfa2715249609))。逻辑上检测关闭则警告不再产生——**建议落地前实测一轮**,因为微软没有单独文档承诺「该警告由 autoProxy 开关控制」。
2. 或切 `networkingMode=mirrored`(镜像模式下 localhost 代理天然可用,警告消失;但会改变整体网络行为,并存在与代理工具冲突后回退 NAT 的已知问题,[issue #13456](https://github.com/microsoft/WSL/issues/13456)、[issue #10794](https://github.com/microsoft/WSL/issues/10794))。
3. 兜底:该警告走 stderr可在 host 侧对 wsl.exe 的 stderr 做已知模式过滤(不影响子命令的退出码和 stdout
- `.wslconfig` 修改后需 `wsl --shutdown` 重启 VM 生效8 秒规则,[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config)。WSLENV 是环境变量透传机制(仅传白名单变量,[Zenn 实践](https://zenn.dev/kwrkb/articles/d927a0eac72d3d?locale=en)),与该警告无关。
---
## 总体评估矩阵
| 能力 | 结论 | 关键手段 |
|---|---|---|
| 专用沙箱发行版供给 | 可行 | `wsl --import` + Ubuntu cloud WSL rootfs / Alpine minirootfs注册表 Lxss + 名字黑名单 + 试运行探测排除 docker-desktop推荐只认自建 distro |
| Windows 路径访问 | 可行 | 盘符机械转换;权限控制走 mount 层而非 chmod9p metadata 默认关) |
| 只读/写范围mount ns | 可行 | `unshare --mount` + `make-rprivate` + 两步 ro bind + 工作区 rw 放回;或 bubblewrap必须 WSL2WSL1 无 userns |
| 网络三档 | 可行 | `bwrap --unshare-net` / `unshare --net` + `ip link set lo up`localhostForwarding 风险被 netns 天然覆盖 |
| 持久交互 shell | 可行 | 沙箱包装器 exec 长驻 bash或 nsenter 重入 |
| 性能 | 有限 | wsl.exe 单次数百 ms必须 VM 保活 + 长驻进程复用 |
| 编码/输出污染 | 有限 | `WSL_UTF8=1` 解决编码;`autoProxy=false` 抑制代理警告建议实测stderr 模式过滤兜底 |
**落地建议优先级**:专用 distro--import Alpine/Ubuntu rootfs→ 发行版内 bubblewrap 统一沙箱ro-bind / + 工作区 bind + --unshare-net 三档)→ 长驻 bwrap bash 会话复用 → `WSL_UTF8=1` + `autoProxy=false`。这样 Linux 与 Windows(WSL2) 可共用几乎同一份沙箱策略代码。