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
This commit is contained in:
JOJO 2026-05-10 19:19:01 +08:00
parent 71a2b4014e
commit 6ee8cda2a7
20 changed files with 803 additions and 107 deletions

View File

@ -41,8 +41,14 @@ TERMINAL_SANDBOX_BINDS=
# 运行时路径及容器名称前缀
TERMINAL_SANDBOX_BIN=docker
TERMINAL_SANDBOX_NAME_PREFIX=agent-term
# 启动失败时是否强制报错(1=强制容器0=允许回退到宿主机
# 启动失败时是否强制报错(建议 1当前实现中 docker 失败会直接报错
TERMINAL_SANDBOX_REQUIRE=0
# 宿主机执行环境默认模式sandbox / direct建议 sandbox
HOST_EXECUTION_MODE_DEFAULT=sandbox
# direct 模式自动回退秒数600=10分钟0 或 -1 表示不自动回退
HOST_EXECUTION_DIRECT_TTL_SECONDS=600
# macOS 宿主机沙箱:额外可写白名单路径(逗号分隔,默认仅工作区+/tmp
HOST_SANDBOX_MACOS_WRITABLE_PATHS=
# 注入到容器的额外环境变量(以 TERMINAL_SANDBOX_ENV_* 命名)
# 例如TERMINAL_SANDBOX_ENV_HTTP_PROXY=http://127.0.0.1:7890

View File

@ -23,6 +23,16 @@ def _parse_bindings(raw_value: str):
return items
def _parse_paths(raw_value: str):
items = []
for chunk in (raw_value or "").split(","):
chunk = chunk.strip()
if not chunk:
continue
items.append(chunk)
return items
_env_prefix = "TERMINAL_SANDBOX_ENV_"
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower()
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
@ -43,6 +53,11 @@ TERMINAL_SANDBOX_REQUIRE = os.environ.get("TERMINAL_SANDBOX_REQUIRE", "0") not i
LINUX_SAFETY = os.environ.get("LINUX_SAFETY", "0") not in {"0", "false", "False"}
TOOLBOX_TERMINAL_IDLE_SECONDS = int(os.environ.get("TOOLBOX_TERMINAL_IDLE_SECONDS", "900"))
MAX_ACTIVE_USER_CONTAINERS = int(os.environ.get("MAX_ACTIVE_USER_CONTAINERS", "8"))
HOST_EXECUTION_MODE_DEFAULT = os.environ.get("HOST_EXECUTION_MODE_DEFAULT", "sandbox").strip().lower()
HOST_EXECUTION_DIRECT_TTL_SECONDS = int(os.environ.get("HOST_EXECUTION_DIRECT_TTL_SECONDS", "600"))
HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
)
__all__ = [
"MAX_TERMINALS",
@ -69,4 +84,7 @@ __all__ = [
"LINUX_SAFETY",
"TOOLBOX_TERMINAL_IDLE_SECONDS",
"MAX_ACTIVE_USER_CONTAINERS",
"HOST_EXECUTION_MODE_DEFAULT",
"HOST_EXECUTION_DIRECT_TTL_SECONDS",
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
]

View File

@ -1,6 +1,7 @@
# core/main_terminal.py - 主终端(集成对话持久化)
from pathlib import Path
import time
from typing import Dict, List, Optional, Set, TYPE_CHECKING
try:
@ -15,6 +16,8 @@ try:
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
MCP_TOOLS_ENABLED,
HOST_EXECUTION_MODE_DEFAULT,
HOST_EXECUTION_DIRECT_TTL_SECONDS,
)
except ImportError:
import sys
@ -32,6 +35,8 @@ except ImportError:
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
MCP_TOOLS_ENABLED,
HOST_EXECUTION_MODE_DEFAULT,
HOST_EXECUTION_DIRECT_TTL_SECONDS,
)
from modules.file_manager import FileManager
@ -199,9 +204,12 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.skill_strict_run_command_background_enabled: bool = False
self.default_permission_mode: str = "unrestricted"
self.current_permission_mode: str = "unrestricted"
self.host_execution_mode: str = "sandbox"
self.host_execution_direct_until: Optional[float] = None
# 当前生效的启用 skills用于强约束判定
self.enabled_skill_ids: Set[str] = set()
self.apply_personalization_preferences()
self._init_host_execution_mode()
# 新增:自动开始新对话
self._ensure_conversation()
@ -231,6 +239,57 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
else:
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
def _init_host_execution_mode(self):
default_mode = str(HOST_EXECUTION_MODE_DEFAULT or "sandbox").strip().lower()
self.host_execution_mode = "direct" if default_mode == "direct" else "sandbox"
self.host_execution_direct_until = None
self._apply_execution_mode_to_runtime()
def _apply_execution_mode_to_runtime(self):
mode = self.get_execution_mode()
if getattr(self, "terminal_ops", None):
self.terminal_ops.set_host_execution_mode(mode)
if getattr(self, "terminal_manager", None):
self.terminal_manager.set_host_execution_mode(mode)
def _refresh_execution_mode_by_ttl(self):
if self.host_execution_mode != "direct":
return
until = self.host_execution_direct_until
if not until:
return
if time.time() >= float(until):
self.host_execution_mode = "sandbox"
self.host_execution_direct_until = None
self._apply_execution_mode_to_runtime()
def get_execution_mode(self) -> str:
self._refresh_execution_mode_by_ttl()
return "direct" if self.host_execution_mode == "direct" else "sandbox"
def get_execution_mode_state(self) -> Dict:
mode = self.get_execution_mode()
ttl_seconds = int(HOST_EXECUTION_DIRECT_TTL_SECONDS)
return {
"mode": mode,
"default_mode": "direct" if str(HOST_EXECUTION_MODE_DEFAULT).lower() == "direct" else "sandbox",
"ttl_seconds": ttl_seconds,
"direct_until": self.host_execution_direct_until,
}
def set_execution_mode(self, mode: str) -> Dict:
normalized = str(mode or "").strip().lower()
if normalized not in {"sandbox", "direct"}:
raise ValueError("无效执行环境,仅支持 sandbox / direct")
self.host_execution_mode = normalized
ttl_seconds = int(HOST_EXECUTION_DIRECT_TTL_SECONDS)
if normalized == "direct":
self.host_execution_direct_until = None if ttl_seconds in (0, -1) else (time.time() + max(1, ttl_seconds))
else:
self.host_execution_direct_until = None
self._apply_execution_mode_to_runtime()
return self.get_execution_mode_state()
def _is_host_mode(self) -> bool:
"""判定当前是否运行在宿主机模式,用于豁免配额等限制。"""
if self.container_session and getattr(self.container_session, "mode", None) != "docker":

