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:
parent
a0ea599c05
commit
42cd99d2d8
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ venv/
|
||||
# Runtime data (main agent)
|
||||
# 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion/<mode>/。
|
||||
# 以下条目用于忽略「通过具体目录变量指回源码树」或历史遗留的运行态目录。
|
||||
.wsl-poc/
|
||||
.astrion/
|
||||
.agents/
|
||||
logs/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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/<mode>/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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
9
prompts/execution_mode/windows.txt
Normal file
9
prompts/execution_mode/windows.txt
Normal file
@ -0,0 +1,9 @@
|
||||
## 执行环境:{execution_mode_label}({execution_mode})
|
||||
|
||||
{environment_rules}
|
||||
|
||||
### 当前规则
|
||||
{rules}
|
||||
{network_rules}
|
||||
|
||||
{switch_invariant}
|
||||
83
scripts/setup-wsl-sandbox.ps1
Normal file
83
scripts/setup-wsl-sandbox.ps1
Normal 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 不可用。请先启用 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"
|
||||
168
windows-sandbox-research.md
Normal file
168
windows-sandbox-research.md
Normal 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`** | 唯一一个**纯 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=<program>,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 ,本轮全文核实):`<MappedFolders>` 支持 `ReadOnly=true/false`、`<Networking>Enable/Disable`、`<LogonCommand>` 开机执行一条命令、内存上限。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 -- <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_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
|
||||
90
wsl2-sandbox-poc-report.md
Normal file
90
wsl2-sandbox-poc-report.md
Normal 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: 固定公共 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 <ws> <ws> --chdir <ws> \
|
||||
--proc /proc --dev /dev --tmpfs /tmp -- bash -lc <cmd>
|
||||
工作区可写: 同上,工作区改为 --bind <ws> <ws>
|
||||
网络全禁/仅回环: 去掉 --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 <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 → 宿主解析器)**会失效**(时好时坏);直连公共 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`。
|
||||
169
wsl2-sandbox-research.md
Normal file
169
wsl2-sandbox-research.md
Normal file
@ -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/<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` 直接 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 <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` 反复进入同一组 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) 可共用几乎同一份沙箱策略代码。
|
||||
Loading…
Reference in New Issue
Block a user