feat: 宿主机模式网络沙箱

- macOS sandbox-exec profile 支持 network_permission 参数 (restricted/full/none)
- backend: 透传网络权限至 run_command / terminal_session / sub_agent / background_command
- 后台 run_command 接入沙箱执行
- 自动审核模式兼容网络权限报错 markers
- 运行时切换网络权限通过 pending 机制 + user 消息通知
- 提示词注入网络状态 (仅沙箱模式)
- 前端权限菜单新增网络权限组 (受限/完全开放)
- direct 模式下网络权限组变灰禁用
- settings.json 默认 HOST_SANDBOX_NETWORK_PERMISSION=restricted
This commit is contained in:
JOJO 2026-06-19 00:22:30 +08:00
parent f75e2f07a3
commit c2d460a706
21 changed files with 489 additions and 51 deletions

View File

@ -58,6 +58,9 @@ HOST_EXECUTION_DIRECT_TTL_SECONDS = int(os.environ.get("HOST_EXECUTION_DIRECT_TT
HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
)
HOST_SANDBOX_NETWORK_PERMISSION = os.environ.get(
"HOST_SANDBOX_NETWORK_PERMISSION", "restricted"
).strip().lower()
__all__ = [
"MAX_TERMINALS",
@ -87,4 +90,5 @@ __all__ = [
"HOST_EXECUTION_MODE_DEFAULT",
"HOST_EXECUTION_DIRECT_TTL_SECONDS",
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
"HOST_SANDBOX_NETWORK_PERMISSION",
]

View File

@ -1,5 +1,6 @@
# core/main_terminal.py - 主终端(集成对话持久化)
import os
from pathlib import Path
import time
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
@ -18,6 +19,7 @@ try:
MCP_TOOLS_ENABLED,
HOST_EXECUTION_MODE_DEFAULT,
HOST_EXECUTION_DIRECT_TTL_SECONDS,
HOST_SANDBOX_NETWORK_PERMISSION,
)
except ImportError:
import sys
@ -37,6 +39,7 @@ except ImportError:
MCP_TOOLS_ENABLED,
HOST_EXECUTION_MODE_DEFAULT,
HOST_EXECUTION_DIRECT_TTL_SECONDS,
HOST_SANDBOX_NETWORK_PERMISSION,
)
from modules.file_manager import FileManager
@ -207,12 +210,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.current_permission_mode: str = "unrestricted"
self.host_execution_mode: str = "sandbox"
self.host_execution_direct_until: Optional[float] = None
self.host_network_permission: str = "restricted"
self.pending_permission_mode: Optional[str] = None
self.pending_execution_mode: Optional[str] = None
self.pending_network_permission: Optional[str] = None
# 当前生效的启用 skills用于强约束判定
self.enabled_skill_ids: Set[str] = set()
self.apply_personalization_preferences()
self._init_host_execution_mode()
self._init_host_network_permission()
# 新增:自动开始新对话
self._ensure_conversation()
@ -257,10 +263,29 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
if getattr(self, "sub_agent_manager", None):
self.sub_agent_manager.set_host_execution_mode(mode)
def _init_host_network_permission(self):
default_permission = str(HOST_SANDBOX_NETWORK_PERMISSION or "restricted").strip().lower()
if default_permission not in {"restricted", "full", "none"}:
default_permission = "restricted"
self.host_network_permission = default_permission
os.environ["HOST_SANDBOX_NETWORK_PERMISSION"] = default_permission
def get_network_permission(self) -> str:
return getattr(self, "host_network_permission", "restricted")
def set_network_permission(self, mode: str) -> str:
normalized = str(mode or "").strip().lower()
if normalized not in {"restricted", "full", "none"}:
raise ValueError("无效网络权限,仅支持 restricted / full / none")
self.host_network_permission = normalized
os.environ["HOST_SANDBOX_NETWORK_PERMISSION"] = normalized
return normalized
def get_pending_runtime_modes(self) -> Dict[str, Optional[str]]:
return {
"permission_mode": self.pending_permission_mode,
"execution_mode": self.pending_execution_mode,
"network_permission": getattr(self, "pending_network_permission", None),
}
def queue_permission_mode_change(self, mode: str) -> str:
@ -283,6 +308,16 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
})
return normalized
def queue_network_permission_change(self, mode: str) -> str:
normalized = str(mode or "").strip().lower()
if normalized not in {"restricted", "full", "none"}:
raise ValueError("无效网络权限,仅支持 restricted / full / none")
self.pending_network_permission = normalized
self._persist_runtime_mode_metadata({
"pending_network_permission": normalized,
})
return normalized
def apply_pending_runtime_mode_changes(self) -> List[Dict[str, str]]:
notices: List[Dict[str, str]] = []
updates: Dict[str, Any] = {}
@ -314,6 +349,17 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
updates["pending_execution_mode"] = None
self.pending_execution_mode = None
pending_network = getattr(self, "pending_network_permission", None)
if pending_network:
current_network = self.get_network_permission()
if pending_network != current_network:
self.set_network_permission(pending_network)
label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(pending_network, pending_network)
notices.append({"text": f"网络权限被用户修改为 {label}", "source": "网络权限变更"})
updates["network_permission"] = pending_network
updates["pending_network_permission"] = None
self.pending_network_permission = None
if updates:
self._persist_runtime_mode_metadata(updates)