View File

@ -101,6 +101,11 @@ class MainTerminalContextMixin:
"approval": "批准",
}
_EXECUTION_MODE_LABEL = {
"sandbox": "沙箱",
"direct": "完全访问权限",
}
def _build_permission_mode_message(self) -> Optional[str]:
"""根据当前权限模式构建权限说明消息(从模板读取并替换占位符)"""
template = self.load_prompt("permission_mode")
@ -144,6 +149,42 @@ class MainTerminalContextMixin:
detailed_rules=detailed_rules,
)
def _build_execution_mode_message(self) -> Optional[str]:
"""根据当前执行环境模式构建提示消息。"""
# 仅宿主机模式注入Docker 模式不需要该提示。
try:
if not getattr(self, "_is_host_mode", lambda: False)():
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:
state = self.get_execution_mode_state() or {}
except Exception:
state = {}
mode = str(state.get("mode") or "sandbox").strip().lower()
mode_label = self._EXECUTION_MODE_LABEL.get(mode, mode)
if mode == "sandbox":
rules = (
"- 所有命令默认在系统 OS 沙箱中执行\n"
"- 若命令因系统权限或目录访问限制失败,请先给出替代方案(只读检查、分步验证);若该操作确属必须,再提示用户切换为“完全访问权限”或让用户在本地授权白名单后重试"
)
else:
rules = (
"- 当前为宿主机直接执行模式(完全访问权限)\n"
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
)
return template.format(
execution_mode=mode,
execution_mode_label=mode_label,
rules=rules,
)
def build_context(self) -> Dict:
"""构建主终端上下文"""
# 读取记忆
@ -241,6 +282,9 @@ class MainTerminalContextMixin:
permission_mode_message = self._build_permission_mode_message()
if permission_mode_message:
messages.append({"role": "system", "content": permission_mode_message})
execution_mode_message = self._build_execution_mode_message()
if execution_mode_message:
messages.append({"role": "system", "content": execution_mode_message})
if self.tool_category_states.get("sub_agent", True):
sub_agent_prompt = self.load_prompt("sub_agent_guidelines").strip()

View File

