diff --git a/.gitignore b/.gitignore index d990e00b..80e2224f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ venv/ # Runtime data (main agent) # 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion//。 # 以下条目用于忽略「通过具体目录变量指回源码树」或历史遗留的运行态目录。 +.wsl-poc/ .astrion/ .agents/ logs/ diff --git a/core/main_terminal.py b/core/main_terminal.py index 4b3bf3ef..2b28cefd 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -340,7 +340,11 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi if pending_execution != current_exec: self.set_execution_mode(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["pending_execution_mode"] = None self.pending_execution_mode = None diff --git a/core/main_terminal_parts/context/mode.py b/core/main_terminal_parts/context/mode.py index 9b3cca92..3b616024 100644 --- a/core/main_terminal_parts/context/mode.py +++ b/core/main_terminal_parts/context/mode.py @@ -165,9 +165,6 @@ class ModeMixin: return None except Exception: return None - template = self.load_prompt("execution_mode") - if not template: - return None state = {} if hasattr(self, "get_execution_mode_state"): try: @@ -176,30 +173,148 @@ class ModeMixin: state = {} mode = str(state.get("mode") or "sandbox").strip().lower() 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": rules = ( "- 所有命令默认在系统 OS 沙箱中执行\n" "- 若遇到权限问题:这是用户刻意设置导致的结果,证明用户在当前状况下不允许你执行此命令。不要尝试绕过,你应立刻询问用户并解释原因,请求用户更换执行环境(如切换为“完全访问权限”)" ) - net = getattr(self, "host_network_permission", "restricted") - if net == "restricted": - network_rules = "- 网络:受限(仅允许 localhost,外部网络不可达)" - elif net == "full": - network_rules = "- 网络:完全开放" - else: - network_rules = "" else: rules = ( "- 当前为宿主机直接执行模式(完全访问权限)\n" "- 仅在必须时执行高权限操作,保持最小化命令范围\n" "- 涉及删除/覆盖/系统级变更前,先说明风险再执行" ) - network_rules = "" return template.format( execution_mode=mode, execution_mode_label=mode_label, 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]: diff --git a/modules/host_sandbox_policy.py b/modules/host_sandbox_policy.py index 224570a3..2c808bd5 100644 --- a/modules/host_sandbox_policy.py +++ b/modules/host_sandbox_policy.py @@ -36,6 +36,22 @@ DEFAULT_MACOS_DENY_READ_REGEXES = [ r"^/.*\.env(\.[^/]*)?$", ] +# Windows(WSL2+bwrap 沙箱)默认掩蔽的敏感路径(支持 ~/ 前缀)。 +# 目录用 tmpfs 整体掩蔽,文件用 /dev/null 掩蔽;仅掩蔽实际存在的路径。 +DEFAULT_WINDOWS_DENY_READ_PATHS = [ + "~/.ssh", + "~/.aws", + "~/.azure", + "~/.gcp", + "~/.google", + "~/.kube", + "~/.docker", + "~/.gnupg", + "~/.npmrc", + "~/.netrc", + "~/.git-credentials", +] + _LOCK = threading.Lock() # 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.astrion//config _POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json")) @@ -47,6 +63,7 @@ def _default_policy() -> Dict: "macos_readable_extra_paths": [], "macos_deny_read_paths": list(DEFAULT_MACOS_DENY_READ_PATHS), "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_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["windows_deny_read_paths"] = [str(x).strip() for x in data["windows_deny_read_paths"] if str(x).strip()] if needs_save: try: @@ -145,3 +163,15 @@ def get_macos_deny_read_regexes() -> List[str]: if val and val not in merged: merged.append(val) return merged + + +def get_windows_deny_read_paths() -> List[str]: + """Windows(WSL2+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 diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index 7f00f9b5..93091b76 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -2,15 +2,17 @@ from __future__ import annotations import os import platform +import re import shutil -import shlex -from dataclasses import dataclass +import subprocess +from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple 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, ) @@ -20,6 +22,8 @@ class SandboxPlan: env: Dict[str, str] cwd: 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): @@ -170,7 +174,7 @@ def build_host_sandbox_readonly_plan( if system == "Linux": return _build_linux_readonly_plan(command, work_path, env, network_permission) 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}") @@ -388,19 +392,178 @@ def _build_linux_common_plan( return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path)) +# ────────────────────────────────────────────────────────────── +# Windows:WSL2 + bubblewrap 沙箱 +# +# 设计要点(依据 .wsl-poc 概念验证,见 wsl2-sandbox-poc-report.md): +# - 使用专用沙箱发行版(默认 astrion-sandbox),必须关闭 interop, +# 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机; +# - 隔离原语与 Linux 方案同构:bwrap --unshare-all + ro-bind / + 工作区 bind; +# - 网络档位:full → --share-net;restricted(仅回环)/ 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.exe(WSL2),拒绝执行宿主机命令。") + 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-net(lo 仍可用) + 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.exe(WSL2),拒绝执行宿主机命令。") + 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( command: str, work_path: Path, env: Dict[str, str], network_permission: Optional[str] = None, ) -> SandboxPlan: - wsl = shutil.which("wsl.exe") - if not wsl: - raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝执行宿主机命令。") - 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)) + return _build_windows_wsl_plan( + work_path, env, ["bash", "-lc", command], readonly=False, + network_permission=network_permission, + ) + + +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( @@ -408,10 +571,7 @@ def _build_windows_shell_plan( env: Dict[str, str], network_permission: Optional[str] = None, ) -> SandboxPlan: - wsl = shutil.which("wsl.exe") - if not wsl: - raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝启动宿主机沙箱终端。") - 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)) + return _build_windows_wsl_plan( + work_path, env, ["bash", "-i"], readonly=False, + network_permission=network_permission, + ) diff --git a/modules/terminal_ops/misc.py b/modules/terminal_ops/misc.py index 7d2116d4..d17359fa 100644 --- a/modules/terminal_ops/misc.py +++ b/modules/terminal_ops/misc.py @@ -63,3 +63,14 @@ class MiscMixin: fd_num = seccomp_fd cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command] 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) diff --git a/modules/terminal_ops/run.py b/modules/terminal_ops/run.py index e3268d5d..42c37709 100644 --- a/modules/terminal_ops/run.py +++ b/modules/terminal_ops/run.py @@ -3,6 +3,7 @@ import os import sys import asyncio +import re import subprocess import shutil import time @@ -110,6 +111,7 @@ class RunMixin: process = None exec_cmd = None use_shell = True + stderr_ignore_regexes: list = [] session = session_override or self.container_session # 如果存在容器会话且模式为docker,则在容器内执行 @@ -161,6 +163,7 @@ class RunMixin: plan.command, plan.seccomp_bpf_path, ) + stderr_ignore_regexes = list(getattr(plan, "stderr_ignore_regexes", None) or []) try: process = await asyncio.create_subprocess_exec( *cmd_args, @@ -298,6 +301,8 @@ class RunMixin: stdout_text = stdout.decode('utf-8', errors='replace') if stdout 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 status = "completed" if success else ("timeout" if timed_out else "error") diff --git a/prompts/execution_mode.txt b/prompts/execution_mode/macos.txt similarity index 100% rename from prompts/execution_mode.txt rename to prompts/execution_mode/macos.txt diff --git a/prompts/execution_mode/windows.txt b/prompts/execution_mode/windows.txt new file mode 100644 index 00000000..3c43e03f --- /dev/null +++ b/prompts/execution_mode/windows.txt @@ -0,0 +1,9 @@ +## 执行环境:{execution_mode_label}({execution_mode}) + +{environment_rules} + +### 当前规则 +{rules} +{network_rules} + +{switch_invariant} diff --git a/scripts/setup-wsl-sandbox.ps1 b/scripts/setup-wsl-sandbox.ps1 new file mode 100644 index 00000000..aa2bc65e --- /dev/null +++ b/scripts/setup-wsl-sandbox.ps1 @@ -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 不可用。请先启用 WSL2(wsl --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" diff --git a/windows-sandbox-research.md b/windows-sandbox-research.md new file mode 100644 index 00000000..19559fb8 --- /dev/null +++ b/windows-sandbox-research.md @@ -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`** | 唯一一个**纯 Python(pywin32+ctypes)的 AppContainer 沙箱实现**,Apache-2.0,核心代码量小,可直接 vendor;但无网络控制、写授权模型需补齐 | +| **Sandboxie-Plus** | 隔离粒度最细(文件/注册表/网络逐规则配置),命令行可驱动;GPL-3.0 且依赖内核驱动,**不能嵌入闭源产品**,只能作为"用户自行安装的外部依赖"调用 | +| **Windows Sandbox(.wsb)** | 微软官方一次性 VM,映射文件夹只读/可写、可禁网,但**无 stdout/stderr 捕获管道、无编程 API**,不适合 agent 自动化 | +| **Windows Containers(containerd/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 star,Apache-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 Object:CreateJobObject/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=,Block/Allow;Protocol=...`)。Issue #5109(https://github.com/sandboxie-plus/Sandboxie/issues/5109 )正是"禁外网但放行本地代理(回环)"的配置讨论——三档网络均可表达。 +- 许可与嵌入:GPL-3.0 + 需要安装**签名内核驱动 + 系统服务**(SbieDrv/SbieSvc)。结论:**不能以库形式嵌入闭源产品**(GPL 传染性 + 驱动安装要求)。合法用法是检测用户机器上是否已装 Sandboxie,有则调用,没有则提示安装——属于外部可选依赖。 +- 其他 agent 项目使用情况:没有找到把 Sandboxie 作为嵌入式沙箱后端的知名 agent 项目;但有用户自发把 Cursor 整个 IDE 跑进 Sandboxie 的实践(Discussion #5077,https://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 star,WinForms 弹窗玩具)。**作为沙箱项目的 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-bpf,Linux 专用。 +- **minijail**:ChromiumOS 项目,同样 Linux 专用。 +- Windows 生态里事实上的"等价物"是三样东西的组合:**AppContainer(能力沙箱)+ restricted token/完整性级别(MIC)+ Job Object(资源限制)**。Chromium 的 Windows sandbox 是这套组合最著名的工程实现;2026 年 GSoC 有个面向 Gemini CLI 的 AppContainer PoC(https://github.com/AnushkaPandit-21/appcontainer-node-sandbox ,学生 PoC,C++/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+ 内置 HCS(Host Compute Service),可用 **containerd + runhcs** 直接跑 OCI 容器(TechTarget 综述、James Sturtevant 的实操博客、docker/roadmap#151 均有 walkthrough)。hcsshim(Go 库)是底层 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 ~260–290MB、Server Core 解压后 ~4.8–6.9GB(ltsc2022 5.16GB、ltsc2025 6.86GB,https://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 只读视图 + +- WinFsp:https://github.com/winfsp/winfsp (8774 star,活跃,许可为 **GPLv3 + 商业双许可**,GitHub API 返回 NOASSERTION 即自定义/双许可) +- Dokany:https://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. 微软官方 2023–2025 的新东西 + +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 Isolation(https://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 ,本轮全文核实):`` 支持 `ReadOnly=true/false`、`Enable/Disable`、`` 开机执行一条命令、内存上限。Win10 18342+/Win11,**仅 Pro/Enterprise/Education,Home 不可用**。 + - 致命短板:**没有任何 stdout/stderr/退出码回传机制**,是 RDP 交互桌面;启动是完整 VM(秒级~十秒级);无编程 API。适合人工试毒,不适合 agent 自动化管道。 +3. **Agent Workspace(Windows 11,2025-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 规避 UAC(issue #26087 的 740 错误)、配置文件损坏导致 `CreateProcessWithLogonW: 1168`(#33073)、helper 进程泄漏(#32194)。说明这条路**能走通但边角多**,自研需要预留测试预算。 +- 需求覆盖:**R1 ✅ R2 ✅ R3 ✅ R4 ✅(WFP 三档,仅回环可用 WFP 规则表达)R5 ⚠️**(Rust crate,不是 Python 库;Python 集成路径有两条:① 把 `codex sandbox -- ` 当外部子进程调用——最省事但引入重依赖;② 以它为蓝本用 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_lib(vendor) | ✅ | ⚠️ 需补写授权 | ✅ | ❌ 需补 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 Object(pywin32 `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/ +- GRR:https://github.com/google/grr ;sandbox_lib.py 源码:https://raw.githubusercontent.com/google/grr/master/grr/client/grr_response_client/unprivileged/windows/sandbox_lib.py +- Sandboxie:https://github.com/sandboxie-plus/Sandboxie ;命令行文档:https://sandboxie-plus.github.io/sandboxie-docs/Content/StartCommandLine.html ;ClosedFilePath:https://sandboxie-plus.github.io/sandboxie-docs/Content/ClosedFilePath.html ;WFP 网络配置 issue:https://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 Containers:https://learn.microsoft.com/en-us/virtualization/windowscontainers/manage-containers/hyperv-container ;镜像尺寸:https://github.com/microsoft/Windows-Containers/issues/584 +- Win32 App Isolation:https://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 Workspace:https://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 +- WinFsp:https://github.com/winfsp/winfsp ;Dokany:https://github.com/dokan-dev/dokany +- GSoC AppContainer PoC:https://github.com/AnushkaPandit-21/appcontainer-node-sandbox diff --git a/wsl2-sandbox-poc-report.md b/wsl2-sandbox-poc-report.md new file mode 100644 index 00000000..2cad5287 --- /dev/null +++ b/wsl2-sandbox-poc-report.md @@ -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: 固定公共 DNS(223.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 --chdir \ + --proc /proc --dev /dev --tmpfs /tmp -- bash -lc +工作区可写: 同上,工作区改为 --bind +网络全禁/仅回环: 去掉 --share-net(--unshare-all 自带的 unshare-net,lo 自动可用) +``` + +| 分组 | 实验 | 结果 | 说明 | +|---|---|---|---| +| 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 ` 掩蔽单文件 | ✅ | | +| E3 禁读 | `--tmpfs ` 掩蔽整目录 | ✅ | 可实现 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 → 宿主解析器)**会失效**(时好时坏);直连公共 DNS(223.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+bwrap(PoC 验证后) | +|---|---|---| +| 只读模式 | 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-net(restricted≈仅回环)✅ | +| 审批链语境 | 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`。 diff --git a/wsl2-sandbox-research.md b/wsl2-sandbox-research.md new file mode 100644 index 00000000..8be148e9 --- /dev/null +++ b/wsl2-sandbox-research.md @@ -0,0 +1,169 @@ +# WSL2 等价强度沙箱可行性调研报告 + +调研日期:2026(本轮)。结论先行:**WSL2 可以做出与 macOS sandbox-exec 等价强度的沙箱**,核心路径是「专用 WSL2 发行版 + 发行版内 user/mount/network namespace(unshare 或 bubblewrap)」。因为 WSL2 跑的是真实 Linux 内核(如 6.6.x-microsoft-standard-WSL2),Linux 侧的方案(unshare / bwrap)基本可以完全复用;最大的工程成本在 Windows 侧的发行版供给、路径转换和 wsl.exe 的编码/开销问题。 + +--- + +## 1. 发行版供给:可行(编程式部署专用沙箱发行版) + +### 结论 +**可行**。不要依赖用户已有的发行版,而是用 `wsl --import` 导入一个官方 rootfs tar,创建专用 distro(如 `astrion-sandbox`)。这是官方支持的、完全非交互的路径。 + +### 具体做法 + +**rootfs 官方来源:** +- Ubuntu WSL 专用 rootfs:`https://cloud-images.ubuntu.com/wsl//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 -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` 直接 EINVAL,bwrap 报 "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,ro(bind+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 + " +' +``` +方案 B(bubblewrap,推荐,与 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 -- bash -lc "cd ... && cmd"`。 +- 持久交互终端:把同样的包装器包在交互 shell 外面——`wsl -d astrion-sandbox -- bwrap -- 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 -m -n` 反复进入同一组 namespace(nsenter 是标准 Linux 能力,WSL2 可用)。 + +--- + +## 6. 性能:有限(每次起 wsl.exe 有数百 ms 开销,必须做进程复用) + +### 结论 +**可行但需要工程优化**。实测/社区数据一致指向:wsl.exe 每次调用有数百毫秒级固定开销,高频命令场景必须保活 + 复用长驻进程。 + +### 数据点 +- 从 Windows 侧经 wsl.exe 调用 Linux 程序,单次约 500–800ms([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 层而非 chmod(9p metadata 默认关) | +| 只读/写范围(mount ns) | 可行 | `unshare --mount` + `make-rprivate` + 两步 ro bind + 工作区 rw 放回;或 bubblewrap;必须 WSL2(WSL1 无 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) 可共用几乎同一份沙箱策略代码。