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:
parent
f75e2f07a3
commit
c2d460a706
@ -58,6 +58,9 @@ HOST_EXECUTION_DIRECT_TTL_SECONDS = int(os.environ.get("HOST_EXECUTION_DIRECT_TT
|
|||||||
HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
|
HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
|
||||||
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
|
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
|
||||||
)
|
)
|
||||||
|
HOST_SANDBOX_NETWORK_PERMISSION = os.environ.get(
|
||||||
|
"HOST_SANDBOX_NETWORK_PERMISSION", "restricted"
|
||||||
|
).strip().lower()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"MAX_TERMINALS",
|
"MAX_TERMINALS",
|
||||||
@ -87,4 +90,5 @@ __all__ = [
|
|||||||
"HOST_EXECUTION_MODE_DEFAULT",
|
"HOST_EXECUTION_MODE_DEFAULT",
|
||||||
"HOST_EXECUTION_DIRECT_TTL_SECONDS",
|
"HOST_EXECUTION_DIRECT_TTL_SECONDS",
|
||||||
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
||||||
|
"HOST_SANDBOX_NETWORK_PERMISSION",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
# core/main_terminal.py - 主终端(集成对话持久化)
|
# core/main_terminal.py - 主终端(集成对话持久化)
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
|
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
|
||||||
@ -18,6 +19,7 @@ try:
|
|||||||
MCP_TOOLS_ENABLED,
|
MCP_TOOLS_ENABLED,
|
||||||
HOST_EXECUTION_MODE_DEFAULT,
|
HOST_EXECUTION_MODE_DEFAULT,
|
||||||
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
||||||
|
HOST_SANDBOX_NETWORK_PERMISSION,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import sys
|
import sys
|
||||||
@ -37,6 +39,7 @@ except ImportError:
|
|||||||
MCP_TOOLS_ENABLED,
|
MCP_TOOLS_ENABLED,
|
||||||
HOST_EXECUTION_MODE_DEFAULT,
|
HOST_EXECUTION_MODE_DEFAULT,
|
||||||
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
||||||
|
HOST_SANDBOX_NETWORK_PERMISSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.file_manager import FileManager
|
from modules.file_manager import FileManager
|
||||||
@ -207,12 +210,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
self.current_permission_mode: str = "unrestricted"
|
self.current_permission_mode: str = "unrestricted"
|
||||||
self.host_execution_mode: str = "sandbox"
|
self.host_execution_mode: str = "sandbox"
|
||||||
self.host_execution_direct_until: Optional[float] = None
|
self.host_execution_direct_until: Optional[float] = None
|
||||||
|
self.host_network_permission: str = "restricted"
|
||||||
self.pending_permission_mode: Optional[str] = None
|
self.pending_permission_mode: Optional[str] = None
|
||||||
self.pending_execution_mode: Optional[str] = None
|
self.pending_execution_mode: Optional[str] = None
|
||||||
|
self.pending_network_permission: Optional[str] = None
|
||||||
# 当前生效的启用 skills(用于强约束判定)
|
# 当前生效的启用 skills(用于强约束判定)
|
||||||
self.enabled_skill_ids: Set[str] = set()
|
self.enabled_skill_ids: Set[str] = set()
|
||||||
self.apply_personalization_preferences()
|
self.apply_personalization_preferences()
|
||||||
self._init_host_execution_mode()
|
self._init_host_execution_mode()
|
||||||
|
self._init_host_network_permission()
|
||||||
|
|
||||||
# 新增:自动开始新对话
|
# 新增:自动开始新对话
|
||||||
self._ensure_conversation()
|
self._ensure_conversation()
|
||||||
@ -257,10 +263,29 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
if getattr(self, "sub_agent_manager", None):
|
if getattr(self, "sub_agent_manager", None):
|
||||||
self.sub_agent_manager.set_host_execution_mode(mode)
|
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]]:
|
def get_pending_runtime_modes(self) -> Dict[str, Optional[str]]:
|
||||||
return {
|
return {
|
||||||
"permission_mode": self.pending_permission_mode,
|
"permission_mode": self.pending_permission_mode,
|
||||||
"execution_mode": self.pending_execution_mode,
|
"execution_mode": self.pending_execution_mode,
|
||||||
|
"network_permission": getattr(self, "pending_network_permission", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
def queue_permission_mode_change(self, mode: str) -> str:
|
def queue_permission_mode_change(self, mode: str) -> str:
|
||||||
@ -283,6 +308,16 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
})
|
})
|
||||||
return normalized
|
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]]:
|
def apply_pending_runtime_mode_changes(self) -> List[Dict[str, str]]:
|
||||||
notices: List[Dict[str, str]] = []
|
notices: List[Dict[str, str]] = []
|
||||||
updates: Dict[str, Any] = {}
|
updates: Dict[str, Any] = {}
|
||||||
@ -314,6 +349,17 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
updates["pending_execution_mode"] = None
|
updates["pending_execution_mode"] = None
|
||||||
self.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:
|
if updates:
|
||||||
self._persist_runtime_mode_metadata(updates)
|
self._persist_runtime_mode_metadata(updates)
|
||||||
|
|
||||||
|
|||||||
@ -183,16 +183,25 @@ class MainTerminalContextMixin:
|
|||||||
"- 若操作受系统权限限制:先提供 1 个安全替代方案并执行;若仍无法满足目标,明确请求用户切换执行环境为“完全访问权限”后重试\n"
|
"- 若操作受系统权限限制:先提供 1 个安全替代方案并执行;若仍无法满足目标,明确请求用户切换执行环境为“完全访问权限”后重试\n"
|
||||||
"- 不要通过复杂绕过手段反复尝试规避沙箱限制;若任务本质需要更高权限,应直接说明并等待用户确认"
|
"- 不要通过复杂绕过手段反复尝试规避沙箱限制;若任务本质需要更高权限,应直接说明并等待用户确认"
|
||||||
)
|
)
|
||||||
|
net = getattr(self, "host_network_permission", "restricted")
|
||||||
|
if net == "restricted":
|
||||||
|
network_rules = "- 网络:受限(仅允许 localhost,外部网络不可达)"
|
||||||
|
elif net == "full":
|
||||||
|
network_rules = "- 网络:完全开放"
|
||||||
|
else:
|
||||||
|
network_rules = ""
|
||||||
else:
|
else:
|
||||||
rules = (
|
rules = (
|
||||||
"- 当前为宿主机直接执行模式(完全访问权限)\n"
|
"- 当前为宿主机直接执行模式(完全访问权限)\n"
|
||||||
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
|
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
|
||||||
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
|
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
|
||||||
)
|
)
|
||||||
|
network_rules = ""
|
||||||
return template.format(
|
return template.format(
|
||||||
execution_mode=mode,
|
execution_mode=mode,
|
||||||
execution_mode_label=mode_label,
|
execution_mode_label=mode_label,
|
||||||
rules=rules,
|
rules=rules,
|
||||||
|
network_rules=network_rules,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:
|
def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -1448,6 +1449,13 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
permission_mode == "readonly"
|
permission_mode == "readonly"
|
||||||
or (permission_mode in {"approval", "auto_approval"} and not write_granted_once)
|
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))
|
run_in_background = bool(arguments.get("run_in_background", False))
|
||||||
timeout_value = arguments.get("timeout")
|
timeout_value = arguments.get("timeout")
|
||||||
if run_in_background:
|
if run_in_background:
|
||||||
@ -1489,6 +1497,7 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
arguments["command"],
|
arguments["command"],
|
||||||
timeout=timeout_value,
|
timeout=timeout_value,
|
||||||
sandbox_write_access=sandbox_write_access,
|
sandbox_write_access=sandbox_write_access,
|
||||||
|
network_permission=network_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 字符数检查
|
# 字符数检查
|
||||||
|
|||||||
@ -234,8 +234,15 @@ class WebTerminal(MainTerminal):
|
|||||||
self.set_execution_mode(execution_mode)
|
self.set_execution_mode(execution_mode)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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_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_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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 重置相关状态
|
# 重置相关状态
|
||||||
@ -356,8 +363,10 @@ class WebTerminal(MainTerminal):
|
|||||||
"model_key": getattr(self, "model_key", None),
|
"model_key": getattr(self, "model_key", None),
|
||||||
"permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted",
|
"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": 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 {},
|
"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",
|
"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_images": getattr(self.context_manager, "has_images", False),
|
||||||
"has_videos": getattr(self.context_manager, "has_videos", False),
|
"has_videos": getattr(self.context_manager, "has_videos", False),
|
||||||
"context": {
|
"context": {
|
||||||
|
|||||||
@ -14,6 +14,11 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from config import MAX_RUN_COMMAND_CHARS
|
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"}
|
TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"}
|
||||||
@ -116,6 +121,7 @@ class BackgroundCommandManager:
|
|||||||
"timeout": timeout_value,
|
"timeout": timeout_value,
|
||||||
"session": session_override or getattr(terminal_ops, "container_session", None),
|
"session": session_override or getattr(terminal_ops, "container_session", None),
|
||||||
"python_env": getattr(terminal_ops, "_python_env", None) or {},
|
"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}",
|
name=f"bg-run-command-{command_id}",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
@ -182,6 +188,7 @@ class BackgroundCommandManager:
|
|||||||
timeout: int,
|
timeout: int,
|
||||||
session,
|
session,
|
||||||
python_env: Dict[str, str],
|
python_env: Dict[str, str],
|
||||||
|
host_execution_mode: str = "sandbox",
|
||||||
) -> None:
|
) -> None:
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
process: Optional[subprocess.Popen] = None
|
process: Optional[subprocess.Popen] = None
|
||||||
@ -229,18 +236,49 @@ class BackgroundCommandManager:
|
|||||||
use_shell = False
|
use_shell = False
|
||||||
|
|
||||||
if use_shell:
|
if use_shell:
|
||||||
process = subprocess.Popen(
|
use_host_sandbox = host_execution_mode != "direct"
|
||||||
command,
|
if use_host_sandbox and host_sandbox_enabled():
|
||||||
stdout=subprocess.PIPE,
|
network_permission = os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted")
|
||||||
stderr=subprocess.PIPE,
|
plan = build_host_sandbox_plan(command, work_path, env, network_permission=network_permission)
|
||||||
cwd=str(work_path),
|
cmd_args = plan.command
|
||||||
shell=True,
|
pass_fds = ()
|
||||||
env=env,
|
seccomp_fd = None
|
||||||
start_new_session=True,
|
if plan.seccomp_bpf_path:
|
||||||
text=True,
|
seccomp_fd = os.open(plan.seccomp_bpf_path, os.O_RDONLY)
|
||||||
errors="replace",
|
cmd_args = [str(seccomp_fd) if token == "__SECCOMP_FD__" else token for token in cmd_args]
|
||||||
bufsize=1,
|
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:
|
else:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
exec_cmd,
|
exec_cmd,
|
||||||
|
|||||||
@ -22,6 +22,17 @@ class HostSandboxError(RuntimeError):
|
|||||||
pass
|
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:
|
def _truthy(name: str, default: str = "1") -> bool:
|
||||||
return os.environ.get(name, default).strip().lower() not in {"0", "false", "no", "off"}
|
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")
|
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()
|
system = platform.system()
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
return _build_macos_plan(command, work_path, env)
|
return _build_macos_plan(command, work_path, env, network_permission)
|
||||||
if system == "Linux":
|
if system == "Linux":
|
||||||
return _build_linux_plan(command, work_path, env)
|
return _build_linux_plan(command, work_path, env, network_permission)
|
||||||
if system == "Windows":
|
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}")
|
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()
|
system = platform.system()
|
||||||
if system == "Darwin":
|
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":
|
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":
|
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_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}")
|
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")
|
sandbox_exec = shutil.which("sandbox-exec")
|
||||||
if not sandbox_exec:
|
if not sandbox_exec:
|
||||||
raise HostSandboxError("macOS 未找到 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]
|
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
||||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
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")
|
sandbox_exec = shutil.which("sandbox-exec")
|
||||||
if not sandbox_exec:
|
if not sandbox_exec:
|
||||||
raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝执行宿主机命令。")
|
raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝执行宿主机命令。")
|
||||||
|
network_policy = _build_macos_network_policy(network_permission)
|
||||||
profile = (
|
profile = (
|
||||||
'(version 1) '
|
'(version 1) '
|
||||||
'(deny default) '
|
'(deny default) '
|
||||||
'(allow sysctl-read) '
|
'(allow sysctl-read) '
|
||||||
'(allow process*) '
|
'(allow process*) '
|
||||||
'(allow network*) '
|
f'{network_policy}'
|
||||||
'(allow file-read*) '
|
'(allow file-read*) '
|
||||||
'(allow file-write* (literal "/dev/null"))'
|
'(allow file-write* (literal "/dev/null"))'
|
||||||
)
|
)
|
||||||
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
||||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
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")
|
sandbox_exec = shutil.which("sandbox-exec")
|
||||||
if not sandbox_exec:
|
if not sandbox_exec:
|
||||||
raise HostSandboxError("macOS 未找到 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"]
|
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-i"]
|
||||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
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())
|
workspace = str(work_path.resolve())
|
||||||
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"]
|
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"]
|
||||||
for raw in get_macos_writable_paths():
|
for raw in get_macos_writable_paths():
|
||||||
@ -112,18 +177,24 @@ def _macos_profile_for_workspace(work_path: Path) -> str:
|
|||||||
else:
|
else:
|
||||||
write_rules.append(f'(subpath "{entry}")')
|
write_rules.append(f'(subpath "{entry}")')
|
||||||
write_expr = " ".join(write_rules)
|
write_expr = " ".join(write_rules)
|
||||||
|
network_policy = _build_macos_network_policy(network_permission)
|
||||||
return (
|
return (
|
||||||
'(version 1) '
|
'(version 1) '
|
||||||
'(deny default) '
|
'(deny default) '
|
||||||
'(allow sysctl-read) '
|
'(allow sysctl-read) '
|
||||||
'(allow process*) '
|
'(allow process*) '
|
||||||
'(allow network*) '
|
f'{network_policy}'
|
||||||
'(allow file-read*) '
|
'(allow file-read*) '
|
||||||
f'(allow file-write* {write_expr})'
|
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")
|
bwrap = shutil.which("bwrap")
|
||||||
if not bwrap:
|
if not bwrap:
|
||||||
raise HostSandboxError("Linux 未找到 bubblewrap(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():
|
if not seccomp_path.exists():
|
||||||
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
|
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
|
||||||
shell_cmd = ["/bin/bash", "-lc", command]
|
shell_cmd = ["/bin/bash", "-lc", command]
|
||||||
|
# network_permission 暂不参与 Linux 构建,保持现有 --share-net 行为
|
||||||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
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")
|
bwrap = shutil.which("bwrap")
|
||||||
if not bwrap:
|
if not bwrap:
|
||||||
raise HostSandboxError("Linux 未找到 bubblewrap(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]
|
shell_cmd = ["/bin/bash", "-lc", command]
|
||||||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path, readonly=True)
|
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")
|
bwrap = shutil.which("bwrap")
|
||||||
if not bwrap:
|
if not bwrap:
|
||||||
raise HostSandboxError("Linux 未找到 bubblewrap(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"]
|
shell_cmd = ["/bin/bash", "-i"]
|
||||||
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
|
||||||
|
|
||||||
|
|
||||||
def _build_linux_common_plan(
|
def _build_linux_common_plan(
|
||||||
work_path: Path,
|
work_path: Path,
|
||||||
env: Dict[str, str],
|
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))
|
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")
|
wsl = shutil.which("wsl.exe")
|
||||||
if not wsl:
|
if not wsl:
|
||||||
raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝执行宿主机命令。")
|
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]
|
cmd = [wsl, "bash", "-lc", shell]
|
||||||
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
|
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")
|
wsl = shutil.which("wsl.exe")
|
||||||
if not wsl:
|
if not wsl:
|
||||||
raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝启动宿主机沙箱终端。")
|
raise HostSandboxError("Windows 未找到 wsl.exe(WSL2),拒绝启动宿主机沙箱终端。")
|
||||||
|
|||||||
@ -247,7 +247,10 @@ class PersistentTerminal:
|
|||||||
env['LC_ALL'] = 'en_US.UTF-8'
|
env['LC_ALL'] = 'en_US.UTF-8'
|
||||||
|
|
||||||
try:
|
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
|
cmd_args = plan.command
|
||||||
pass_fds = ()
|
pass_fds = ()
|
||||||
seccomp_fd = None
|
seccomp_fd = None
|
||||||
|
|||||||
@ -188,7 +188,10 @@ class SubAgentManager:
|
|||||||
return {"success": False, "error": "宿主机子智能体执行被拒绝:HOST_SANDBOX_ENABLED=0"}
|
return {"success": False, "error": "宿主机子智能体执行被拒绝:HOST_SANDBOX_ENABLED=0"}
|
||||||
try:
|
try:
|
||||||
command_str = self._join_shell_words(cmd)
|
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(
|
launch_cmd, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
|
||||||
plan.command,
|
plan.command,
|
||||||
plan.seccomp_bpf_path,
|
plan.seccomp_bpf_path,
|
||||||
|
|||||||
@ -18,6 +18,7 @@ try:
|
|||||||
OUTPUT_FORMATS,
|
OUTPUT_FORMATS,
|
||||||
MAX_RUN_COMMAND_CHARS,
|
MAX_RUN_COMMAND_CHARS,
|
||||||
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||||
|
HOST_SANDBOX_NETWORK_PERMISSION,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
project_root = Path(__file__).resolve().parents[1]
|
project_root = Path(__file__).resolve().parents[1]
|
||||||
@ -29,10 +30,12 @@ except ImportError:
|
|||||||
OUTPUT_FORMATS,
|
OUTPUT_FORMATS,
|
||||||
MAX_RUN_COMMAND_CHARS,
|
MAX_RUN_COMMAND_CHARS,
|
||||||
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
TOOLBOX_TERMINAL_IDLE_SECONDS,
|
||||||
|
HOST_SANDBOX_NETWORK_PERMISSION,
|
||||||
)
|
)
|
||||||
from modules.toolbox_container import ToolboxContainer
|
from modules.toolbox_container import ToolboxContainer
|
||||||
from modules.host_sandbox_runner import (
|
from modules.host_sandbox_runner import (
|
||||||
HostSandboxError,
|
HostSandboxError,
|
||||||
|
NETWORK_PERMISSION_RESTRICTED,
|
||||||
build_host_sandbox_plan,
|
build_host_sandbox_plan,
|
||||||
build_host_sandbox_readonly_plan,
|
build_host_sandbox_readonly_plan,
|
||||||
host_sandbox_enabled,
|
host_sandbox_enabled,
|
||||||
@ -308,6 +311,7 @@ class TerminalOperator:
|
|||||||
working_dir: str = None,
|
working_dir: str = None,
|
||||||
timeout: int = None,
|
timeout: int = None,
|
||||||
sandbox_write_access: bool = True,
|
sandbox_write_access: bool = True,
|
||||||
|
network_permission: Optional[str] = None,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
执行终端命令
|
执行终端命令
|
||||||
@ -383,6 +387,7 @@ class TerminalOperator:
|
|||||||
timeout,
|
timeout,
|
||||||
session_override=session_override,
|
session_override=session_override,
|
||||||
sandbox_write_access=sandbox_write_access,
|
sandbox_write_access=sandbox_write_access,
|
||||||
|
network_permission=network_permission,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 若未绑定用户容器,则使用工具箱容器(与终端相同镜像/预装包)
|
# 若未绑定用户容器,则使用工具箱容器(与终端相同镜像/预装包)
|
||||||
@ -425,7 +430,7 @@ class TerminalOperator:
|
|||||||
|
|
||||||
# 改为一次性子进程执行,确保等待到超时或命令结束
|
# 改为一次性子进程执行,确保等待到超时或命令结束
|
||||||
result_payload = result_payload if result_payload is not None else await self._run_command_subprocess(
|
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,
|
timeout: int,
|
||||||
session_override: Optional["ContainerHandle"] = None,
|
session_override: Optional["ContainerHandle"] = None,
|
||||||
sandbox_write_access: bool = True,
|
sandbox_write_access: bool = True,
|
||||||
|
network_permission: Optional[str] = None,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
try:
|
try:
|
||||||
@ -534,9 +540,13 @@ class TerminalOperator:
|
|||||||
use_host_sandbox = self.host_execution_mode != "direct"
|
use_host_sandbox = self.host_execution_mode != "direct"
|
||||||
if use_host_sandbox and host_sandbox_enabled():
|
if use_host_sandbox and host_sandbox_enabled():
|
||||||
if sandbox_write_access:
|
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:
|
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(
|
cmd_args, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
|
||||||
plan.command,
|
plan.command,
|
||||||
plan.seccomp_bpf_path,
|
plan.seccomp_bpf_path,
|
||||||
|
|||||||
@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
### 当前规则
|
### 当前规则
|
||||||
{rules}
|
{rules}
|
||||||
|
{network_rules}
|
||||||
|
|||||||
@ -50,6 +50,7 @@ chat_bp = Blueprint('chat', __name__)
|
|||||||
|
|
||||||
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
|
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
|
||||||
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
|
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:
|
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'])
|
@chat_bp.route('/api/path-authorization', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
|
|||||||
@ -1703,9 +1703,9 @@ async def handle_task_with_sender(
|
|||||||
username=username,
|
username=username,
|
||||||
task_id=client_sid,
|
task_id=client_sid,
|
||||||
)
|
)
|
||||||
# 权限/审核方式变更通知在任务完成时不应该触发新一轮工作,
|
# 权限/执行环境/网络权限变更通知在任务完成时不应该触发新一轮工作,
|
||||||
# 只保留真正的引导消息(压缩续接等),丢弃权限/执行环境变更通知。
|
# 只保留真正的引导消息(压缩续接等),丢弃变更通知。
|
||||||
runtime_skip_sources = {"权限变更", "执行环境变更", "notify"}
|
runtime_skip_sources = {"权限变更", "执行环境变更", "网络权限变更", "notify"}
|
||||||
for item in (raw_items or []):
|
for item in (raw_items or []):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
src = str(item.get("source") or "").strip().lower()
|
src = str(item.get("source") or "").strip().lower()
|
||||||
|
|||||||
@ -163,6 +163,13 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
|
|||||||
"无权限",
|
"无权限",
|
||||||
"不允许",
|
"不允许",
|
||||||
"access denied",
|
"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)
|
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 = dict(arguments or {})
|
||||||
retry_arguments["_approval_write_granted"] = True
|
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)
|
retry_tool_result = await web_terminal.handle_tool_call(function_name, retry_arguments)
|
||||||
try:
|
try:
|
||||||
result_data = json.loads(retry_tool_result)
|
result_data = json.loads(retry_tool_result)
|
||||||
|
|||||||
@ -303,6 +303,9 @@
|
|||||||
:execution-mode-enabled="executionModeEnabled"
|
:execution-mode-enabled="executionModeEnabled"
|
||||||
:current-execution-mode="currentExecutionMode"
|
:current-execution-mode="currentExecutionMode"
|
||||||
:execution-mode-options="executionModeOptions"
|
:execution-mode-options="executionModeOptions"
|
||||||
|
:network-permission-enabled="networkPermissionEnabled"
|
||||||
|
:current-network-permission="currentNetworkPermission"
|
||||||
|
:network-permission-options="networkPermissionOptions"
|
||||||
:current-context-tokens="currentContextTokens"
|
:current-context-tokens="currentContextTokens"
|
||||||
:versioning-enabled="versioningEnabled"
|
:versioning-enabled="versioningEnabled"
|
||||||
:runtime-queued-messages="runtimeQueuedMessages"
|
:runtime-queued-messages="runtimeQueuedMessages"
|
||||||
@ -343,6 +346,7 @@
|
|||||||
@toggle-permission-menu="togglePermissionMenu"
|
@toggle-permission-menu="togglePermissionMenu"
|
||||||
@change-permission-mode="changePermissionMode"
|
@change-permission-mode="changePermissionMode"
|
||||||
@change-execution-mode="changeExecutionMode"
|
@change-execution-mode="changeExecutionMode"
|
||||||
|
@change-network-permission="changeNetworkPermission"
|
||||||
@open-path-authorization="openPathAuthorizationDialog"
|
@open-path-authorization="openPathAuthorizationDialog"
|
||||||
@open-versioning-dialog="openVersioningDialog"
|
@open-versioning-dialog="openVersioningDialog"
|
||||||
@guide-runtime-message="handleGuideRuntimeMessage"
|
@guide-runtime-message="handleGuideRuntimeMessage"
|
||||||
|
|||||||
@ -379,6 +379,7 @@ export const conversationMethods = {
|
|||||||
}
|
}
|
||||||
this.fetchPermissionMode();
|
this.fetchPermissionMode();
|
||||||
this.fetchExecutionMode();
|
this.fetchExecutionMode();
|
||||||
|
this.fetchNetworkPermission();
|
||||||
await this.fetchVersioningStatus(conversationId, { silent: true });
|
await this.fetchVersioningStatus(conversationId, { silent: true });
|
||||||
await this.maybePromptVersioningMismatch(result.versioning);
|
await this.maybePromptVersioningMismatch(result.versioning);
|
||||||
this.fetchPendingToolApprovals();
|
this.fetchPendingToolApprovals();
|
||||||
|
|||||||
@ -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') {
|
if (typeof pendingModes.execution_mode === 'string') {
|
||||||
this.pendingExecutionMode = pendingModes.execution_mode;
|
this.pendingExecutionMode = pendingModes.execution_mode;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -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() {
|
async fetchPermissionMode() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/permission-mode');
|
const response = await fetch('/api/permission-mode');
|
||||||
@ -3524,6 +3577,7 @@ export const uiMethods = {
|
|||||||
this.applyStatusSnapshot(statusData);
|
this.applyStatusSnapshot(statusData);
|
||||||
this.fetchPermissionMode();
|
this.fetchPermissionMode();
|
||||||
this.fetchExecutionMode();
|
this.fetchExecutionMode();
|
||||||
|
this.fetchNetworkPermission();
|
||||||
this.fetchPendingToolApprovals();
|
this.fetchPendingToolApprovals();
|
||||||
// 立即更新配额和运行模式,避免等待其他慢接口
|
// 立即更新配额和运行模式,避免等待其他慢接口
|
||||||
this.fetchUsageQuota();
|
this.fetchUsageQuota();
|
||||||
|
|||||||
@ -129,6 +129,21 @@ export function dataState() {
|
|||||||
executionModeAutoFallbackToastId: null,
|
executionModeAutoFallbackToastId: null,
|
||||||
executionModeTtlSeconds: 600,
|
executionModeTtlSeconds: 600,
|
||||||
executionModeExpirySyncTimer: null,
|
executionModeExpirySyncTimer: null,
|
||||||
|
networkPermissionEnabled: false,
|
||||||
|
currentNetworkPermission: 'restricted',
|
||||||
|
pendingNetworkPermission: '',
|
||||||
|
networkPermissionOptions: [
|
||||||
|
{
|
||||||
|
value: 'restricted',
|
||||||
|
label: '受限',
|
||||||
|
description: '仅允许本地回环访问,外部网络不可用'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full',
|
||||||
|
label: '完全开放',
|
||||||
|
description: '允许所有出站和入站网络连接'
|
||||||
|
}
|
||||||
|
],
|
||||||
pathAuthorizationDialogOpen: false,
|
pathAuthorizationDialogOpen: false,
|
||||||
pathAuthorizationMode: 'writable',
|
pathAuthorizationMode: 'writable',
|
||||||
pathAuthorizationWritableDraft: '',
|
pathAuthorizationWritableDraft: '',
|
||||||
|
|||||||
@ -375,6 +375,29 @@
|
|||||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -459,6 +482,7 @@ const emit = defineEmits([
|
|||||||
'toggle-permission-menu',
|
'toggle-permission-menu',
|
||||||
'change-permission-mode',
|
'change-permission-mode',
|
||||||
'change-execution-mode',
|
'change-execution-mode',
|
||||||
|
'change-network-permission',
|
||||||
'open-versioning-dialog',
|
'open-versioning-dialog',
|
||||||
'guide-runtime-message',
|
'guide-runtime-message',
|
||||||
'delete-runtime-message',
|
'delete-runtime-message',
|
||||||
@ -516,6 +540,9 @@ const props = defineProps<{
|
|||||||
executionModeEnabled?: boolean;
|
executionModeEnabled?: boolean;
|
||||||
currentExecutionMode?: 'sandbox' | 'direct';
|
currentExecutionMode?: 'sandbox' | 'direct';
|
||||||
executionModeOptions?: Array<{ value: string; label: string; description: string }>;
|
executionModeOptions?: Array<{ value: string; label: string; description: string }>;
|
||||||
|
networkPermissionEnabled?: boolean;
|
||||||
|
currentNetworkPermission?: 'restricted' | 'full';
|
||||||
|
networkPermissionOptions?: Array<{ value: string; label: string; description: string }>;
|
||||||
currentContextTokens: number;
|
currentContextTokens: number;
|
||||||
versioningEnabled?: boolean;
|
versioningEnabled?: boolean;
|
||||||
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
|
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
|
||||||
|
|||||||
@ -492,12 +492,21 @@ body[data-theme='light'] {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
|
> :first-child.permission-switcher__group {
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-switcher__group {
|
.permission-switcher__group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-switcher__group-title {
|
.permission-switcher__group-title {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user