@ -554,6 +554,13 @@ class MainTerminalToolsExecutionMixin:
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
"""处理工具调用(添加参数预检查和改进错误处理)"""
logger.info("[handle_tool_call] 工具调用开始: tool_name=%s, arguments=%s", tool_name, arguments)
try:
if hasattr(self, "_refresh_execution_mode_by_ttl"):
self._refresh_execution_mode_by_ttl()
if hasattr(self, "_apply_execution_mode_to_runtime"):
self._apply_execution_mode_to_runtime()
except Exception:
pass
# 导入字符限制配置
from config import (

View File

@ -332,6 +332,8 @@ class WebTerminal(MainTerminal):
"run_mode": self.run_mode,
"model_key": getattr(self, "model_key", None),
"permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted",
"execution_mode": self.get_execution_mode_state() if hasattr(self, "get_execution_mode_state") else {"mode": "sandbox"},
"execution_mode_enabled": bool(self._is_host_mode()) and getattr(self, "user_role", "user") == "admin",
"has_images": getattr(self.context_manager, "has_images", False),
"has_videos": getattr(self.context_manager, "has_videos", False),
"context": {

View File

@ -0,0 +1,181 @@
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))

View File

@ -15,6 +15,11 @@ from collections import deque
import shutil
import uuid
import codecs
from modules.host_sandbox_runner import (
HostSandboxError,
build_host_sandbox_shell_plan,
host_sandbox_enabled,
)
try:
from config import (
OUTPUT_FORMATS,
@ -149,6 +154,7 @@ class PersistentTerminal:
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
self.sandbox_options = sandbox_defaults
self.sandbox_required = bool(self.sandbox_options.get("require"))
self.allow_direct_host_execution = bool(self.sandbox_options.get("allow_direct_host_execution", False))
self.sandbox_container_name = None
self.execution_mode = "host"
self.using_container = False
@ -168,12 +174,8 @@ class PersistentTerminal:
process = self._start_docker_terminal()
except Exception as exc:
message = f"容器终端启动失败: {exc}"
if self.sandbox_required:
print(f"{OUTPUT_FORMATS['error']} {message}")
return False
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机终端。")
process = None
selected_mode = "host"
print(f"{OUTPUT_FORMATS['error']} {message}")
return False
if process is None:
process = self._start_host_terminal()
selected_mode = "host"
@ -230,6 +232,57 @@ class PersistentTerminal:
def _start_host_terminal(self):
"""启动宿主机终端"""
if self.allow_direct_host_execution:
return self._start_plain_host_terminal()
if not host_sandbox_enabled():
raise RuntimeError("宿主机沙箱已禁用HOST_SANDBOX_ENABLED=0拒绝启动 host 终端。")
self.using_container = False
self._owns_container = False
self.is_windows = sys.platform == "win32"
env = os.environ.copy()
env['PYTHONIOENCODING'] = 'utf-8'
env['TERM'] = 'xterm-256color'
env['LANG'] = 'en_US.UTF-8'
env['LC_ALL'] = 'en_US.UTF-8'
try:
plan = build_host_sandbox_shell_plan(self.working_dir, env)
cmd_args = plan.command
pass_fds = ()
seccomp_fd = None
if plan.seccomp_bpf_path:
seccomp_fd = os.open(plan.seccomp_bpf_path, os.O_RDONLY)
cmd_args = [str(seccomp_fd) if token == "__SECCOMP_FD__" else token for token in cmd_args]
pass_fds = (seccomp_fd,)
try:
process = subprocess.Popen(
cmd_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=plan.cwd or str(self.working_dir),
shell=False,
bufsize=0,
env=plan.env,
pass_fds=pass_fds
)
finally:
if seccomp_fd is not None:
try:
os.close(seccomp_fd)
except OSError:
pass
self.shell_command = "host-sandbox-shell"
self.is_windows = False
return process
except HostSandboxError as exc:
raise RuntimeError(str(exc))
except FileNotFoundError:
print(f"{OUTPUT_FORMATS['error']} 无法找到宿主机沙箱终端运行时")
return None
def _start_plain_host_terminal(self):
self.using_container = False
self._owns_container = False
self.is_windows = sys.platform == "win32"
@ -239,41 +292,22 @@ class PersistentTerminal:
else:
shell_cmd = shell_cmd or os.environ.get('SHELL', '/bin/bash')
self.shell_command = shell_cmd
env = os.environ.copy()
env['PYTHONIOENCODING'] = 'utf-8'
try:
if self.is_windows:
env['CHCP'] = '65001'
process = subprocess.Popen(
shell_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.working_dir),
shell=False,
bufsize=0,
env=env
)
else:
env['TERM'] = 'xterm-256color'
env['LANG'] = 'en_US.UTF-8'
env['LC_ALL'] = 'en_US.UTF-8'
process = subprocess.Popen(
shell_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.working_dir),
shell=False,
bufsize=0,
env=env
)
return process
except FileNotFoundError:
print(f"{OUTPUT_FORMATS['error']} 无法找到终端程序: {shell_cmd}")
return None
env['TERM'] = 'xterm-256color'
env['LANG'] = 'en_US.UTF-8'
env['LC_ALL'] = 'en_US.UTF-8'
process = subprocess.Popen(
shell_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.working_dir),
shell=False,
bufsize=0,
env=env
)
return process
def _start_docker_terminal(self):
"""启动或连接容器化终端。"""

