This commit introduces a substantial security and runtime architecture update for host mode, with three major goals: (1) enforce OS-level sandboxing by default, (2) support a controlled temporary direct-execution escape hatch for admin users, and (3) eliminate silent fallback behavior that could hide risk. Backend/runtime changes:\n- Added a unified host sandbox runner (macOS sandbox-exec / Linux bubblewrap+seccomp / Windows WSL2).\n- Converted host terminal creation to sandboxed interactive shells by default.\n- Kept run_command/run_python on the same execution policy and added host execution mode plumbing (sandbox|direct).\n- Added session-scoped direct mode with TTL auto-revert (default 10 minutes), configurable via environment.\n- Added execution mode APIs (GET/POST /api/execution-mode), host+admin gating, status propagation, and rate limiting.\n- Added runtime refresh hooks so tool calls honor TTL expiration and mode transitions consistently.\n- Removed docker->host silent fallback paths: docker startup/runtime failures now fail fast instead of degrading silently.\n- Added host sandbox prompt injection (host-only) to guide agent behavior under permission constraints. Configuration and policy changes:\n- Added HOST_EXECUTION_MODE_DEFAULT and HOST_EXECUTION_DIRECT_TTL_SECONDS.\n- Added HOST_SANDBOX_MACOS_WRITABLE_PATHS to support user-defined writable path allowlists.\n- Updated macOS sandbox profile generation to use minimal defaults (workspace + tmp + /dev/null) plus explicit allowlist extensions.\n- Updated .env.example documentation for new execution/sandbox controls. Frontend changes:\n- Extended permission popover to a two-column model (Permission + Execution Environment) for host admin sessions.\n- Added execution mode state/options to app state, fetching, and status synchronization flows.\n- Added execution mode switching action and user feedback toasts.\n- Kept warning emphasis via text styling only (removed warning border per UX request). Validation:\n- Python syntax checks passed for modified backend modules.\n- Existing smoke tests passed: python3 -m unittest test.test_server_refactor_smoke\n- Frontend production build passed: npm run build
182 lines
6.5 KiB
Python
182 lines
6.5 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import platform
|
||
import shutil
|
||
import shlex
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional
|
||
try:
|
||
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS
|
||
except Exception:
|
||
HOST_SANDBOX_MACOS_WRITABLE_PATHS = []
|
||
|
||
|
||
@dataclass
|
||
class SandboxPlan:
|
||
command: List[str]
|
||
env: Dict[str, str]
|
||
cwd: Optional[str] = None
|
||
seccomp_bpf_path: Optional[str] = None
|
||
|
||
|
||
class HostSandboxError(RuntimeError):
|
||
pass
|
||
|
||
|
||
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 build_host_sandbox_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||
system = platform.system()
|
||
if system == "Darwin":
|
||
return _build_macos_plan(command, work_path, env)
|
||
if system == "Linux":
|
||
return _build_linux_plan(command, work_path, env)
|
||
if system == "Windows":
|
||
return _build_windows_plan(command, work_path, env)
|
||
raise HostSandboxError(f"不支持的宿主机系统: {system}")
|
||
|
||
def build_host_sandbox_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||
system = platform.system()
|
||
if system == "Darwin":
|
||
return _build_macos_shell_plan(work_path, env)
|
||
if system == "Linux":
|
||
return _build_linux_shell_plan(work_path, env)
|
||
if system == "Windows":
|
||
return _build_windows_shell_plan(work_path, env)
|
||
raise HostSandboxError(f"不支持的宿主机系统: {system}")
|
||
|
||
|
||
def _build_macos_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||
sandbox_exec = shutil.which("sandbox-exec")
|
||
if not sandbox_exec:
|
||
raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝执行宿主机命令。")
|
||
profile = _macos_profile_for_workspace(work_path)
|
||
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
||
|
||
def _build_macos_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||
sandbox_exec = shutil.which("sandbox-exec")
|
||
if not sandbox_exec:
|
||
raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝启动宿主机沙箱终端。")
|
||
profile = _macos_profile_for_workspace(work_path)
|
||
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-i"]
|
||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
||
|
||
def _macos_profile_for_workspace(work_path: Path) -> str:
|
||
workspace = str(work_path.resolve())
|
||
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"]
|
||
for raw in HOST_SANDBOX_MACOS_WRITABLE_PATHS or []:
|
||
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)
|
||
return (
|
||
'(version 1) '
|
||
'(deny default) '
|
||
'(allow sysctl-read) '
|
||
'(allow process*) '
|
||
'(allow network*) '
|
||
'(allow file-read*) '
|
||
f'(allow file-write* {write_expr})'
|
||
)
|
||
|
||
|
||
def _build_linux_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||
bwrap = shutil.which("bwrap")
|
||
if not bwrap:
|
||
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap),拒绝执行宿主机命令。")
|
||
|
||
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
|
||
if not seccomp_bpf:
|
||
raise HostSandboxError("Linux 缺少 HOST_SANDBOX_LINUX_SECCOMP_BPF,拒绝执行宿主机命令。")
|
||
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
|
||
if not seccomp_path.exists():
|
||
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
|
||
shell_cmd = ["/bin/bash", "-lc", command]
|
||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
||
|
||
def _build_linux_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
|
||
bwrap = shutil.which("bwrap")
|
||
if not bwrap:
|
||
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap),拒绝启动宿主机沙箱终端。")
|
||
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
|
||
if not seccomp_bpf:
|
||
raise HostSandboxError("Linux 缺少 HOST_SANDBOX_LINUX_SECCOMP_BPF,拒绝启动宿主机沙箱终端。")
|
||
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
|
||
if not seccomp_path.exists():
|
||
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
|
||
shell_cmd = ["/bin/bash", "-i"]
|
||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
||
|
||
def _build_linux_common_plan(
|
||
work_path: Path,
|
||
env: Dict[str, str],
|
||
shell_cmd: List[str],
|
||
seccomp_path: Path,
|
||
) -> SandboxPlan:
|
||
bwrap = shutil.which("bwrap")
|
||
if not bwrap:
|
||
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap)。")
|
||
sandbox_root = str(work_path.resolve())
|
||
cmd = [
|
||
bwrap,
|
||
"--die-with-parent",
|
||
"--new-session",
|
||
"--unshare-all",
|
||
"--share-net",
|
||
"--ro-bind",
|
||
"/",
|
||
"/",
|
||
"--bind",
|
||
sandbox_root,
|
||
sandbox_root,
|
||
"--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))
|
||
|
||
|
||
def _build_windows_plan(command: str, work_path: Path, env: Dict[str, str]) -> 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))
|
||
|
||
def _build_windows_shell_plan(work_path: Path, env: Dict[str, str]) -> 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))
|