agent-Specialization/modules/host_sandbox_runner.py
JOJO 6ee8cda2a7 feat(security): harden host execution model with sandbox/direct controls
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
2026-05-10 19:19:01 +08:00

182 lines
6.5 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 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.exeWSL2拒绝执行宿主机命令。")
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.exeWSL2拒绝启动宿主机沙箱终端。")
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))