View File

@ -183,16 +183,25 @@ class MainTerminalContextMixin:
"- 若操作受系统权限限制:先提供 1 个安全替代方案并执行;若仍无法满足目标,明确请求用户切换执行环境为“完全访问权限”后重试\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,
)
def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:

View File

@ -1,5 +1,6 @@
import asyncio
import json
import os
import time
from datetime import datetime
from pathlib import Path
@ -1448,6 +1449,13 @@ class MainTerminalToolsExecutionMixin:
permission_mode == "readonly"
or (permission_mode in {"approval", "auto_approval"} and not write_granted_once)
)
network_granted_once = bool(arguments.get("_approval_network_granted", False))
if network_granted_once:
network_permission = "full"
else:
network_permission = getattr(
self, "host_network_permission", None
) or os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted")
run_in_background = bool(arguments.get("run_in_background", False))
timeout_value = arguments.get("timeout")
if run_in_background:
@ -1489,6 +1497,7 @@ class MainTerminalToolsExecutionMixin:
arguments["command"],
timeout=timeout_value,
sandbox_write_access=sandbox_write_access,
network_permission=network_permission,
)
# 字符数检查

View File

@ -234,8 +234,15 @@ class WebTerminal(MainTerminal):
self.set_execution_mode(execution_mode)
except Exception:
pass
network_permission = str(meta.get("network_permission") or "").strip().lower()
if network_permission in {"restricted", "full", "none"}:
try:
self.set_network_permission(network_permission)
except Exception:
pass
self.pending_permission_mode = str(meta.get("pending_permission_mode") or "").strip().lower() or None
self.pending_execution_mode = str(meta.get("pending_execution_mode") or "").strip().lower() or None
self.pending_network_permission = str(meta.get("pending_network_permission") or "").strip().lower() or None
except Exception:
pass
# 重置相关状态
@ -356,8 +363,10 @@ class WebTerminal(MainTerminal):
"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"},
"network_permission": self.get_network_permission() if hasattr(self, "get_network_permission") else "restricted",
"pending_runtime_modes": self.get_pending_runtime_modes() if hasattr(self, "get_pending_runtime_modes") else {},
"execution_mode_enabled": bool(self._is_host_mode()) and getattr(self, "user_role", "user") == "admin",
"network_permission_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

