From b55f4a811db65da10b099a94c7a9ee7df10eb207 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 30 Aug 2026 23:06:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E7=BB=88=E7=AB=AF=E8=AF=BB?= =?UTF-8?q?=E5=86=99=E8=BA=AB=E4=BB=BD=E5=88=9B=E5=BB=BA=E6=97=B6=E9=92=89?= =?UTF-8?q?=E6=AD=BB=EF=BC=8C=E6=9D=83=E9=99=90=E8=B7=A8=E7=95=8C=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=94=80=E6=AF=81=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 宿主机持久终端支持只读身份:build_host_sandbox_shell_plan 三平台(macOS Seatbelt 只读 profile / Linux ro-bind / Windows WSL 只读挂载)统一 readonly 参数;macOS 只读 profile 抽为 _macos_readonly_profile_for_workspace 与只读 run_command 共用 - docker_terminal_readonly_enabled 泛化为 terminal_readonly_enabled(docker uid 与宿主机沙箱同一判定:非 unrestricted 即只读身份) - 权限跨界切换(受限档⇄unrestricted)销毁现有终端会话(close_all),受限档内部互切不销毁;与执行环境切换销毁策略一致 - terminal_input 工具层全面放开:只读白名单加入、审批集合移除、approval/auto_approval 命令文本分流删除;受限档终端内写入由系统 EPERM 兜底,审批写入走 run_command 两段式 --- AGENTS.md | 5 +- core/main_terminal.py | 2 +- core/main_terminal_parts/context/mode.py | 21 +++----- core/main_terminal_parts/tools_execution.py | 38 ++++--------- core/main_terminal_parts/tools_policy.py | 26 +++++++-- core/web_terminal.py | 6 +-- modules/host_sandbox_runner.py | 59 +++++++++++++++------ modules/persistent_terminal/start.py | 7 ++- modules/terminal_manager.py | 19 ++++--- 9 files changed, 105 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc53ecfb..5160b8b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,12 +333,14 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝 - 读取同样受限(各平台只读强制的可读边界见 §10.8);读不到属预期边界,不是故障 - `write_file` / `edit_file` 等写入类工具直接拒绝 + - 持久终端可自由创建与输入,但以只读身份运行(docker 非特权 uid / 宿主机只读 profile),写入 EPERM 由系统强制;权限跨界切换(受限档⇄unrestricted)时现有终端直接销毁,重开生效 - `approval` - `run_command` 先走只读沙箱(可读边界见 §10.8,读越界也会触发权限拒绝) - 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批 - 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果” - **审批只授予该次命令的工作区内写权限,不放大读取**(2026-08-30 方案一):可写沙箱读边界与只读同一白名单,读越界唯一途径是「路径授权」;重试后仍为文件权限拒绝时,工具结果附带 `read_scope_hint` 引导模型说明 - **执行环境锁定为沙箱**(2026-08-30 起):direct 下无沙箱兜底,启发式漏判的写命令会直接执行成功、审批承诺被架空,故批准/自动审核与 direct 硬互斥(锁定机制同 plan/只读,见 §10.6) + - 持久终端以只读身份运行(同 readonly 条目):终端内写入永远 EPERM,需审批的写入走 `run_command` 两段式通道 - `auto_approval` - `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程 - `run_command` 先走只读沙箱(可读边界见 §10.8);触发权限拒绝后由后台审批智能体自动审核 @@ -414,12 +416,13 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - 容器主进程保持 root(可写执行不变);`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖 - 强制力 = 内核 DAC:工作区属主为宿主机 root,非属主无写权;600 权限文件(如 .env)不可读;逃逸需提权(setuid/内核漏洞),无 umount 类捷径 - 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock(云端已验证);macOS Docker Desktop(virtiofs fakeowner)不执行 uid 权限,仅 Linux 宿主生效 - - 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`docker_terminal_readonly_enabled` 判定,`docker_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道 + - 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道;权限跨界切换(受限档⇄unrestricted)时销毁现有终端会话重建(`_apply_restricted_execution_mode_link` → `close_all()`) - Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益 - **macOS 宿主机 = Seatbelt 白名单读模型**(`modules/host_sandbox_runner.py`): - 只读/可写两个 profile **共用同一白名单读模型**(`_build_macos_whitelist_read_rules`),唯一区别是写权限:deny default + 系统路径白名单(`MACOS_MINIMAL_READABLE_PATHS`)+ 路径授权(writable + readable_extra)+ 工作区 + 祖先目录 literal allow(缺一个祖先进程 exec 直接 Abort trap,必须为 file-read*)+ env 注入 `GIT_CONFIG_GLOBAL=/dev/null`(可写/只读/持久终端三条 plan 均注入) - **deny 规则(.env 正则、~/.ssh 等)必须位于所有 allow 之后**(Seatbelt 后规则覆盖先规则);两个 profile 均已修复旧顺序漏洞(工作区 .env 曾实际可读) - 可写 profile 已于 2026-08-30 白名单化(此前为全局可读,导致 unrestricted/审批批准后能读授权范围外文件);白名单固有代价:祖先目录顶层文件名可列出(读文件内容仍被拒) + - 持久终端 shell plan 支持 readonly 参数(`_build_macos_shell_plan` 复用 `_macos_readonly_profile_for_workspace`):受限档终端以只读 profile 创建,unrestricted 保持可写 profile - 原生文件工具对齐(`file_manager/path_mixin.py`):读 roots 与沙箱白名单同源(系统路径 + 工作区 + 授权)+ 叠加同一禁读清单——至此 host+sandbox 下全部读通道(只读/可写沙箱命令、原生 read_file)共享同一边界 - **Linux 宿主机 = bwrap**:只读为 `--ro-bind / /`(全局只读);可写为 `--ro-bind / /` + 工作区可写 bind——**读侧仍是全局可读,尚未对齐白名单**(本机无 Linux 测试环境,刻意未动,待后续);**Windows = WSL2 最小根文件系统(白名单)**——命名空间内只有系统目录+工作区,天然符合 diff --git a/core/main_terminal.py b/core/main_terminal.py index c028553d..44614a8d 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -132,7 +132,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi broadcast_callback=None, # CLI模式不需要广播 container_session=container_session, network_permission_getter=self.get_network_permission, - docker_readonly_getter=self.docker_terminal_readonly_enabled, + terminal_readonly_getter=self.terminal_readonly_enabled, ) # 让 run_command 复用终端容器,保持环境一致 self.terminal_ops.attach_terminal_manager(self.terminal_manager) diff --git a/core/main_terminal_parts/context/mode.py b/core/main_terminal_parts/context/mode.py index fc73800e..1e6e6dd7 100644 --- a/core/main_terminal_parts/context/mode.py +++ b/core/main_terminal_parts/context/mode.py @@ -172,22 +172,19 @@ class ModeMixin: "- run_command:统一在系统只读沙箱中执行,写入会被系统直接拒绝\n" "- 可读范围同样受限(宿主机 macOS:仅系统目录/工作区/路径授权可读;docker:受文件属主权限限制,600 等属主保护文件不可读);读不到属预期边界,不要尝试绕过\n" "- 文件操作:仅允许 read_file、view_image、view_video 等读取类工具\n" - "- 禁止:write_file、edit_file 及会修改工作区的命令操作" + "- 禁止:write_file、edit_file 及会修改工作区的命令操作\n" + "- 终端会话(terminal_*):可自由创建与输入,但终端以只读身份运行(docker 非特权 uid / 宿主机只读沙箱 profile),写入会被系统拒绝,属预期边界" ) elif mode == "approval": detailed_rules = ( "- run_command:先在系统只读沙箱执行(可读范围同样受限);仅当出现权限拒绝时才触发审批\n" "- 审批通过后:仅当前这一次命令会以可写沙箱重试;审批只授予工作区内写权限,不放大读取范围\n" "- 读取授权范围外的路径:审批无法放行,需用户在「路径授权」中添加\n" - "- 需要用户批准:terminal_input、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n" + "- 需要用户批准:write_file、edit_file、save_webpage 及其他会修改工作区的操作\n" "- 被拒绝或超时:本次操作不会执行写入\n" - "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)" + "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)\n" + "- 终端会话(terminal_*)可直接创建与输入(无需批准),但以只读身份运行(docker 非特权 uid / 宿主机只读沙箱 profile):终端里的写入会被系统拒绝,需要写入的命令请用 run_command(审批通过后以可写身份重跑),属预期边界" ) - if docker_runtime: - detailed_rules += ( - "\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command(审批通过后以可写身份重跑);" - "终端里的写入会被系统拒绝,属预期边界" - ) elif mode == "auto_approval": detailed_rules = ( "- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n" @@ -196,13 +193,9 @@ class ModeMixin: "- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n" "- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n" "- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准\n" - "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)" + "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)\n" + "- 终端会话(terminal_*)以只读身份运行(docker 非特权 uid / 宿主机只读沙箱 profile):终端里的写入会被系统拒绝,需要写入的命令请用 run_command(审批通过后以可写身份重跑),属预期边界" ) - if docker_runtime: - detailed_rules += ( - "\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command(审批通过后以可写身份重跑);" - "终端里的写入会被系统拒绝,属预期边界" - ) else: detailed_rules = "" diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 5e2497bc..83544bd1 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -209,8 +209,11 @@ class MainTerminalToolsExecutionMixin: "sleep", "ask_user", # 以下为只读/调研性质或会话状态管理类工具,只读模式(含计划模式)放行: - # 终端会话管理与快照(terminal_input 仍禁止,防止经持久终端执行写命令) + # 终端会话全生命周期(terminal_input 自 2026-08-30 起放行:终端读写身份在 + # 创建时已钉死,受限档终端为只读身份——docker 非特权 uid / 宿主机只读 + # profile,写入由系统直接 EPERM,无需工具层再拦) "terminal_session", + "terminal_input", "terminal_snapshot", # 子智能体全生命周期(只读权限会传播给子智能体,计划模式调研主力) "create_sub_agent", @@ -225,7 +228,6 @@ class MainTerminalToolsExecutionMixin: } _APPROVAL_REQUIRED_TOOLS = { "run_command", - "terminal_input", "write_file", "edit_file", "create_file", @@ -428,20 +430,9 @@ class MainTerminalToolsExecutionMixin: "requires_approval": requires, "risk_markers": risk_markers, } - if tool_name == "terminal_input": - command_text = args.get("command") or args.get("input") - if self._is_docker_runtime(): - readonly_ok = self._is_readonly_run_command_allowed(command_text) - risk_markers = self._extract_command_risk_markers(command_text) - requires = (not readonly_ok) or bool(risk_markers) - return { - "allowed": True, - "mode": mode, - "requires_approval": requires, - "risk_markers": risk_markers, - } - if self._is_readonly_run_command_allowed(command_text): - return {"allowed": True, "mode": mode, "requires_approval": False} + # terminal_input 不设审批/分流(2026-08-30 起):受限档终端创建时 + # 已钉死为只读身份,写入由系统 EPERM 兜底;需要审批的写入请走 + # run_command 两段式通道。 if tool_name in self._APPROVAL_REQUIRED_TOOLS: return {"allowed": True, "mode": mode, "requires_approval": True} return {"allowed": True, "mode": mode} @@ -473,19 +464,8 @@ class MainTerminalToolsExecutionMixin: "risk_markers": risk_markers, } if tool_name == "terminal_input": - command_text = args.get("command") or args.get("input") - if self._is_docker_runtime(): - readonly_ok = self._is_readonly_run_command_allowed(command_text) - risk_markers = self._extract_command_risk_markers(command_text) - requires = (not readonly_ok) or bool(risk_markers) - return { - "allowed": True, - "mode": mode, - "requires_approval": requires, - "risk_markers": risk_markers, - } - if self._is_readonly_run_command_allowed(command_text): - return {"allowed": True, "mode": mode, "requires_approval": False} + # 与 approval 同理:受限档终端只读身份兜底,不审批不分流 + return {"allowed": True, "mode": mode, "requires_approval": False} if tool_name in self._APPROVAL_REQUIRED_TOOLS: return {"allowed": True, "mode": mode, "requires_approval": True} return {"allowed": True, "mode": mode} diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index abed41e0..f434ae6e 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -239,12 +239,17 @@ class MainTerminalToolsPolicyMixin: return "unrestricted" return mode - def docker_terminal_readonly_enabled(self) -> bool: - """docker 持久终端是否以只读身份(非特权 uid)创建。 + def terminal_readonly_enabled(self) -> bool: + """持久终端是否以只读身份创建(docker 与宿主机沙箱通用)。 - readonly / approval / auto_approval 模式下为 True:新建的 docker - 终端会话与 run_command 只读执行同一身份(内核 DAC 强制只读,见 - modules/docker_readonly_exec.py);unrestricted 保持 root。 + readonly / approval / auto_approval 模式下为 True:新建的终端 + 会话与只读 run_command 同一身份——docker 容器内为非特权 uid + (内核 DAC 强制只读,见 modules/docker_readonly_exec.py), + 宿主机沙箱为只读 profile(macOS Seatbelt 白名单只读 / Linux ro-bind + / Windows WSL 只读挂载,见 host_sandbox_runner 的 shell plan + readonly 参数);unrestricted 保持可写身份(docker root / + 宿主机可写 profile)。权限跨界切换(受限档⇄unrestricted)时 + 现有终端会被销毁,下次以新身份重建。 """ return self.get_permission_mode() != "unrestricted" @@ -408,7 +413,18 @@ class MainTerminalToolsPolicyMixin: 切离受限档(到 unrestricted)时仅当有明确进入前记录才恢复(无记录保持 sandbox,安全默认)。 注意:metadata 键名保留 pre_readonly_execution_mode 不改(语义已泛化为受限档共用), 存量对话的恢复记录不受影响。 + + 终端身份联动:终端读写身份在创建时钉死(受限档=只读身份,unrestricted=可写/root), + 跨界切换时销毁现有终端会话,强制下次以新身份重建;readonly⇄approval + 互切身份不变,不销毁。与执行环境切换销毁(terminal_manager.set_host_execution_mode)同策略。 """ + if entering or leaving: + try: + tm = getattr(self, "terminal_manager", None) + if tm is not None and hasattr(tm, "close_all"): + tm.close_all() + except Exception: + pass if entering: try: if hasattr(self, "get_execution_mode") and self.get_execution_mode() == "direct": diff --git a/core/web_terminal.py b/core/web_terminal.py index 8ab2151c..6899b858 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -136,8 +136,8 @@ class WebTerminal(MainTerminal): # 复用父类实例时也要确保 getter 已注入(父类创建时已传入,防御旧实例) if getattr(self.terminal_manager, "network_permission_getter", None) is None: self.terminal_manager.network_permission_getter = self.get_network_permission - if getattr(self.terminal_manager, "docker_readonly_getter", None) is None: - self.terminal_manager.docker_readonly_getter = self.docker_terminal_readonly_enabled + if getattr(self.terminal_manager, "terminal_readonly_getter", None) is None: + self.terminal_manager.terminal_readonly_getter = self.terminal_readonly_enabled else: self.terminal_manager = TerminalManager( project_path=project_path, @@ -147,7 +147,7 @@ class WebTerminal(MainTerminal): broadcast_callback=message_callback, container_session=self.container_session, network_permission_getter=self.get_network_permission, - docker_readonly_getter=self.docker_terminal_readonly_enabled, + terminal_readonly_getter=self.terminal_readonly_enabled, ) # 让 run_command 与实时终端共享同一容器环境 self.terminal_ops.attach_terminal_manager(self.terminal_manager) diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index ba46a164..b0efacc4 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -219,14 +219,21 @@ def build_host_sandbox_shell_plan( work_path: Path, env: Dict[str, str], network_permission: Optional[str] = None, + readonly: bool = False, ) -> SandboxPlan: + """持久终端(交互式 shell)沙箱计划。 + + readonly=True 时使用只读身份(macOS 只读 profile / Linux bwrap ro-bind / + Windows WSL 只读挂载):受限权限档(只读/批准/自动审核)的终端以此创建, + 写入由系统直接拒绝(EPERM);unrestricted 档传 False 保持可写。 + """ system = platform.system() if system == "Darwin": - return _build_macos_shell_plan(work_path, env, network_permission) + return _build_macos_shell_plan(work_path, env, network_permission, readonly=readonly) if system == "Linux": - return _build_linux_shell_plan(work_path, env, network_permission) + return _build_linux_shell_plan(work_path, env, network_permission, readonly=readonly) if system == "Windows": - return _build_windows_shell_plan(work_path, env, network_permission) + return _build_windows_shell_plan(work_path, env, network_permission, readonly=readonly) raise HostSandboxError(tr("sandbox.unsupported_system", system=system)) @@ -256,14 +263,29 @@ def _build_macos_readonly_plan( sandbox_exec = shutil.which("sandbox-exec") if not sandbox_exec: raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec")) + profile = _macos_readonly_profile_for_workspace(work_path, network_permission) + # git 在 ~/.gitconfig 不可读时会 fatal(PoC 实测),指向 /dev/null 跳过全局配置 + plan_env = dict(env) + plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null") + cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command] + return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path)) + + +def _macos_readonly_profile_for_workspace( + work_path: Path, + network_permission: Optional[str] = None, +) -> str: + """macOS 只读沙箱 profile(deny-default 白名单读模型,2026-08-30 起)。 + + 默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读, + 写权限仅 /dev/null;deny 规则在白名单内做最后排除(如工作区内的 .env)。 + 历史模型为「全局可读 + 敏感路径黑名单」,且 deny 顺序在 workspace allow + 之前导致工作区内 .env 实际可读(Seatbelt 后规则覆盖先规则),本次一并修复。 + 只读 run_command 与受限档持久终端(shell plan readonly=True)共用本函数。 + """ network_policy = _build_macos_network_policy(network_permission) workspace = str(work_path.resolve()) - # 只读沙箱(2026-08-30 起):deny-default 白名单读模型。 - # 默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读, - # 写权限仅 /dev/null;deny 规则在白名单内做最后排除(如工作区内的 .env)。 - # 历史模型为「全局可读 + 敏感路径黑名单」,且 deny 顺序在 workspace allow - # 之前导致工作区内 .env 实际可读(Seatbelt 后规则覆盖先规则),本次一并修复。 readable_paths = list(MACOS_MINIMAL_READABLE_PATHS) readable_paths.extend(get_macos_readable_paths()) readable_paths.append(str(work_path)) # 原始形式(可能含符号链接) @@ -274,7 +296,7 @@ def _build_macos_readonly_plan( if regex_rules: deny_rules += "\n" + regex_rules - profile = ( + return ( '(version 1)\n' '(deny default)\n' '(allow sysctl-read)\n' @@ -285,22 +307,23 @@ def _build_macos_readonly_plan( f'{deny_rules}\n' '(allow file-write* (literal "/dev/null"))' ) - # git 在 ~/.gitconfig 不可读时会 fatal(PoC 实测),指向 /dev/null 跳过全局配置 - plan_env = dict(env) - plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null") - cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command] - return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path)) def _build_macos_shell_plan( work_path: Path, env: Dict[str, str], network_permission: Optional[str] = None, + readonly: bool = False, ) -> SandboxPlan: sandbox_exec = shutil.which("sandbox-exec") if not sandbox_exec: raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec_shell")) - profile = _macos_profile_for_workspace(work_path, network_permission) + # 受限档终端以只读身份创建(与只读 run_command 同一 profile),写入 EPERM; + # unrestricted 保持可写 profile(白名单读 + 工作区/授权路径可写)。 + if readonly: + profile = _macos_readonly_profile_for_workspace(work_path, network_permission) + else: + profile = _macos_profile_for_workspace(work_path, network_permission) # 同 _build_macos_plan:白名单读下 git 需要 GIT_CONFIG_GLOBAL 兜底 plan_env = dict(env) plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null") @@ -401,6 +424,7 @@ def _build_linux_shell_plan( work_path: Path, env: Dict[str, str], network_permission: Optional[str] = None, + readonly: bool = False, ) -> SandboxPlan: bwrap = shutil.which("bwrap") if not bwrap: @@ -412,7 +436,7 @@ def _build_linux_shell_plan( if not seccomp_path.exists(): raise HostSandboxError(tr("sandbox.seccomp_bpf_not_found", path=seccomp_path)) 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, readonly=readonly) def _build_linux_common_plan( @@ -627,8 +651,9 @@ def _build_windows_shell_plan( work_path: Path, env: Dict[str, str], network_permission: Optional[str] = None, + readonly: bool = False, ) -> SandboxPlan: return _build_windows_wsl_plan( - work_path, env, ["bash", "-i"], readonly=False, + work_path, env, ["bash", "-i"], readonly=readonly, network_permission=network_permission, ) diff --git a/modules/persistent_terminal/start.py b/modules/persistent_terminal/start.py index 55e51863..8b751fb6 100644 --- a/modules/persistent_terminal/start.py +++ b/modules/persistent_terminal/start.py @@ -157,8 +157,13 @@ class StartMixin: try: # 实时读取所属终端实例的权限设置,而非进程级 env(多 terminal 并存时 env 会被覆盖) network_permission = self._resolve_network_permission() + # 终端读写身份创建时钉死:受限权限档(只读/批准/自动审核)→ 只读 profile + # (写入 EPERM 由系统强制);unrestricted → 可写 profile。 + # 权限跨界切换时所属 terminal 会销毁现有会话,保证身份与当前档位一致。 + readonly = bool(self.sandbox_options.get("host_terminal_readonly")) plan = build_host_sandbox_shell_plan( - self.working_dir, env, network_permission=network_permission + self.working_dir, env, network_permission=network_permission, + readonly=readonly, ) cmd_args = plan.command pass_fds = () diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index 8c39b103..956806ff 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -77,7 +77,7 @@ class TerminalManager: sandbox_options: Optional[Dict] = None, container_session: Optional["ContainerHandle"] = None, network_permission_getter: Optional[Callable] = None, - docker_readonly_getter: Optional[Callable] = None, + terminal_readonly_getter: Optional[Callable] = None, ): """ 初始化终端管理器 @@ -129,10 +129,12 @@ class TerminalManager: # 网络权限 getter(由所属 WebTerminal 注入):新建持久终端时透传, # 使 shell 沙箱实时跟随该终端实例的权限设置而非进程级 env。 self.network_permission_getter = network_permission_getter - # docker 只读身份 getter(由所属 WebTerminal 注入):返回 True 时新建的 - # docker 终端会话以非特权 uid 运行(与 run_command 只读执行同一身份, - # 见 modules/docker_readonly_exec.py);已存在的会话不受影响。 - self.docker_readonly_getter = docker_readonly_getter + # 终端只读身份 getter(由所属 WebTerminal 注入):返回 True 时新建的 + # 终端会话以只读身份运行——docker 容器内为非特权 uid(与 run_command + # 只读执行同一身份,见 modules/docker_readonly_exec.py),宿主机沙箱为 + # 只读 profile(host_sandbox_runner shell plan 的 readonly 参数); + # 已存在的会话不受影响(权限跨界切换时由 set_permission_mode 销毁重建)。 + self.terminal_readonly_getter = terminal_readonly_getter # 终端命令超时工具(兼容 macOS 无 coreutils 的情况) self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout") @@ -163,8 +165,11 @@ class TerminalManager: """构造当前终端应使用的沙箱参数。""" options = dict(self.sandbox_options) options["allow_direct_host_execution"] = self.sandbox_mode == "host" and self.host_execution_mode == "direct" - readonly_getter = getattr(self, "docker_readonly_getter", None) - options["docker_readonly_exec"] = bool(readonly_getter and readonly_getter()) + readonly_getter = getattr(self, "terminal_readonly_getter", None) + readonly_terminal = bool(readonly_getter and readonly_getter()) + options["docker_readonly_exec"] = readonly_terminal + # 宿主机沙箱持久终端同身份:受限档(非 unrestricted)以只读 profile 创建 + options["host_terminal_readonly"] = readonly_terminal if self.container_session and self.container_session.mode == "docker": options["container_name"] = self.container_session.container_name options["mount_path"] = self.container_session.mount_path