View File

@ -137,6 +137,7 @@ class TerminalManager:
# 终端工厂(跨平台支持)
self.factory = TerminalFactory()
self.host_execution_mode: str = "sandbox"
def _apply_container_session(self, session: Optional["ContainerHandle"]):
"""根据容器句柄调整执行模式。"""
@ -151,6 +152,7 @@ class TerminalManager:
def _build_sandbox_options(self) -> Dict:
"""构造当前终端应使用的沙箱参数。"""
options = dict(self.sandbox_options)
options["allow_direct_host_execution"] = self.sandbox_mode == "host" and self.host_execution_mode == "direct"
if self.container_session and self.container_session.mode == "docker":
options["container_name"] = self.container_session.container_name
options["mount_path"] = self.container_session.mount_path
@ -158,6 +160,16 @@ class TerminalManager:
options.pop("container_name", None)
return options
def set_host_execution_mode(self, mode: str) -> None:
normalized = str(mode or "").strip().lower()
target = "direct" if normalized == "direct" else "sandbox"
if self.host_execution_mode == target:
return
self.host_execution_mode = target
if self.sandbox_mode == "host" and self.terminals:
print(f"{OUTPUT_FORMATS['warning']} 执行环境已切换为 {target},正在关闭现有终端会话。")
self.close_all()
@staticmethod
def _same_container(a: Optional["ContainerHandle"], b: Optional["ContainerHandle"]) -> bool:
if a is b:

View File