@ -14,6 +14,11 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from config import MAX_RUN_COMMAND_CHARS
from modules.host_sandbox_runner import (
HostSandboxError,
build_host_sandbox_plan,
host_sandbox_enabled,
)
TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"}
@ -116,6 +121,7 @@ class BackgroundCommandManager:
"timeout": timeout_value,
"session": session_override or getattr(terminal_ops, "container_session", None),
"python_env": getattr(terminal_ops, "_python_env", None) or {},
"host_execution_mode": getattr(terminal_ops, "host_execution_mode", "sandbox"),
},
name=f"bg-run-command-{command_id}",
daemon=True,
@ -182,6 +188,7 @@ class BackgroundCommandManager:
timeout: int,
session,
python_env: Dict[str, str],
host_execution_mode: str = "sandbox",
) -> None:
start_ts = time.time()
process: Optional[subprocess.Popen] = None
@ -229,18 +236,49 @@ class BackgroundCommandManager:
use_shell = False
if use_shell:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(work_path),
shell=True,
env=env,
start_new_session=True,
text=True,
errors="replace",
bufsize=1,
)
use_host_sandbox = host_execution_mode != "direct"
if use_host_sandbox and host_sandbox_enabled():
network_permission = os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted")
plan = build_host_sandbox_plan(command, work_path, env, network_permission=network_permission)
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,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=plan.cwd,
env=plan.env,
start_new_session=True,
text=True,
errors="replace",
bufsize=1,
pass_fds=pass_fds,
)
finally:
if seccomp_fd is not None:
try:
os.close(seccomp_fd)
except OSError:
pass
else:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(work_path),
shell=True,
env=env,
start_new_session=True,
text=True,
errors="replace",
bufsize=1,
)
else:
process = subprocess.Popen(
exec_cmd,

View File

@ -22,6 +22,17 @@ class HostSandboxError(RuntimeError):
pass
# 宿主机网络权限档位
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"}
@ -30,72 +41,126 @@ 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:
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)
return _build_macos_plan(command, work_path, env, network_permission)
if system == "Linux":
return _build_linux_plan(command, work_path, env)
return _build_linux_plan(command, work_path, env, network_permission)
if system == "Windows":
return _build_windows_plan(command, work_path, env)
return _build_windows_plan(command, work_path, env, network_permission)
raise HostSandboxError(f"不支持的宿主机系统: {system}")
def build_host_sandbox_readonly_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
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)
return _build_macos_readonly_plan(command, work_path, env, network_permission)
if system == "Linux":
return _build_linux_readonly_plan(command, work_path, env)
return _build_linux_readonly_plan(command, work_path, env, network_permission)
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)
return _build_windows_plan(command, work_path, env, network_permission)
raise HostSandboxError(f"不支持的宿主机系统: {system}")
def _build_macos_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
def build_host_sandbox_shell_plan(
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
system = platform.system()
if system == "Darwin":
return _build_macos_shell_plan(work_path, env, network_permission)
if system == "Linux":
return _build_linux_shell_plan(work_path, env, network_permission)
if system == "Windows":
return _build_windows_shell_plan(work_path, env, network_permission)
raise HostSandboxError(f"不支持的宿主机系统: {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("macOS 未找到 sandbox-exec拒绝执行宿主机命令。")
profile = _macos_profile_for_workspace(work_path)
profile = _macos_profile_for_workspace(work_path, network_permission)
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
def _build_macos_readonly_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
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("macOS 未找到 sandbox-exec拒绝执行宿主机命令。")
network_policy = _build_macos_network_policy(network_permission)
profile = (
'(version 1) '
'(deny default) '
'(allow sysctl-read) '
'(allow process*) '
'(allow network*) '
f'{network_policy}'
'(allow file-read*) '
'(allow file-write* (literal "/dev/null"))'
)
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:
def _build_macos_shell_plan(
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("macOS 未找到 sandbox-exec拒绝启动宿主机沙箱终端。")
profile = _macos_profile_for_workspace(work_path)
profile = _macos_profile_for_workspace(work_path, network_permission)
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:
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():
@ -112,18 +177,24 @@ def _macos_profile_for_workspace(work_path: Path) -> str:
else:
write_rules.append(f'(subpath "{entry}")')
write_expr = " ".join(write_rules)
network_policy = _build_macos_network_policy(network_permission)
return (
'(version 1) '
'(deny default) '
'(allow sysctl-read) '
'(allow process*) '
'(allow network*) '
f'{network_policy}'
'(allow file-read*) '
f'(allow file-write* {write_expr})'
)
def _build_linux_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
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("Linux 未找到 bubblewrap(bwrap),拒绝执行宿主机命令。")
@ -135,10 +206,16 @@ def _build_linux_plan(command: str, work_path: Path, env: Dict[str, str]) -> San
if not seccomp_path.exists():
raise HostSandboxError(f"seccomp BPF 文件不存在: {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]) -> SandboxPlan:
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("Linux 未找到 bubblewrap(bwrap),拒绝执行宿主机命令。")
@ -151,7 +228,12 @@ def _build_linux_readonly_plan(command: str, work_path: Path, env: Dict[str, str
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]) -> SandboxPlan:
def _build_linux_shell_plan(
work_path: Path,
env: Dict[str, str],
network_permission: Optional[str] = None,
) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap),拒绝启动宿主机沙箱终端。")
@ -164,6 +246,7 @@ def _build_linux_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan
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],
@ -205,7 +288,12 @@ def _build_linux_common_plan(
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:
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.exeWSL2拒绝执行宿主机命令。")
@ -214,7 +302,12 @@ def _build_windows_plan(command: str, work_path: Path, env: Dict[str, str]) -> S
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:
def _build_windows_shell_plan(
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.exeWSL2拒绝启动宿主机沙箱终端。")

View File

@ -247,7 +247,10 @@ class PersistentTerminal:
env['LC_ALL'] = 'en_US.UTF-8'
try:
plan = build_host_sandbox_shell_plan(self.working_dir, env)
network_permission = os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted")
plan = build_host_sandbox_shell_plan(
self.working_dir, env, network_permission=network_permission
)
cmd_args = plan.command
pass_fds = ()
seccomp_fd = None

View File

@ -188,7 +188,10 @@ class SubAgentManager:
return {"success": False, "error": "宿主机子智能体执行被拒绝HOST_SANDBOX_ENABLED=0"}
try:
command_str = self._join_shell_words(cmd)
plan = build_host_sandbox_plan(command_str, self.project_path, env)
network_permission = os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted")
plan = build_host_sandbox_plan(
command_str, self.project_path, env, network_permission=network_permission
)
launch_cmd, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
plan.command,
plan.seccomp_bpf_path,

View File

@ -18,6 +18,7 @@ try:
OUTPUT_FORMATS,
MAX_RUN_COMMAND_CHARS,
TOOLBOX_TERMINAL_IDLE_SECONDS,
HOST_SANDBOX_NETWORK_PERMISSION,
)
except ImportError:
project_root = Path(__file__).resolve().parents[1]
@ -29,10 +30,12 @@ except ImportError:
OUTPUT_FORMATS,
MAX_RUN_COMMAND_CHARS,
TOOLBOX_TERMINAL_IDLE_SECONDS,
HOST_SANDBOX_NETWORK_PERMISSION,
)
from modules.toolbox_container import ToolboxContainer
from modules.host_sandbox_runner import (
HostSandboxError,
NETWORK_PERMISSION_RESTRICTED,
build_host_sandbox_plan,
build_host_sandbox_readonly_plan,
host_sandbox_enabled,
@ -308,6 +311,7 @@ class TerminalOperator:
working_dir: str = None,
timeout: int = None,
sandbox_write_access: bool = True,
network_permission: Optional[str] = None,
) -> Dict:
"""
执行终端命令
@ -383,6 +387,7 @@ class TerminalOperator:
timeout,
session_override=session_override,
sandbox_write_access=sandbox_write_access,
network_permission=network_permission,
)
else:
# 若未绑定用户容器,则使用工具箱容器(与终端相同镜像/预装包)
@ -425,7 +430,7 @@ class TerminalOperator:
# 改为一次性子进程执行,确保等待到超时或命令结束
result_payload = result_payload if result_payload is not None else await self._run_command_subprocess(
command, work_path, timeout, sandbox_write_access=sandbox_write_access
command, work_path, timeout, sandbox_write_access=sandbox_write_access, network_permission=network_permission
)
# 字符数检查
@ -488,6 +493,7 @@ class TerminalOperator:
timeout: int,
session_override: Optional["ContainerHandle"] = None,
sandbox_write_access: bool = True,
network_permission: Optional[str] = None,
) -> Dict:
start_ts = time.time()
try:
@ -534,9 +540,13 @@ class TerminalOperator:
use_host_sandbox = self.host_execution_mode != "direct"
if use_host_sandbox and host_sandbox_enabled():
if sandbox_write_access:
plan = build_host_sandbox_plan(command, work_path, env)
plan = build_host_sandbox_plan(
command, work_path, env, network_permission=network_permission
)
else:
plan = build_host_sandbox_readonly_plan(command, work_path, env)
plan = build_host_sandbox_readonly_plan(
command, work_path, env, network_permission=network_permission
)
cmd_args, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
plan.command,
plan.seccomp_bpf_path,

View File

@ -2,3 +2,4 @@
### 当前规则
{rules}
{network_rules}

View File

@ -50,6 +50,7 @@ chat_bp = Blueprint('chat', __name__)
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
NETWORK_PERMISSION_OPTIONS = ["restricted", "full"]
def _dispatch_runtime_mode_notice(terminal: WebTerminal, username: str, text: str, source: str = "notify") -> None:
@ -857,6 +858,90 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern
})
@chat_bp.route('/api/network-permission', methods=['GET'])
@api_login_required
@with_terminal
def get_network_permission(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"
current = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else "restricted"
return jsonify({
"success": True,
"enabled": can_manage,
"mode": current,
"pending_mode": (terminal.get_pending_runtime_modes().get("network_permission") if hasattr(terminal, "get_pending_runtime_modes") else None),
"options": NETWORK_PERMISSION_OPTIONS,
})
@chat_bp.route('/api/network-permission', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("network_permission_switch", 20, 60, scope="user")
def update_network_permission(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 NETWORK_PERMISSION_OPTIONS:
return jsonify({"success": False, "error": "无效网络权限,仅支持 restricted / full"}), 400
is_running = False
try:
from .tasks import task_manager
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
running_tasks = [
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
]
if current_conv:
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
is_running = bool(running_tasks)
except Exception:
pass
if is_running:
try:
terminal.queue_network_permission_change(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,
"mode": target_mode,
"pending_mode": target_mode,
"options": NETWORK_PERMISSION_OPTIONS,
"message": "网络权限将在当前工具执行完成后生效",
})
previous_mode = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else None
try:
applied = terminal.set_network_permission(target_mode)
if hasattr(terminal, "pending_network_permission"):
terminal.pending_network_permission = None
if hasattr(terminal, "_persist_runtime_mode_metadata"):
terminal._persist_runtime_mode_metadata({
"network_permission": applied,
"pending_network_permission": None,
})
except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500
status = terminal.get_status()
if applied != previous_mode:
label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(applied, applied)
_dispatch_runtime_mode_notice(terminal, username, f"网络权限被用户修改为 {label}", source="网络权限变更")
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({
"success": True,
"mode": applied,
"pending_mode": None,
"options": NETWORK_PERMISSION_OPTIONS,
"message": "网络权限已更新并立即生效",
})
@chat_bp.route('/api/path-authorization', methods=['GET'])
@api_login_required
@with_terminal

View File

@ -1703,9 +1703,9 @@ async def handle_task_with_sender(
username=username,
task_id=client_sid,
)
# 权限/审核方式变更通知在任务完成时不应该触发新一轮工作,
# 只保留真正的引导消息(压缩续接等),丢弃权限/执行环境变更通知。
runtime_skip_sources = {"权限变更", "执行环境变更", "notify"}
# 权限/执行环境/网络权限变更通知在任务完成时不应该触发新一轮工作,
# 只保留真正的引导消息(压缩续接等),丢弃变更通知。
runtime_skip_sources = {"权限变更", "执行环境变更", "网络权限变更", "notify"}
for item in (raw_items or []):
if isinstance(item, dict):
src = str(item.get("source") or "").strip().lower()

View File

@ -163,6 +163,13 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
"无权限",
"不允许",
"access denied",
# 网络受限常见报错
"could not resolve host",
"nodename nor servname provided, or not known",
"unknown host",
"network is unreachable",
"no route to host",
"socket.gaierror",
)
return any(marker in joined for marker in markers)
@ -940,6 +947,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
retry_arguments = dict(arguments or {})
retry_arguments["_approval_write_granted"] = True
retry_arguments["_approval_network_granted"] = True
retry_tool_result = await web_terminal.handle_tool_call(function_name, retry_arguments)
try:
result_data = json.loads(retry_tool_result)

View File

@ -303,6 +303,9 @@
:execution-mode-enabled="executionModeEnabled"
:current-execution-mode="currentExecutionMode"
:execution-mode-options="executionModeOptions"
:network-permission-enabled="networkPermissionEnabled"
:current-network-permission="currentNetworkPermission"
:network-permission-options="networkPermissionOptions"
:current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled"
:runtime-queued-messages="runtimeQueuedMessages"
@ -343,6 +346,7 @@
@toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode"
@change-execution-mode="changeExecutionMode"
@change-network-permission="changeNetworkPermission"
@open-path-authorization="openPathAuthorizationDialog"
@open-versioning-dialog="openVersioningDialog"
@guide-runtime-message="handleGuideRuntimeMessage"

View File

@ -379,6 +379,7 @@ export const conversationMethods = {
}
this.fetchPermissionMode();
this.fetchExecutionMode();
this.fetchNetworkPermission();
await this.fetchVersioningStatus(conversationId, { silent: true });
await this.maybePromptVersioningMismatch(result.versioning);
this.fetchPendingToolApprovals();

View File

@ -203,6 +203,16 @@ export const resourceMethods = {
});
}
}
if (typeof status.network_permission === 'string') {
this.currentNetworkPermission = status.network_permission;
}
this.networkPermissionEnabled =
typeof status.network_permission_enabled === 'boolean' ? status.network_permission_enabled : false;
if (typeof pendingModes.network_permission === 'string') {
this.pendingNetworkPermission = pendingModes.network_permission;
} else {
this.pendingNetworkPermission = '';
}
if (typeof pendingModes.execution_mode === 'string') {
this.pendingExecutionMode = pendingModes.execution_mode;
} else {

View File

@ -1859,6 +1859,59 @@ export const uiMethods = {
}
},
async changeNetworkPermission(mode) {
const target = String(mode || '').trim().toLowerCase();
if (!this.networkPermissionEnabled || !target) {
return;
}
try {
const response = await fetch('/api/network-permission', {
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 || '切换网络权限失败');
}
if (typeof payload.mode === 'string') {
this.currentNetworkPermission = payload.mode;
}
this.pendingNetworkPermission = typeof payload.pending_mode === 'string' ? payload.pending_mode : '';
const labelMap: Record<string, string> = { restricted: '受限', full: '完全开放' };
this.uiPushToast({
title: '网络权限已更新',
message: payload?.message || `已切换为 ${labelMap[this.currentNetworkPermission] || this.currentNetworkPermission}`,
type: this.currentNetworkPermission === 'full' ? 'warning' : 'info',
duration: 1800
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '切换网络权限失败');
this.uiPushToast({
title: '切换网络权限失败',
message: msg,
type: 'error'
});
}
},
async fetchNetworkPermission() {
try {
const response = await fetch('/api/network-permission');
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
this.networkPermissionEnabled = !!payload.enabled;
if (typeof payload.mode === 'string') {
this.currentNetworkPermission = payload.mode;
}
this.pendingNetworkPermission = typeof payload.pending_mode === 'string' ? payload.pending_mode : '';
} catch (_error) {
// ignore
}
},
async fetchPermissionMode() {
try {
const response = await fetch('/api/permission-mode');
@ -3524,6 +3577,7 @@ export const uiMethods = {
this.applyStatusSnapshot(statusData);
this.fetchPermissionMode();
this.fetchExecutionMode();
this.fetchNetworkPermission();
this.fetchPendingToolApprovals();
// 立即更新配额和运行模式,避免等待其他慢接口
this.fetchUsageQuota();

View File

@ -129,6 +129,21 @@ export function dataState() {
executionModeAutoFallbackToastId: null,
executionModeTtlSeconds: 600,
executionModeExpirySyncTimer: null,
networkPermissionEnabled: false,
currentNetworkPermission: 'restricted',
pendingNetworkPermission: '',
networkPermissionOptions: [
{
value: 'restricted',
label: '受限',
description: '仅允许本地回环访问,外部网络不可用'
},
{
value: 'full',
label: '完全开放',
description: '允许所有出站和入站网络连接'
}
],
pathAuthorizationDialogOpen: false,
pathAuthorizationMode: 'writable',
pathAuthorizationWritableDraft: '',

View File

@ -375,6 +375,29 @@
<span class="permission-switcher__item-desc">{{ option.description }}</span>
</button>
</div>
<div
v-if="networkPermissionEnabled"
class="permission-switcher__group"
:class="{ 'permission-switcher__group--disabled': currentExecutionMode === 'direct' }"
>
<div class="permission-switcher__group-title">网络权限</div>
<button
v-for="option in networkPermissionOptions"
:key="`network-${option.value}`"
type="button"
class="permission-switcher__item"
:class="{ active: option.value === currentNetworkPermission }"
:disabled="currentExecutionMode === 'direct'"
@click="$emit('change-network-permission', option.value)"
>
<span
class="permission-switcher__item-label"
:class="{ 'permission-switcher__item-label--warn': option.value === 'full' }"
>{{ option.label }}</span
>
<span class="permission-switcher__item-desc">{{ option.description }}</span>
</button>
</div>
</div>
</div>
</div>
@ -459,6 +482,7 @@ const emit = defineEmits([
'toggle-permission-menu',
'change-permission-mode',
'change-execution-mode',
'change-network-permission',
'open-versioning-dialog',
'guide-runtime-message',
'delete-runtime-message',
@ -516,6 +540,9 @@ const props = defineProps<{
executionModeEnabled?: boolean;
currentExecutionMode?: 'sandbox' | 'direct';
executionModeOptions?: Array<{ value: string; label: string; description: string }>;
networkPermissionEnabled?: boolean;
currentNetworkPermission?: 'restricted' | 'full';
networkPermissionOptions?: Array<{ value: string; label: string; description: string }>;
currentContextTokens: number;
versioningEnabled?: boolean;
runtimeQueuedMessages?: Array<{ id: string; text: string }>;

View File

@ -492,12 +492,21 @@ body[data-theme='light'] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
> :first-child.permission-switcher__group {
grid-row: span 2;
}
}
.permission-switcher__group {
display: flex;
flex-direction: column;
gap: 6px;
&--disabled {
opacity: 0.35;
pointer-events: none;
}
}
.permission-switcher__group-title {