agent-Specialization/modules/host_sandbox_runner.py
JOJO b55f4a811d feat(security): 终端读写身份创建时钉死,权限跨界切换销毁重建
- 宿主机持久终端支持只读身份:build_host_sandbox_shell_plan 三平台(macOS Seatbelt 只读 profile / Linux ro-bind / Windows WSL 只读挂载)统一 readonly 参数;macOS 只读 profile 抽为 _macos_readonly_profile_for_workspace 与只读 run_command 共用
- docker_terminal_readonly_enabled 泛化为 terminal_readonly_enabled(docker uid 与宿主机沙箱同一判定:非 unrestricted 即只读身份)
- 权限跨界切换(受限档⇄unrestricted)销毁现有终端会话(close_all),受限档内部互切不销毁;与执行环境切换销毁策略一致
- terminal_input 工具层全面放开:只读白名单加入、审批集合移除、approval/auto_approval 命令文本分流删除;受限档终端内写入由系统 EPERM 兜底,审批写入走 run_command 两段式
2026-08-30 23:06:33 +08:00

660 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
import platform
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from modules.host_sandbox_policy import (
get_macos_writable_paths,
get_macos_readable_paths,
get_macos_deny_read_paths,
get_macos_deny_read_regexes,
)
from modules.i18n import tr
@dataclass
class SandboxPlan:
command: List[str]
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):
pass
# macOS 只读沙箱的系统路径白名单deny-default + allow-listCodex 风格)。
# 2026-08-30 起正式启用:只读沙箱 = 默认拒绝全部读取,仅本列表 + 路径授权
# macos_writable_paths / macos_readable_extra_paths+ 工作区可读。
# 列表经真机 PoC 校准:/System 含 dyld 共享缓存(进程启动必需)、
# /Library/Developer/CommandLineTools 是 Apple git 等开发工具的真身、
# /opt/homebrew 为 arm64 工具链Intel 的 /usr/local 已由 /usr 覆盖)、
# /private/var 覆盖 $TMPDIR/var/folders/...)。
MACOS_MINIMAL_READABLE_PATHS = [
"/bin",
"/sbin",
"/usr",
"/lib",
"/System",
"/Library/Apple",
"/Library/Developer/CommandLineTools",
"/Applications",
"/etc",
"/private/etc",
"/dev",
"/tmp",
"/private/tmp",
"/var",
"/private/var",
"/opt/homebrew",
]
def _expand_path(raw: str) -> Optional[str]:
"""展开路径中的 ~ 并返回绝对路径;无法展开时返回 None。"""
if not raw:
return None
try:
expanded = str(Path(raw).expanduser().resolve())
except Exception:
return None
return expanded
def _build_macos_read_rules(paths: List[str]) -> str:
"""把路径列表转成 (allow file-read* (subpath ...)) 规则段。"""
rules: List[str] = []
seen: set[str] = set()
for raw in paths:
expanded = _expand_path(raw)
if expanded and expanded not in seen:
seen.add(expanded)
rules.append(f'(allow file-read* (subpath "{expanded}"))')
return "\n".join(rules)
def _build_macos_deny_rules(paths: List[str]) -> str:
"""把路径列表转成 (deny file-read* (subpath ...)) 规则段。"""
rules: List[str] = []
seen: set[str] = set()
for raw in paths:
expanded = _expand_path(raw)
if expanded and expanded not in seen:
seen.add(expanded)
rules.append(f'(deny file-read* (subpath "{expanded}"))')
return "\n".join(rules)
def _build_macos_deny_regex_rules(patterns: List[str]) -> str:
"""把正则列表转成 (deny file-read* (regex #"...")) 规则段。"""
rules: List[str] = []
for pattern in patterns:
rules.append(f'(deny file-read* (regex #"{pattern}"))')
return "\n".join(rules)
def _build_macos_whitelist_read_rules(paths: List[str]) -> str:
"""白名单读规则:每个允许路径的 subpath allow + 其全部祖先目录的 literal allow。
两个实测要点2026-08-30 真机 PoC
1. Seatbelt 路径解析需要对每个祖先目录的读权限file-read*),缺一个祖先
进程 exec 会直接 Abort trap: 6file-read-metadata 不够,必须 file-read*)。
代价:祖先目录的顶层文件名可列出(读文件内容、列子目录仍被拒)。
2. 符号链接路径必须「原始形式 + 解析形式」双写:/etc→/private/etc 这类
链接,只写任一种都会 Operation not permitted链接遍历与目标各查一次
"""
literals: set[str] = set()
subpaths: List[str] = []
seen: set[str] = set()
def _add(path_str: str) -> None:
if not path_str or path_str in seen:
return
seen.add(path_str)
subpaths.append(path_str)
for ancestor in Path(path_str).parents:
ancestor_str = str(ancestor)
if ancestor_str and ancestor_str != ".":
literals.add(ancestor_str)
for raw in paths:
if not raw:
continue
try:
expanded = str(Path(raw).expanduser())
except Exception:
continue
_add(expanded)
resolved = _expand_path(raw)
if resolved:
_add(resolved)
rules: List[str] = []
for literal in sorted(literals):
rules.append(f'(allow file-read* (literal "{literal}"))')
for subpath in subpaths:
rules.append(f'(allow file-read* (subpath "{subpath}"))')
return "\n".join(rules)
# 宿主机网络权限档位
NETWORK_PERMISSION_RESTRICTED = "restricted" # macOS: 仅本地回环Linux/Windows: 暂不隔离
NETWORK_PERMISSION_FULL = "full" # 完全开放
NETWORK_PERMISSION_NONE = "none" # 完全禁止网络(后端保留)
_NETWORK_PERMISSION_VALUES = {
NETWORK_PERMISSION_RESTRICTED,
NETWORK_PERMISSION_FULL,
NETWORK_PERMISSION_NONE,
}
def _truthy(name: str, default: str = "1") -> bool:
return os.environ.get(name, default).strip().lower() not in {"0", "false", "no", "off"}
def host_sandbox_enabled() -> bool:
return _truthy("HOST_SANDBOX_ENABLED", "1")
def _normalize_network_permission(value: Optional[str]) -> str:
"""归一化网络权限值,非法值回退为 restricted。"""
normalized = str(value or "").strip().lower()
if normalized in _NETWORK_PERMISSION_VALUES:
return normalized
return NETWORK_PERMISSION_RESTRICTED
def _build_macos_network_policy(network_permission: str) -> str:
"""根据网络权限档位生成 macOS sandbox-exec 网络规则片段。"""
permission = _normalize_network_permission(network_permission)
if permission == NETWORK_PERMISSION_NONE:
return ""
if permission == NETWORK_PERMISSION_FULL:
return "(allow network-outbound)\n(allow network-inbound)\n"
# restricted: 仅允许本地回环出站(涵盖 127.0.0.1 / ::1 的实际效果)
return '(allow network-outbound (remote ip "localhost:*"))\n'
def build_host_sandbox_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
system = platform.system()
if system == "Darwin":
return _build_macos_plan(command, work_path, env, network_permission)
if system == "Linux":
return _build_linux_plan(command, work_path, env, network_permission)
if system == "Windows":
return _build_windows_plan(command, work_path, env, network_permission)
raise HostSandboxError(tr("sandbox.unsupported_system", system=system))
def build_host_sandbox_readonly_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
system = platform.system()
if system == "Darwin":
return _build_macos_readonly_plan(command, work_path, env, network_permission)
if system == "Linux":
return _build_linux_readonly_plan(command, work_path, env, network_permission)
if system == "Windows":
return _build_windows_readonly_plan(command, work_path, env, network_permission)
raise HostSandboxError(tr("sandbox.unsupported_system", system=system))
def build_host_sandbox_shell_plan(
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan:
"""持久终端(交互式 shell沙箱计划。
readonly=True 时使用只读身份macOS 只读 profile / Linux bwrap ro-bind /
Windows WSL 只读挂载):受限权限档(只读/批准/自动审核)的终端以此创建,
写入由系统直接拒绝EPERMunrestricted 档传 False 保持可写。
"""
system = platform.system()
if system == "Darwin":
return _build_macos_shell_plan(work_path, env, network_permission, readonly=readonly)
if system == "Linux":
return _build_linux_shell_plan(work_path, env, network_permission, readonly=readonly)
if system == "Windows":
return _build_windows_shell_plan(work_path, env, network_permission, readonly=readonly)
raise HostSandboxError(tr("sandbox.unsupported_system", system=system))
def _build_macos_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec")
if not sandbox_exec:
raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec"))
profile = _macos_profile_for_workspace(work_path, network_permission)
# 白名单读模型下 ~/.gitconfig 不可读会使 git fatalPoC 实测),指向 /dev/null 跳过
plan_env = dict(env)
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
def _build_macos_readonly_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec")
if not sandbox_exec:
raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec"))
profile = _macos_readonly_profile_for_workspace(work_path, network_permission)
# git 在 ~/.gitconfig 不可读时会 fatalPoC 实测),指向 /dev/null 跳过全局配置
plan_env = dict(env)
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
def _macos_readonly_profile_for_workspace(
work_path: Path,
network_permission: Optional[str] = None,
) -> str:
"""macOS 只读沙箱 profiledeny-default 白名单读模型2026-08-30 起)。
默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读,
写权限仅 /dev/nulldeny 规则在白名单内做最后排除(如工作区内的 .env
历史模型为「全局可读 + 敏感路径黑名单」,且 deny 顺序在 workspace allow
之前导致工作区内 .env 实际可读Seatbelt 后规则覆盖先规则),本次一并修复。
只读 run_command 与受限档持久终端shell plan readonly=True共用本函数。
"""
network_policy = _build_macos_network_policy(network_permission)
workspace = str(work_path.resolve())
readable_paths = list(MACOS_MINIMAL_READABLE_PATHS)
readable_paths.extend(get_macos_readable_paths())
readable_paths.append(str(work_path)) # 原始形式(可能含符号链接)
readable_paths.append(workspace) # 解析形式
allow_rules = _build_macos_whitelist_read_rules(readable_paths)
deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths())
regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes())
if regex_rules:
deny_rules += "\n" + regex_rules
return (
'(version 1)\n'
'(deny default)\n'
'(allow sysctl-read)\n'
'(allow process*)\n'
f'{network_policy}'
f'{allow_rules}\n'
# deny 必须位于所有 allow 之后(后规则覆盖先规则)
f'{deny_rules}\n'
'(allow file-write* (literal "/dev/null"))'
)
def _build_macos_shell_plan(
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec")
if not sandbox_exec:
raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec_shell"))
# 受限档终端以只读身份创建(与只读 run_command 同一 profile写入 EPERM
# unrestricted 保持可写 profile白名单读 + 工作区/授权路径可写)。
if readonly:
profile = _macos_readonly_profile_for_workspace(work_path, network_permission)
else:
profile = _macos_profile_for_workspace(work_path, network_permission)
# 同 _build_macos_plan白名单读下 git 需要 GIT_CONFIG_GLOBAL 兜底
plan_env = dict(env)
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-i"]
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
def _macos_profile_for_workspace(
work_path: Path,
network_permission: Optional[str] = None,
) -> str:
workspace = str(work_path.resolve())
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"]
for raw in get_macos_writable_paths():
try:
expanded = str(Path(raw).expanduser().resolve())
except Exception:
continue
if expanded not in writable_paths:
writable_paths.append(expanded)
write_rules: list[str] = []
for entry in writable_paths:
if entry == "/dev/null":
write_rules.append('(literal "/dev/null")')
else:
write_rules.append(f'(subpath "{entry}")')
write_expr = " ".join(write_rules)
network_policy = _build_macos_network_policy(network_permission)
# 可写沙箱2026-08-30 起)与只读沙箱共用同一白名单读模型:
# 默认拒绝全部读取,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读。
# 权限模式只管工作区内读写——unrestricted 也不例外;工作区外读取的唯一途径
# 是「路径授权」。历史模型为「全局可读 + 黑名单」,导致无限制模式/审批批准后
# 能读授权范围外文件(读放大),本次按方案一修复:审批不放大读取。
readable_paths = list(MACOS_MINIMAL_READABLE_PATHS)
readable_paths.extend(get_macos_readable_paths())
readable_paths.append(str(work_path)) # 原始形式(可能含符号链接)
readable_paths.append(workspace) # 解析形式
allow_rules = _build_macos_whitelist_read_rules(readable_paths)
deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths())
regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes())
if regex_rules:
deny_rules += "\n" + regex_rules
return (
'(version 1)\n'
'(deny default)\n'
'(allow sysctl-read)\n'
'(allow process*)\n'
f'{network_policy}'
f'{allow_rules}\n'
# deny 必须位于所有 allow 之后Seatbelt 后规则覆盖先规则),
# 否则工作区内的 .env 会被 workspace allow 覆盖成可读(旧顺序漏洞)
f'{deny_rules}\n'
f'(allow file-write* {write_expr})'
)
def _build_linux_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError(tr("sandbox.linux_no_bwrap_exec"))
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
if not seccomp_bpf:
raise HostSandboxError(tr("sandbox.linux_no_seccomp_exec"))
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
if not seccomp_path.exists():
raise HostSandboxError(tr("sandbox.seccomp_bpf_not_found", path=seccomp_path))
shell_cmd = ["/bin/bash", "-lc", command]
# network_permission 暂不参与 Linux 构建,保持现有 --share-net 行为
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
def _build_linux_readonly_plan(
command: str,
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError(tr("sandbox.linux_no_bwrap_exec"))
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
if not seccomp_bpf:
raise HostSandboxError(tr("sandbox.linux_no_seccomp_exec"))
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
if not seccomp_path.exists():
raise HostSandboxError(tr("sandbox.seccomp_bpf_not_found", path=seccomp_path))
shell_cmd = ["/bin/bash", "-lc", command]
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path, readonly=True)
def _build_linux_shell_plan(
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError(tr("sandbox.linux_no_bwrap_shell"))
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
if not seccomp_bpf:
raise HostSandboxError(tr("sandbox.linux_no_seccomp_shell"))
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
if not seccomp_path.exists():
raise HostSandboxError(tr("sandbox.seccomp_bpf_not_found", path=seccomp_path))
shell_cmd = ["/bin/bash", "-i"]
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path, readonly=readonly)
def _build_linux_common_plan(
work_path: Path,
env: Dict[str, str],
shell_cmd: List[str],
seccomp_path: Path,
readonly: bool = False,
) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError(tr("sandbox.linux_no_bwrap_brief"))
sandbox_root = str(work_path.resolve())
cmd: List[str] = [
bwrap,
"--die-with-parent",
"--new-session",
"--unshare-all",
"--share-net",
"--ro-bind",
"/",
"/",
]
if readonly:
cmd.extend(["--ro-bind", sandbox_root, sandbox_root])
else:
cmd.extend(["--bind", sandbox_root, sandbox_root])
cmd.extend([
"--chdir",
sandbox_root,
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--seccomp",
"__SECCOMP_FD__",
*shell_cmd,
])
return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path))
# ──────────────────────────────────────────────────────────────
# WindowsWSL2 + bubblewrap 沙箱
#
# 设计要点(依据 .wsl-poc 与 .wsl-exp 两轮实验,见 wsl2-sandbox-poc-report.md
# 与项目记忆 wsl_sandbox_minimal_root
# - 使用专用沙箱发行版(默认 astrion-sandbox必须关闭 interop
# 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机;
# - 最小根文件系统:只挂载发行版的 Linux 系统目录(/bin /sbin /usr /lib /etc
# 纯工具链、无用户数据)+ 工作区 bind不挂载 / 本身与任何 /mnt/<盘>
# 工作区外的数据在命名空间内根本不存在(报 No such file or directory
# bwrap 是挂载命名空间构造器而非访问过滤器,因此可以实现 macOS Seatbelt
# 做不到的“指令正常运行 + 默认拒绝的完美读限制”;
# - bwrap 为挂载点自动创建的中间父目录是会话内可写 tmpfs不落盘、无安全
# 问题但缺报错语义),启动后先 mount -o remount,ro / 恢复只读报错;
# - 网络档位full → --share-netrestricted仅回环/ none → 不 share-net
# unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted
# - 敏感路径掩蔽清单windows_deny_read_paths不再需要数据根本不进命名
# 空间;该清单仍由 server/chat/permission.py 用于原生读工具的禁读判断;
# - 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(tr("sandbox.wsl_path_convert_failed", path=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(tr("sandbox.windows_no_wsl"))
env = dict(os.environ)
env["WSL_UTF8"] = "1"
setup_hint = tr("sandbox.wsl_setup_hint", distro=name)
try:
probe = subprocess.run(
[wsl, "-d", name, "-e", "true"],
capture_output=True, timeout=30, env=env,
)
except Exception as exc:
raise HostSandboxError(tr("sandbox.wsl_distro_probe_failed", error=exc, hint=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(tr("sandbox.wsl_bwrap_probe_failed", error=exc, hint=setup_hint))
if probe_bwrap.returncode != 0:
raise HostSandboxError(tr("sandbox.wsl_distro_no_bwrap", distro=name))
_wsl_distro_verified[name] = True
return name
def _build_windows_bwrap_argv(
ws_wsl: str,
shell_cmd: List[str],
readonly: bool,
network_permission: Optional[str],
) -> List[str]:
permission = _normalize_network_permission(network_permission)
argv: List[str] = [
"bwrap",
"--die-with-parent",
"--new-session",
"--unshare-all",
]
# full → 共享网络restricted仅回环与 none → unshare-netlo 仍可用)
if permission == NETWORK_PERMISSION_FULL:
argv.append("--share-net")
# 最小根文件系统:只挂沙箱发行版的 Linux 系统目录(纯工具链、无用户数据)。
# 不挂载 / 本身与任何 /mnt/<盘>——工作区外的数据在命名空间内不存在。
# 注意:若日后改用 glibc 发行版(如 Ubuntu需补 --ro-bind /lib64 /lib64。
for sysdir in ("/bin", "/sbin", "/usr", "/lib", "/etc"):
argv += ["--ro-bind", sysdir, sysdir]
argv += (["--ro-bind"] if readonly else ["--bind"]) + [ws_wsl, ws_wsl]
argv += [
"--chdir", ws_wsl,
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--tmpfs", "/var/tmp",
"--dir", "/root",
"--",
# bwrap 为挂载点自动创建的中间父目录(如 /mnt/e是会话内可写 tmpfs
# (写入不落盘、退出即消失,无安全问题),但写入不报错、与 mac 的审批
# 关键词语义不一致;启动后先把根 remount 为只读,再 exec 真正的命令。
# $0="bwrap-sh" 仅作占位,$@ 从 shell_cmd 开始exec "$@" 按 argv 原样
# 透传,避免对用户命令做字符串拼接(切勿加 shift否则会丢掉 argv[0])。
"bash", "-c", 'mount -o remount,ro / 2>/dev/null; exec "$@"', "bwrap-sh",
*shell_cmd,
]
return argv
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(tr("sandbox.windows_no_wsl"))
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"
# 必须用 -eexec不经默认 shell而非 ---- 形式会把尾部交给 /bin/sh 重新解析,
# 带空格/引号的参数会被拆散bash -c 后的位置参数全部丢失),-e 原样传递 argv
# .wsl-exp/test_argprobe2.py 实测P4/P6(--) 参数丢失P5/P8(-e) 完整)。
return SandboxPlan(
command=[wsl, "-d", distro, "-e", *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:
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(
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan:
return _build_windows_wsl_plan(
work_path, env, ["bash", "-i"], readonly=readonly,
network_permission=network_permission,
)