@ -33,6 +33,11 @@ except ImportError:
TOOLBOX_TERMINAL_IDLE_SECONDS,
)
from modules.toolbox_container import ToolboxContainer
from modules.host_sandbox_runner import (
HostSandboxError,
build_host_sandbox_plan,
host_sandbox_enabled,
)
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
@ -52,6 +57,11 @@ class TerminalOperator:
self.container_session: Optional["ContainerHandle"] = container_session
# 记录 TerminalManager 引用,便于 CLI 场景复用同一容器
self._terminal_manager: Optional["TerminalManager"] = None
self.host_execution_mode: str = "sandbox"
def set_host_execution_mode(self, mode: str) -> None:
normalized = str(mode or "").strip().lower()
self.host_execution_mode = "direct" if normalized == "direct" else "sandbox"
def _reset_toolbox(self):
"""强制关闭并重建工具终端,保证每次命令/脚本运行独立环境。"""
@ -131,7 +141,31 @@ class TerminalOperator:
def _detect_preinstalled_python(self) -> Optional[str]:
"""尝试定位预装虚拟环境的 python 可执行文件。"""
# 允许显式指定可执行文件(优先级最高)
explicit_python = os.environ.get("AGENT_PYTHON_BIN")
if explicit_python:
p = Path(explicit_python).expanduser()
if p.exists() and os.access(p, os.X_OK) and not self._is_blocked_python_candidate(str(p)):
try:
return str(p.resolve())
except Exception:
return str(p)
candidates = []
if sys.platform == "darwin":
# 优先使用非 shim 的系统 Python 路径(避免 /usr/bin/python3 xcode shim
mac_python_candidates = [
"/Library/Developer/CommandLineTools/usr/bin/python3",
"/opt/homebrew/bin/python3",
"/usr/local/bin/python3",
]
for raw_py in mac_python_candidates:
p = Path(raw_py)
if p.exists() and os.access(p, os.X_OK) and not self._is_blocked_python_candidate(str(p)):
try:
return str(p.resolve())
except Exception:
return str(p)
# 环境变量优先Dockerfile 已设置 AGENT_TOOLBOX_VENV=/opt/agent-venv
env_venv = os.environ.get("AGENT_TOOLBOX_VENV")
@ -162,30 +196,48 @@ class TerminalOperator:
def _detect_system_python(self) -> str:
"""在系统 PATH 中探测可用的 Python3 可执行文件。"""
commands_to_try = []
if sys.platform == "win32":
commands_to_try = ["python", "py", "python3"]
else:
commands_to_try = ["python3", "python"]
commands_to_try = ["python", "py", "python3"] if sys.platform == "win32" else ["python3", "python"]
for cmd in commands_to_try:
if shutil.which(cmd):
try:
result = subprocess.run(
[cmd, "--version"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
output = result.stdout + result.stderr
if "Python 3" in output or "Python 2" not in output:
return cmd
except Exception:
continue
resolved = shutil.which(cmd)
if not resolved:
continue
if self._is_blocked_python_candidate(resolved):
continue
try:
result = subprocess.run(
[resolved, "--version"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
output = result.stdout + result.stderr
if "Python 3" in output or "Python 2" not in output:
return resolved
except Exception:
continue
return "python" if sys.platform == "win32" else "python3"
fallback = "python" if sys.platform == "win32" else "python3"
fallback_resolved = shutil.which(fallback) or fallback
if sys.platform == "darwin" and self._is_blocked_python_candidate(fallback_resolved):
raise RuntimeError(
"未找到可用的 Python3 可执行文件:检测到 /usr/bin/python3 (xcode shim) 在沙箱中不可用。"
"请设置 AGENT_PYTHON_BIN 或安装非 shim 的 python3。"
)
return fallback
def _is_blocked_python_candidate(self, candidate: str) -> bool:
"""过滤已知在当前沙箱下不可靠的 Python 候选。"""
if sys.platform != "darwin":
return False
try:
resolved = str(Path(candidate).expanduser().resolve())
except Exception:
resolved = candidate
# macOS 的 /usr/bin/python3 常为 xcode shim会调用 xcodebuild 并触发 /dev/null 拦截
if resolved == "/usr/bin/python3":
return True
return False
def _build_python_env(self, python_path: str) -> Dict[str, str]:
"""构造与预装虚拟环境匹配的环境变量(仅作用于宿主机子进程)。"""
@ -477,15 +529,49 @@ class TerminalOperator:
env.update(self._python_env)
if use_shell:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(work_path),
shell=True,
env=env,
start_new_session=True,
)
use_host_sandbox = self.host_execution_mode != "direct"
if use_host_sandbox and host_sandbox_enabled():
plan = build_host_sandbox_plan(command, work_path, env)
cmd_args, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
plan.command,
plan.seccomp_bpf_path,
)
try:
process = await asyncio.create_subprocess_exec(
*cmd_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=plan.cwd,
env=plan.env,
start_new_session=True,
pass_fds=pass_fds,
)
finally:
if seccomp_fd is not None:
try:
os.close(seccomp_fd)
except OSError:
pass
elif use_host_sandbox:
return {
"success": False,
"status": "error",
"error": "宿主机命令执行被拒绝HOST_SANDBOX_ENABLED=0",
"output": "",
"return_code": -1,
"timeout": timeout,
"elapsed_ms": int((time.time() - start_ts) * 1000)
}
else:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(work_path),
shell=True,
env=env,
start_new_session=True,
)
else:
process = await asyncio.create_subprocess_exec(
*exec_cmd,
@ -510,6 +596,24 @@ class TerminalOperator:
stdout_task = asyncio.create_task(_read_stream(process.stdout, stdout_buf))
stderr_task = asyncio.create_task(_read_stream(process.stderr, stderr_buf))
async def _finish_reader_tasks(force: bool = False) -> None:
"""收口 reader 任务,避免因后台子进程持续占用管道而卡死。"""
join_timeout = 0.8 if force else 3.0
try:
await asyncio.wait_for(
asyncio.gather(stdout_task, stderr_task, return_exceptions=True),
timeout=join_timeout
)
except asyncio.TimeoutError:
stdout_task.cancel()
stderr_task.cancel()
try:
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
except Exception:
pass
except Exception:
pass
timed_out = False
try:
@ -546,22 +650,23 @@ class TerminalOperator:
pass
raise
# 确保读取协程结束
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
# 确保读取协程结束(超时场景强制收口,避免永久等待 EOF
await _finish_reader_tasks(force=timed_out)
# 兜底再读一次,防止剩余缓冲未被读取
try:
remaining_out = await process.stdout.read()
if remaining_out:
stdout_buf.append(remaining_out)
except Exception:
pass
try:
remaining_err = await process.stderr.read()
if remaining_err:
stderr_buf.append(remaining_err)
except Exception:
pass
# 非超时场景下兜底再读一次,防止剩余缓冲未被读取
if not timed_out:
try:
remaining_out = await asyncio.wait_for(process.stdout.read(), timeout=0.2)
if remaining_out:
stdout_buf.append(remaining_out)
except Exception:
pass
try:
remaining_err = await asyncio.wait_for(process.stderr.read(), timeout=0.2)
if remaining_err:
stderr_buf.append(remaining_err)
except Exception:
pass
stdout = b"".join(stdout_buf)
stderr = b"".join(stderr_buf)
@ -601,6 +706,16 @@ class TerminalOperator:
if stderr_text:
response["stderr"] = stderr_text
return response
except HostSandboxError as exc:
return {
"success": False,
"status": "error",
"error": f"宿主机沙箱不可用,拒绝执行: {str(exc)}",
"output": "",
"return_code": -1,
"timeout": timeout,
"elapsed_ms": int((time.time() - start_ts) * 1000)
}
except Exception as exc:
return {
"success": False,
@ -812,3 +927,11 @@ class TerminalOperator:
if self.process and self.process.returncode is None:
self.process.kill()
print(f"{OUTPUT_FORMATS['warning']} 进程已终止")
@staticmethod
def _materialize_seccomp_fd(plan_command: list[str], seccomp_path: Optional[str]) -> tuple[list[str], tuple[int, ...], Optional[int]]:
if not seccomp_path:
return plan_command, tuple(), None
seccomp_fd = os.open(seccomp_path, os.O_RDONLY)
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

View File

@ -96,6 +96,8 @@ class UserContainerManager:
self._stats_log_path.parent.mkdir(parents=True, exist_ok=True)
if not self._stats_log_path.exists():
self._stats_log_path.touch()
if self.sandbox_mode == "docker":
self._assert_docker_runtime_ready()
# 用户要求:每次启动程序时自动关闭本项目相关容器,避免“程序退出后容器残留”。
# 默认开启;如确实需要保留容器,可设置 CLEANUP_PROJECT_CONTAINERS_ON_START=0。
@ -286,11 +288,7 @@ class UserContainerManager:
docker_path = shutil.which(self.sandbox_bin or "docker")
if not docker_path:
message = f"未找到容器运行时 {self.sandbox_bin}"
if self.require:
raise RuntimeError(message)
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机执行。")
return self._host_handle(username, workspace)
raise RuntimeError(f"未找到容器运行时 {self.sandbox_bin}")
if not self.image:
raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置,无法启动容器。")
@ -345,10 +343,7 @@ class UserContainerManager:
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "容器启动失败"
if self.require:
raise RuntimeError(message)
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机。")
return self._host_handle(username, workspace)
raise RuntimeError(message)
container_id = result.stdout.strip() or None
print(f"{OUTPUT_FORMATS['success']} 启动用户容器: {container_name} ({username})")
@ -370,6 +365,25 @@ class UserContainerManager:
mount_path=workspace,
)
def _assert_docker_runtime_ready(self):
docker_path = shutil.which(self.sandbox_bin or "docker")
if not docker_path:
raise RuntimeError(f"Docker 模式启动失败:未找到容器运行时 {self.sandbox_bin}")
try:
result = subprocess.run(
[docker_path, "info"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=8,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise RuntimeError(f"Docker 模式启动失败:无法访问 Docker daemon: {exc}") from exc
if result.returncode != 0:
message = (result.stderr or result.stdout or "").strip() or "docker info 返回非零状态"
raise RuntimeError(f"Docker 模式启动失败:{message}")
def _kill_container(self, container_name: Optional[str], docker_bin: Optional[str]):
if not container_name or not docker_bin:
return

View File

@ -0,0 +1,4 @@
## 执行环境:{execution_mode_label}{execution_mode}
### 当前规则
{rules}

View File

@ -43,6 +43,7 @@ UPLOAD_FOLDER_NAME = "user_upload"
chat_bp = Blueprint('chat', __name__)
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"]
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
@chat_bp.route('/api/thinking-mode', methods=['POST'])
@api_login_required
@ -635,6 +636,47 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
})
@chat_bp.route('/api/execution-mode', methods=['GET'])
@api_login_required
@with_terminal
def get_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
state = terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else {"mode": "sandbox"}
return jsonify({
"success": True,
"enabled": can_manage,
"state": state,
"options": EXECUTION_MODE_OPTIONS,
})
@chat_bp.route('/api/execution-mode', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("execution_mode_switch", 20, 60, scope="user")
def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
if not can_manage:
return jsonify({"success": False, "error": "仅宿主机管理员可切换执行环境"}), 403
data = request.get_json() or {}
target_mode = str(data.get("mode") or "").strip().lower()
if target_mode not in EXECUTION_MODE_OPTIONS:
return jsonify({"success": False, "error": "无效执行环境,仅支持 sandbox / direct"}), 400
try:
state = terminal.set_execution_mode(target_mode)
except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": "切换执行环境失败"}), 500
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"state": state,
"options": EXECUTION_MODE_OPTIONS,
})
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required
@with_terminal

View File

@ -293,6 +293,9 @@
:current-permission-mode="currentPermissionMode"
:permission-menu-open="permissionMenuOpen"
:permission-options="permissionModeOptions"
:execution-mode-enabled="executionModeEnabled"
:current-execution-mode="currentExecutionMode"
:execution-mode-options="executionModeOptions"
:current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled"
:runtime-queued-messages="runtimeQueuedMessages"
@ -324,6 +327,7 @@
@open-review="openReviewDialog"
@toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode"
@change-execution-mode="changeExecutionMode"
@open-versioning-dialog="openVersioningDialog"
@guide-runtime-message="handleGuideRuntimeMessage"
@delete-runtime-message="handleDeleteRuntimeMessage"

View File

@ -374,6 +374,7 @@ export const conversationMethods = {
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
await this.fetchAndDisplayHistory();
this.fetchPermissionMode();
this.fetchExecutionMode();
await this.fetchVersioningStatus(conversationId, { silent: true });
await this.maybePromptVersioningMismatch(result.versioning);
this.fetchPendingToolApprovals();

View File

@ -142,6 +142,17 @@ export const resourceMethods = {
if (status && typeof status.permission_mode === 'string') {
this.currentPermissionMode = status.permission_mode;
}
if (status?.execution_mode && typeof status.execution_mode === 'object') {
if (typeof status.execution_mode.mode === 'string') {
this.currentExecutionMode = status.execution_mode.mode;
}
if (typeof status.execution_mode.ttl_seconds === 'number') {
this.executionModeTtlSeconds = status.execution_mode.ttl_seconds;
}
this.executionModeDirectUntil = status.execution_mode.direct_until ?? null;
this.executionModeEnabled =
typeof status.execution_mode_enabled === 'boolean' ? status.execution_mode_enabled : true;
}
if (status && typeof status.has_images !== 'undefined') {
this.conversationHasImages = !!status.has_images;
}

View File

@ -1120,6 +1120,11 @@ export const uiMethods = {
const hit = options.find((item) => item.value === mode);
return hit ? hit.label : mode || '未知';
},
getExecutionModeLabel(mode) {
const options = Array.isArray(this.executionModeOptions) ? this.executionModeOptions : [];
const hit = options.find((item) => item.value === mode);
return hit ? hit.label : mode || '未知';
},
togglePermissionMenu() {
if (!this.isConnected) {
@ -1171,6 +1176,42 @@ export const uiMethods = {
}
},
async changeExecutionMode(mode) {
const target = String(mode || '').trim().toLowerCase();
if (!this.executionModeEnabled || !target || target === this.currentExecutionMode) {
return;
}
try {
const response = await fetch('/api/execution-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: target })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '切换执行环境失败');
}
const state = payload?.state || {};
this.currentExecutionMode = state.mode || target;
this.executionModeDirectUntil = state.direct_until ?? null;
this.executionModeTtlSeconds =
typeof state.ttl_seconds === 'number' ? state.ttl_seconds : this.executionModeTtlSeconds;
this.uiPushToast({
title: '执行环境已切换',
message: `当前执行环境:${this.getExecutionModeLabel(this.currentExecutionMode)}`,
type: this.currentExecutionMode === 'direct' ? 'warning' : 'success',
duration: 1800
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '切换执行环境失败');
this.uiPushToast({
title: '切换执行环境失败',
message: msg,
type: 'error'
});
}
},
async handleSwitchPermissionToUnrestricted(approvalId) {
await this.changePermissionMode('unrestricted');
if (approvalId) {
@ -1193,6 +1234,27 @@ export const uiMethods = {
}
},
async fetchExecutionMode() {
try {
const response = await fetch('/api/execution-mode');
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
this.executionModeEnabled = !!payload.enabled;
const state = payload.state || {};
if (typeof state.mode === 'string') {
this.currentExecutionMode = state.mode;
}
this.executionModeDirectUntil = state.direct_until ?? null;
if (typeof state.ttl_seconds === 'number') {
this.executionModeTtlSeconds = state.ttl_seconds;
}
} catch (_error) {
// ignore
}
},
async fetchPendingToolApprovals() {
if (!this.currentConversationId) {
this.pendingToolApprovals = [];
@ -2432,6 +2494,7 @@ export const uiMethods = {
this.thinkingMode = !!statusData.thinking_mode;
this.applyStatusSnapshot(statusData);
this.fetchPermissionMode();
this.fetchExecutionMode();
this.fetchPendingToolApprovals();
// 立即更新配额和运行模式,避免等待其他慢接口
this.fetchUsageQuota();

View File

@ -97,6 +97,10 @@ export function dataState() {
modelMenuOpen: false,
permissionMenuOpen: false,
currentPermissionMode: 'unrestricted',
executionModeEnabled: false,
currentExecutionMode: 'sandbox',
executionModeDirectUntil: null,
executionModeTtlSeconds: 600,
versioningHostMode: false,
hostWorkspaces: [],
currentHostWorkspaceId: '',
@ -139,6 +143,18 @@ export function dataState() {
description: '保持当前默认行为,不额外拦截'
}
],
executionModeOptions: [
{
value: 'sandbox',
label: '沙箱',
description: '所有指令会在系统沙箱中执行'
},
{
value: 'direct',
label: '完全访问权限',
description: '所有指令会在宿主机直接执行'
}
],
pendingToolApprovals: [],
decidingApprovalIds: [],
imageEntries: [],

View File

@ -181,18 +181,39 @@
<span>权限{{ currentPermissionLabel }}</span>
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }"></span>
</button>
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
<button
v-for="option in permissionOptions"
:key="option.value"
type="button"
class="permission-switcher__item"
:class="{ active: option.value === currentPermissionMode }"
@click="$emit('change-permission-mode', option.value)"
>
<span class="permission-switcher__item-label">{{ option.label }}</span>
<span class="permission-switcher__item-desc">{{ option.description }}</span>
</button>
<div v-if="permissionMenuOpen" class="permission-switcher__menu permission-switcher__menu--split">
<div class="permission-switcher__group">
<div class="permission-switcher__group-title">权限</div>
<button
v-for="option in permissionOptions"
:key="`permission-${option.value}`"
type="button"
class="permission-switcher__item"
:class="{ active: option.value === currentPermissionMode }"
@click="$emit('change-permission-mode', option.value)"
>
<span class="permission-switcher__item-label">{{ option.label }}</span>
<span class="permission-switcher__item-desc">{{ option.description }}</span>
</button>
</div>
<div v-if="executionModeEnabled" class="permission-switcher__group">
<div class="permission-switcher__group-title">执行环境</div>
<button
v-for="option in executionModeOptions"
:key="`execution-${option.value}`"
type="button"
class="permission-switcher__item"
:class="{ active: option.value === currentExecutionMode }"
@click="$emit('change-execution-mode', option.value)"
>
<span
class="permission-switcher__item-label"
:class="{ 'permission-switcher__item-label--warn': option.value === 'direct' }"
>{{ option.label }}</span
>
<span class="permission-switcher__item-desc">{{ option.description }}</span>
</button>
</div>
</div>
</div>
</div>
@ -269,6 +290,7 @@ const emit = defineEmits([
'open-review',
'toggle-permission-menu',
'change-permission-mode',
'change-execution-mode',
'open-versioning-dialog',
'guide-runtime-message',
'delete-runtime-message',
@ -319,6 +341,9 @@ const props = defineProps<{
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
permissionMenuOpen: boolean;
permissionOptions: Array<{ value: string; label: string; description: string }>;
executionModeEnabled?: boolean;
currentExecutionMode?: 'sandbox' | 'direct';
executionModeOptions?: Array<{ value: string; label: string; description: string }>;
currentContextTokens: number;
versioningEnabled?: boolean;
runtimeQueuedMessages?: Array<{ id: string; text: string }>;

View File

@ -265,6 +265,27 @@ body[data-theme='dark'] {
box-shadow: var(--theme-shadow-mid);
}
.permission-switcher__menu--split {
width: 560px;
max-width: min(560px, calc(100vw - 40px));
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.permission-switcher__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.permission-switcher__group-title {
font-size: 12px;
color: var(--claude-text-secondary);
font-weight: 600;
padding: 2px 4px;
}
.permission-switcher__caret {
font-size: 14px;
color: var(--claude-text-secondary);
@ -308,6 +329,10 @@ body[data-theme='dark'] {
line-height: 1.35;
}
.permission-switcher__item-label--warn {
color: #d97706;
}
@media (max-width: 768px) {
.permission-switcher {
left: 20px;
@ -326,6 +351,11 @@ body[data-theme='dark'] {
width: min(250px, calc(100vw - 40px));
max-width: 250px;
}
.permission-switcher__menu--split {
width: min(560px, calc(100vw - 40px));
max-width: min(560px, calc(100vw - 40px));
grid-template-columns: 1fr;
}
}
.stadium-shell {