From c2d460a70624b6c43b24228a60ab64325ac3f28a Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 19 Jun 2026 00:22:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=BF=E4=B8=BB=E6=9C=BA=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=BD=91=E7=BB=9C=E6=B2=99=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- config/terminal.py | 4 + core/main_terminal.py | 46 ++++++ core/main_terminal_parts/context.py | 9 + core/main_terminal_parts/tools_execution.py | 9 + core/web_terminal.py | 9 + modules/background_command_manager.py | 62 +++++-- modules/host_sandbox_runner.py | 155 ++++++++++++++---- modules/persistent_terminal.py | 5 +- modules/sub_agent_manager.py | 5 +- modules/terminal_ops.py | 16 +- prompts/execution_mode.txt | 1 + server/chat.py | 85 ++++++++++ server/chat_flow_task_main.py | 6 +- server/chat_flow_tool_loop.py | 8 + static/src/App.vue | 4 + static/src/app/methods/conversation.ts | 1 + static/src/app/methods/resources.ts | 10 ++ static/src/app/methods/ui.ts | 54 ++++++ static/src/app/state.ts | 15 ++ static/src/components/input/InputComposer.vue | 27 +++ .../styles/components/input/_composer.scss | 9 + 21 files changed, 489 insertions(+), 51 deletions(-) diff --git a/config/terminal.py b/config/terminal.py index 69653e9..afc5fb9 100644 --- a/config/terminal.py +++ b/config/terminal.py @@ -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", ] diff --git a/core/main_terminal.py b/core/main_terminal.py index 19e72cc..705ee67 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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) diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index 26eee5c..4461d85 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -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]: diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 219fdb8..d407a7b 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -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, ) # 字符数检查 diff --git a/core/web_terminal.py b/core/web_terminal.py index 47d0cd9..249bd02 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -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": { diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index b12548c..22cc2ab 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -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, diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index a7ee792..582fc07 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -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.exe(WSL2),拒绝执行宿主机命令。") @@ -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.exe(WSL2),拒绝启动宿主机沙箱终端。") diff --git a/modules/persistent_terminal.py b/modules/persistent_terminal.py index a707782..e54dc66 100644 --- a/modules/persistent_terminal.py +++ b/modules/persistent_terminal.py @@ -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 diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 32636f6..923d076 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -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, diff --git a/modules/terminal_ops.py b/modules/terminal_ops.py index 38bca9c..8a05e4d 100644 --- a/modules/terminal_ops.py +++ b/modules/terminal_ops.py @@ -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, diff --git a/prompts/execution_mode.txt b/prompts/execution_mode.txt index ae35459..1b2a62c 100644 --- a/prompts/execution_mode.txt +++ b/prompts/execution_mode.txt @@ -2,3 +2,4 @@ ### 当前规则 {rules} +{network_rules} diff --git a/server/chat.py b/server/chat.py index ef1cc6c..43b763f 100644 --- a/server/chat.py +++ b/server/chat.py @@ -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 diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 4cf1412..11579ba 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -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() diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 4745f60..3124376 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -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) diff --git a/static/src/App.vue b/static/src/App.vue index 1a23c75..08d409d 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -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" diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index f1d2c07..8b7fb6b 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -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(); diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts index 5b8526f..d670349 100644 --- a/static/src/app/methods/resources.ts +++ b/static/src/app/methods/resources.ts @@ -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 { diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index ef53740..f1cd03a 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -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 = { 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(); diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 74c53a9..6b6238b 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -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: '', diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index cfe6eb7..377adc0 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -375,6 +375,29 @@ {{ option.description }} +
+
网络权限
+ +
@@ -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 }>; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 8e64e5d..5b4b737 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -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 {