diff --git a/AGENTS.md b/AGENTS.md index 54d707bc..bc53ecfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,22 +331,28 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - `readonly` - `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝 + - 读取同样受限(各平台只读强制的可读边界见 §10.8);读不到属预期边界,不是故障 - `write_file` / `edit_file` 等写入类工具直接拒绝 - `approval` - - `run_command` 先走只读沙箱 + - `run_command` 先走只读沙箱(可读边界见 §10.8,读越界也会触发权限拒绝) - 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批 - 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果” + - **审批只授予该次命令的工作区内写权限,不放大读取**(2026-08-30 方案一):可写沙箱读边界与只读同一白名单,读越界唯一途径是「路径授权」;重试后仍为文件权限拒绝时,工具结果附带 `read_scope_hint` 引导模型说明 + - **执行环境锁定为沙箱**(2026-08-30 起):direct 下无沙箱兜底,启发式漏判的写命令会直接执行成功、审批承诺被架空,故批准/自动审核与 direct 硬互斥(锁定机制同 plan/只读,见 §10.6) - `auto_approval` - `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程 - - `run_command` 先走只读沙箱;触发权限拒绝后由后台审批智能体自动审核 + - `run_command` 先走只读沙箱(可读边界见 §10.8);触发权限拒绝后由后台审批智能体自动审核 - 自动审核拒绝时,工具返回“被拒绝+理由”并继续主循环(不强制结束任务) + - 批准后的读取边界与 approval 相同(不放大) + - 执行环境同样锁定为沙箱(同 approval 条目) - `unrestricted` - - 不做权限模式拦截;是否沙箱由执行环境决定 + - 权限层不做拦截:工作区内读写自由、命令免批准;是否沙箱由执行环境决定 + - 注意「无限制」只管工作区内:宿主机沙箱执行时读边界仍是白名单(见 §10.8),授权范围外读取需路径授权;direct 模式才真正无边界 ### 10.2 执行环境 - `sandbox`(默认):使用 OS 沙箱执行(macOS: `sandbox-exec`,Linux: `bwrap + seccomp`,Windows: `WSL2`) -- `direct`:宿主机直接执行(高风险) +- `direct`:宿主机直接执行(高风险),**仅 `unrestricted` 权限可选**:受限权限档(只读/批准/自动审核)与 plan 模式下后端 `set_execution_mode` 硬锁拒绝 direct(i18n 键 `main_terminal.restricted_mode_locks_sandbox` / `plan_mode_locks_sandbox`);进入受限档时若已是 direct 会被联动压回沙箱(记录 `pre_readonly_execution_mode`,切回 unrestricted 时恢复);存量「受限档+direct」对话在加载时自愈矫正回沙箱 - 切换后一直生效,无自动回退机制(2026-07 已移除原 TTL 自动回退) ### 10.3 路径授权语义 @@ -357,6 +363,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - 语义: - 可读集合 = 可读可写 + 仅可读 - 可写集合 = 可读可写 +- 配置来源(2026-08-30 收敛为两个):`config/host_sandbox_policy.json`(UI 唯一读写目标)+ 真·环境变量 `HOST_SANDBOX_MACOS_WRITABLE_PATHS`(部署通道,与文件合并去重)。settings.json 的 `terminal.macos_writable_paths` 映射已移除(历史值一次性失效,需用路径授权重新添加);.env 注入技术上仍生效,但不是受支持的通道,不推荐使用 ### 10.4 维护约束 @@ -386,8 +393,9 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: ### 实现要点(改代码必须知道) -- **后端核心**:`core/main_terminal_parts/tools_policy.py`(WORK_MODES、`get/set/switch_work_mode`、plan 锁);`switch_work_mode` 处理 plan⇄只读+沙箱联动,`pre_plan_permission_mode`/`pre_plan_execution_mode` 存对话 metadata;执行环境 plan 锁在 `core/main_terminal.py::set_execution_mode`(只拦 direct) -- **创建对话的 work_mode 继承**:三条创建路径(prefer_defaults / 显式模式 / safe_navigation)一律沿用 terminal 当前值,**不用个性化默认值覆盖**——/new 页面切换器显示什么新对话就是什么;个性化 `default_work_mode` 仅在 terminal 首次构造时生效。plan 锁存在于 `set_permission_mode`,任何创建路径不得在其之前调用非只读 set(曾因此 500) +- **后端核心**:`core/main_terminal_parts/tools_policy.py`(WORK_MODES、`get/set/switch_work_mode`、plan 锁;`RESTRICTED_PERMISSION_MODES` 受限档集合 + `set_permission_mode` 内 `_apply_restricted_execution_mode_link` 处理受限档⇄沙箱联动,`pre_readonly_execution_mode` 存对话 metadata——键名保留,语义已泛化为受限档共用);`switch_work_mode` 处理 plan⇄只读+沙箱联动,`pre_plan_permission_mode`/`pre_plan_execution_mode` 存对话 metadata;执行环境 plan 锁与受限档锁同在 `core/main_terminal.py::set_execution_mode`(只拦 direct) +- **创建对话的模式继承(work_mode + permission_mode 同一原则)**:三条创建路径(prefer_defaults / 显式模式 / safe_navigation)一律沿用 terminal 当前值,**不用个性化默认值覆盖**——/new 页面切换器显示什么新对话就是什么(切换经 `_sync_workspace_terminal_mode` 同步到工作区级 terminal);个性化 `default_work_mode` / `default_permission_mode` 仅在 terminal 首次构造时生效(tools_policy 加载)。多智能体创建路径(`server/multi_agent.py`)与工作流创建路径(`workflow_runtime_api.py`)同样遵守。plan 锁存在于 `set_permission_mode`,任何创建路径不得在其之前调用非只读 set(曾因此 500) + - 历史教训:permission_mode 曾长期例外(`a2a04b95` 引入「个性化默认优先」,彼时还没有 /new 切换器同步机制),导致 /new 切只读后新建对话回落无限制(2026-08-30 修复,与 work_mode 对齐) - **提示词**:`prompts/work_mode.txt` 模板 + `mode.py::_build_work_mode_rules`(三档规则唯一来源,冻结注入与切换通知共用);冻结注入在 `messages.py`(执行环境之后);切换走现有 drift 机制(`_RUNTIME_MODE_KINDS` 第四种),通知携带完整新规则文本 - **API**:`GET/POST /api/work-mode`(`server/chat/permission.py`);**仅空闲可切换,运行中 409**(无 pending 队列);`plan-approvals` pending/answer 端点在 `server/chat/approval.py` - **submit_plan 链路**:工具全模式注入(沟通类工具不过滤,非 plan 调用由 handler 运行时兜延返回引导);`PlanApprovalManager`(`modules/plan_approval_manager.py`)+ `chat_flow_tool_loop.py::_handle_submit_plan` 阻塞等待 → 前端 `PlanApprovalDialog.vue` 弹窗 → 批准则工具循环内切 execute 并静默更新 baseline(避免误发 drift 通知) @@ -396,7 +404,24 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: ### 10.7) 后台命令只读沙箱修复(2026-08-13) -后台 `run_command`(`run_in_background=true`)此前在**所有环境**绕过只读权限(`background_command_manager` 固定用可写沙箱计划)。已修复:`create_background_command` 透传 `sandbox_write_access`,宿主机路径按它选只读/可写计划。docker/web 模式的只读仍仅靠提示词约束(无真沙箱),维持现状。 +后台 `run_command`(`run_in_background=true`)此前在**所有环境**绕过只读权限(`background_command_manager` 固定用可写沙箱计划)。已修复:`create_background_command` 透传 `sandbox_write_access`,宿主机路径按它选只读/可写计划;docker 路径按它决定是否以非特权 uid 执行(见 §10.8)。 + +### 10.8) 平台级只读强制(2026-08-30) + +只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。 + +- **docker/web 模式 = 非特权 uid 执行角色**(`modules/docker_readonly_exec.py`): + - 容器主进程保持 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 审批通道 + - 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/审批批准后能读授权范围外文件);白名单固有代价:祖先目录顶层文件名可列出(读文件内容仍被拒) + - 原生文件工具对齐(`file_manager/path_mixin.py`):读 roots 与沙箱白名单同源(系统路径 + 工作区 + 授权)+ 叠加同一禁读清单——至此 host+sandbox 下全部读通道(只读/可写沙箱命令、原生 read_file)共享同一边界 +- **Linux 宿主机 = bwrap**:只读为 `--ro-bind / /`(全局只读);可写为 `--ro-bind / /` + 工作区可写 bind——**读侧仍是全局可读,尚未对齐白名单**(本机无 Linux 测试环境,刻意未动,待后续);**Windows = WSL2 最小根文件系统(白名单)**——命名空间内只有系统目录+工作区,天然符合 ## 11) 多智能体对话类型(multi-agent conversation type) diff --git a/config/__init__.py b/config/__init__.py index e9c4feb3..96393754 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -59,10 +59,12 @@ def _load_dotenv(): os.environ[k] = str(v) # 映射 settings.json 字段 → 旧版环境变量名(兼容现有 config/*.py 模块) + # 注意:terminal.macos_writable_paths 已于 2026-08-30 移除—— + # 沙箱路径授权只读两个来源:config/host_sandbox_policy.json(前端 + # 「路径授权」UI)与真·环境变量;settings.json 中的历史值一次性失效。 _LEGACY_MAP = { "terminal.sandbox_mode": "TERMINAL_SANDBOX_MODE", "terminal.execution_mode_default": "HOST_EXECUTION_MODE_DEFAULT", - "terminal.macos_writable_paths": "HOST_SANDBOX_MACOS_WRITABLE_PATHS", "terminal.max_active_containers": "MAX_ACTIVE_USER_CONTAINERS", "terminal.project_max_storage_mb": "PROJECT_MAX_STORAGE_MB", "server.port": "WEB_SERVER_PORT", diff --git a/config/limits.py b/config/limits.py index 2b5ef9fb..81c7c473 100644 --- a/config/limits.py +++ b/config/limits.py @@ -43,7 +43,11 @@ READ_TOOL_MAX_MATCHES = 50 PROJECT_MAX_STORAGE_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048")) PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024 -# 只读权限模式:run_command 白名单配置 +# 只读权限模式:run_command 命令白名单(启发式文本识别)。 +# 注意(2026-08-30 起):这只是「审批决策」与「兜底」的启发式,不再是安全边界—— +# docker 只读由非特权 uid 内核 DAC 强制(modules/docker_readonly_exec.py), +# 宿主机只读由 OS 沙箱强制(modules/host_sandbox_runner.py)。已知可绕过 +# (如 find . -delete),请勿再为它增加拦截规则试图把它做成边界。 READONLY_RUN_COMMAND_ALLOWED = ( "grep", "find", diff --git a/config/terminal.py b/config/terminal.py index 75e38c1d..8786dae8 100644 --- a/config/terminal.py +++ b/config/terminal.py @@ -54,6 +54,11 @@ LINUX_SAFETY = os.environ.get("LINUX_SAFETY", "0") not in {"0", "false", "False" TOOLBOX_TERMINAL_IDLE_SECONDS = int(os.environ.get("TOOLBOX_TERMINAL_IDLE_SECONDS", "900")) MAX_ACTIVE_USER_CONTAINERS = int(os.environ.get("MAX_ACTIVE_USER_CONTAINERS", "8")) HOST_EXECUTION_MODE_DEFAULT = os.environ.get("HOST_EXECUTION_MODE_DEFAULT", "sandbox").strip().lower() +# 沙箱可写路径的「部署通道」(逗号分隔)。路径授权只有两个来源: +# config/host_sandbox_policy.json(前端「路径授权」UI)+ 本变量(真·环境变量), +# 两者合并去重(见 modules/host_sandbox_policy.py::get_macos_writable_paths)。 +# settings.json 的 terminal.macos_writable_paths 映射已于 2026-08-30 移除; +# .env 注入技术上仍会生效,但不是受支持的配置通道(不推荐使用)。 HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths( os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "") ) diff --git a/core/main_terminal.py b/core/main_terminal.py index a1c52edc..c028553d 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -132,6 +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, ) # 让 run_command 复用终端容器,保持环境一致 self.terminal_ops.attach_terminal_manager(self.terminal_manager) @@ -208,7 +209,10 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.skill_strict_run_command_foreground_enabled: bool = False self.skill_strict_run_command_background_enabled: bool = False self.default_permission_mode: str = "unrestricted" - self.current_permission_mode: str = "unrestricted" + # None = 未初始化:由随后的 apply_personalization_preferences 应用个性化默认 + # 权限模式(与 current_work_mode 同一机制,构造时生效一次);所有读取路径 + # 均按 None→unrestricted 兜底(get_permission_mode / tools_execution) + self.current_permission_mode: Optional[str] = None self.host_execution_mode: str = "sandbox" self.host_network_permission: str = "restricted" self.pending_permission_mode: Optional[str] = None @@ -352,7 +356,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.set_execution_mode(pending_execution) applied = True except Exception: - # 锁定拒绝(plan / readonly 下禁止 direct):丢弃该 pending,保持沙箱 + # 锁定拒绝(plan / 受限权限档下禁止 direct):丢弃该 pending,保持沙箱 applied = False if applied: if hasattr(self, "build_runtime_mode_switch_notice"): @@ -433,13 +437,14 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi raise ValueError(tr("main_terminal.plan_mode_locks_sandbox")) except AttributeError: pass - # 只读模式下执行环境同样锁死为沙箱(与 plan 锁并列): - # 切到 readonly 权限时由 set_permission_mode 联动强制沙箱, - # 这里拦直接调用(API/队列)防止只读期间切回 direct 绕过 OS 沙箱硬限制。 + # 受限权限档(只读/批准/自动审核)执行环境同样锁死为沙箱(与 plan 锁并列): + # 切到受限档时由 set_permission_mode 联动强制沙箱,这里拦直接调用(API/队列) + # 防止受限档期间切回 direct——direct 下无沙箱,只读形同虚设、审批承诺被架空 + # (启发式漏判的写命令无 EPERM 兜底)。仅 unrestricted 可选 direct。 if normalized == "direct" and getattr(self, "get_permission_mode", None): try: - if self.get_permission_mode() == "readonly": - raise ValueError(tr("main_terminal.readonly_mode_locks_sandbox")) + if self.get_permission_mode() != "unrestricted": + raise ValueError(tr("main_terminal.restricted_mode_locks_sandbox")) except AttributeError: pass self.host_execution_mode = normalized @@ -679,7 +684,9 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi thinking_mode=self.thinking_mode, run_mode=self.run_mode, metadata_overrides={ - "permission_mode": self.default_permission_mode or "unrestricted", + # 构造时 current_permission_mode 已由 apply_personalization_preferences + # 初始化为个性化默认值,这里直接取当前生效值即可 + "permission_mode": self.get_permission_mode(), "execution_mode": self.get_execution_mode(), "pending_permission_mode": None, "pending_execution_mode": None, diff --git a/core/main_terminal_parts/context/mode.py b/core/main_terminal_parts/context/mode.py index 7ab8e8d6..fc73800e 100644 --- a/core/main_terminal_parts/context/mode.py +++ b/core/main_terminal_parts/context/mode.py @@ -150,6 +150,10 @@ class ModeMixin: mode = self.get_permission_mode() + # docker 运行时(终端会话为只读身份,需要补充引导) + _session = getattr(self, "container_session", None) + docker_runtime = bool(_session and getattr(_session, "mode", None) == "docker") + # 根据模式生成详细规则 if mode == "unrestricted": detailed_rules = ( @@ -157,28 +161,48 @@ class ModeMixin: "- run_command:支持任意命令,包括管道、重定向、子shell等\n" "- 文件操作:read_file / write_file / edit_file 可直接使用;其他文件管理请通过 run_command 执行;需要 Python 时先探测并选择合适解释器" ) + if not docker_runtime: + # 权限模式只管工作区内读写;宿主机沙箱的读边界与只读模式同一白名单 + detailed_rules += ( + "\n- 可读范围:宿主机沙箱执行时同样受限(仅系统目录/工作区/路径授权可读);" + "读取授权范围外的文件会被系统拒绝,需用户在「路径授权」中添加,属预期边界,不要尝试绕过" + ) elif mode == "readonly": detailed_rules = ( - "- run_command:统一在系统只读沙箱中执行\n" - "- 若命令触发写入,系统会直接拒绝并返回权限错误\n" + "- run_command:统一在系统只读沙箱中执行,写入会被系统直接拒绝\n" + "- 可读范围同样受限(宿主机 macOS:仅系统目录/工作区/路径授权可读;docker:受文件属主权限限制,600 等属主保护文件不可读);读不到属预期边界,不要尝试绕过\n" "- 文件操作:仅允许 read_file、view_image、view_video 等读取类工具\n" "- 禁止:write_file、edit_file 及会修改工作区的命令操作" ) elif mode == "approval": detailed_rules = ( - "- run_command:先在系统只读沙箱执行;仅当出现权限拒绝时才触发审批\n" - "- 审批通过后:仅当前这一次命令会重试为可写沙箱执行\n" + "- run_command:先在系统只读沙箱执行(可读范围同样受限);仅当出现权限拒绝时才触发审批\n" + "- 审批通过后:仅当前这一次命令会以可写沙箱重试;审批只授予工作区内写权限,不放大读取范围\n" + "- 读取授权范围外的路径:审批无法放行,需用户在「路径授权」中添加\n" "- 需要用户批准:terminal_input、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n" - "- 被拒绝或超时:本次操作不会执行写入" + "- 被拒绝或超时:本次操作不会执行写入\n" + "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)" ) + if docker_runtime: + detailed_rules += ( + "\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command(审批通过后以可写身份重跑);" + "终端里的写入会被系统拒绝,属预期边界" + ) elif mode == "auto_approval": detailed_rules = ( "- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n" - "- run_command:先在系统只读沙箱执行;仅当出现权限拒绝时才触发自动审批\n" + "- run_command:先在系统只读沙箱执行(可读范围同样受限);仅当出现权限拒绝时才触发自动审批\n" + "- 审批只授予工作区内写权限,不放大读取范围(读越界需用户在「路径授权」中添加)\n" "- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n" "- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n" - "- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准" + "- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准\n" + "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)" ) + 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 d1fc2b28..5e2497bc 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -238,6 +238,10 @@ class MainTerminalToolsExecutionMixin: "save_workflow", } + # 只读命令文本判定(启发式):用于 approval/auto_approval 的「是否需要审批」决策。 + # 2026-08-30 起不再是安全边界——docker 只读由非特权 uid 内核 DAC 强制 + # (modules/docker_readonly_exec.py),宿主机由 OS 沙箱强制。已知可绕过 + # (如 find . -delete),绕过启发式只会让命令多走一次审批,不会造成写入。 # 扩展的只读命令白名单(包含常用管道命令) _READONLY_ALLOWED_EXECUTABLES = { "grep", "find", "ls", "pwd", "tree", "cat", "head", "tail", diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index cdbbb328..abed41e0 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -88,6 +88,10 @@ from modules.i18n import tr logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} +# 受限权限档:只读 / 批准 / 自动审核。三档都依赖宿主机 OS 沙箱硬限制兜底 +# (批准后仍是沙箱可写+白名单读,direct 下启发式漏判的写命令无任何兜底), +# 执行环境统一锁定为沙箱;direct(完全访问)是无限制档的专属搭配。 +RESTRICTED_PERMISSION_MODES = {"readonly", "approval", "auto_approval"} # 运行模式(交互方式档):计划 / 询问 / 执行。 # 与权限模式(能力档)正交;plan 档会联动锁死权限为只读。 WORK_MODES = {"plan", "ask", "execute"} @@ -235,6 +239,15 @@ class MainTerminalToolsPolicyMixin: return "unrestricted" return mode + def docker_terminal_readonly_enabled(self) -> bool: + """docker 持久终端是否以只读身份(非特权 uid)创建。 + + readonly / approval / auto_approval 模式下为 True:新建的 docker + 终端会话与 run_command 只读执行同一身份(内核 DAC 强制只读,见 + modules/docker_readonly_exec.py);unrestricted 保持 root。 + """ + return self.get_permission_mode() != "unrestricted" + def get_work_mode(self) -> str: mode = str(getattr(self, "current_work_mode", None) or WORK_MODE_DEFAULT).strip().lower() if mode not in WORK_MODES: @@ -361,13 +374,15 @@ class MainTerminalToolsPolicyMixin: except AttributeError: pass previous = self.get_permission_mode() - entering_readonly = normalized == "readonly" and previous != "readonly" - leaving_readonly = previous == "readonly" and normalized != "readonly" + # 受限档集合边界判定:readonly⇄approval 互切不动执行环境; + # 仅 unrestricted⇄受限档 跨界时才强制沙箱 / 恢复进入前环境。 + entering_restricted = normalized in RESTRICTED_PERMISSION_MODES and previous not in RESTRICTED_PERMISSION_MODES + leaving_restricted = previous in RESTRICTED_PERMISSION_MODES and normalized not in RESTRICTED_PERMISSION_MODES self.current_permission_mode = normalized if not persist: - # 只读联动不依赖持久化:运行中 pending 切换路径(apply_pending_runtime_mode_changes) + # 受限档联动不依赖持久化:运行中 pending 切换路径(apply_pending_runtime_mode_changes) # 也要内存级强制沙箱,只是不落 metadata(无记录则切离时保持沙箱,安全默认)。 - self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=False) + self._apply_restricted_execution_mode_link(entering_restricted, leaving_restricted, persist=False) return normalized conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) @@ -380,16 +395,19 @@ class MainTerminalToolsPolicyMixin: self.context_manager.conversation_metadata["permission_mode"] = normalized except Exception: pass - self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=True) + self._apply_restricted_execution_mode_link(entering_restricted, leaving_restricted, persist=True) return normalized - def _apply_readonly_execution_mode_link(self, entering: bool, leaving: bool, *, persist: bool) -> None: - """只读权限 ⇄ 执行环境联动:进入 readonly 强制切沙箱,切离恢复进入前执行环境。 + def _apply_restricted_execution_mode_link(self, entering: bool, leaving: bool, *, persist: bool) -> None: + """受限权限档(只读/批准/自动审核)⇄ 执行环境联动:进入受限档强制切沙箱,切离恢复进入前执行环境。 - 与 plan ⇄ readonly+sandbox 双锁逻辑对称:只读权限在宿主机依赖 OS 沙箱硬限制, - direct(完全访问)下无沙箱,只读形同虚设,必须一并锁回沙箱。 - 进入 readonly 时若执行环境为 direct,先存 pre_readonly_execution_mode 供切离时恢复; - 切离 readonly 时仅当有明确进入前记录才恢复(无记录保持 sandbox,安全默认)。 + 与 plan ⇄ readonly+sandbox 双锁逻辑对称:受限档都依赖宿主机 OS 沙箱硬限制兜底 + (批准后重试仍是可写沙箱+白名单读),direct(完全访问)下无沙箱,审批承诺会被架空 + (启发式漏判的写命令直接执行成功、无 EPERM 触发审批),必须一并锁回沙箱。 + 进入受限档时若执行环境为 direct,先存 pre_readonly_execution_mode 供切离时恢复; + 切离受限档(到 unrestricted)时仅当有明确进入前记录才恢复(无记录保持 sandbox,安全默认)。 + 注意:metadata 键名保留 pre_readonly_execution_mode 不改(语义已泛化为受限档共用), + 存量对话的恢复记录不受影响。 """ if entering: try: diff --git a/core/web_terminal.py b/core/web_terminal.py index 63f1fa07..8ab2151c 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -136,6 +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 else: self.terminal_manager = TerminalManager( project_path=project_path, @@ -145,6 +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, ) # 让 run_command 与实时终端共享同一容器环境 self.terminal_ops.attach_terminal_manager(self.terminal_manager) @@ -197,7 +200,9 @@ class WebTerminal(MainTerminal): logger.warning("加载个性化偏好失败,将使用内置默认: %s", exc) preferred_model = prefs.get("default_model") preferred_mode = prefs.get("default_run_mode") - preferred_permission_mode = prefs.get("default_permission_mode") or None + # 权限模式不读取个性化默认值:沿用 terminal 当前值(与下方 work_mode 同一原则), + # /new 页面权限切换已同步到 terminal(_sync_workspace_terminal_mode), + # 个性化 default_permission_mode 仅在 terminal 首次构造时生效(tools_policy 加载)。 preferred_effort = prefs.get("default_reasoning_effort") if isinstance(preferred_effort, str): preferred_effort = preferred_effort.strip().lower() or None @@ -205,13 +210,6 @@ class WebTerminal(MainTerminal): preferred_effort = None else: preferred_effort = None - if preferred_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): - try: - preferred_permission_mode = self.get_permission_mode() - except Exception: - preferred_permission_mode = None - if not isinstance(preferred_permission_mode, str) or not preferred_permission_mode.strip(): - preferred_permission_mode = "unrestricted" candidate_mode = preferred_mode.lower() if isinstance(preferred_mode, str) else None if candidate_mode == "deep": # 旧版标识符映射 candidate_mode = "thinking" @@ -237,6 +235,8 @@ class WebTerminal(MainTerminal): current_work_mode = self.get_work_mode() except Exception: pass + # 权限模式(permission_mode):与 work_mode 同一原则,沿用 terminal 当前值, + # 不用个性化默认值覆盖(曾因此导致 /new 切只读后新建对话回落无限制)。 if current_work_mode == "plan": # 不变量:plan ⇒ 权限必须只读。记录进入前权限(供离开 plan 恢复)并锁定。 # 注意不能在 plan 状态下调用非只读的 set_permission_mode(plan 锁会 raise)。 @@ -250,14 +250,6 @@ class WebTerminal(MainTerminal): self.set_permission_mode("readonly", persist=False) except Exception: pass - else: - try: - self.set_permission_mode(preferred_permission_mode, persist=False) - except Exception: - try: - self.set_permission_mode("unrestricted", persist=False) - except Exception: - pass if isinstance(run_mode, str): try: @@ -491,6 +483,16 @@ class WebTerminal(MainTerminal): self.set_execution_mode("sandbox") except Exception: pass + # 自愈:受限权限档(只读/批准/自动审核)但执行环境是 direct 的 + # 存量/异常数据,强制回沙箱(批准后重试依赖沙箱可写+白名单读兜底, + # direct 下审批承诺被架空;与 set_execution_mode 的受限档锁互补, + # 上面 restore direct 被锁拦下后这里再保一层) + if self.get_permission_mode() in {"readonly", "approval", "auto_approval"} and hasattr(self, "get_execution_mode"): + try: + if self.get_execution_mode() != "sandbox": + self.set_execution_mode("sandbox") + except Exception: + pass network_permission = str(meta.get("network_permission") or "").strip().lower() if network_permission in {"restricted", "full", "none"}: try: diff --git a/docker/terminal.Dockerfile b/docker/terminal.Dockerfile index 6ed1f5f2..efbf6f8f 100644 --- a/docker/terminal.Dockerfile +++ b/docker/terminal.Dockerfile @@ -76,3 +76,18 @@ ENV AGENT_TOOLBOX_VENV=/opt/agent-venv ENV PATH="/opt/agent-venv/bin:${PATH}" ENV NODE_PATH="/usr/lib/node_modules" +# 5. 只读执行角色与安全加固(2026-08-30) +# - agent 用户(uid 10001,可用 --build-arg 调整)供只读权限模式的 +# docker exec/run --user 使用;容器主进程与可写执行仍为 root。 +# 内核 DAC 强制:工作区文件属主为宿主机 root,agent 非属主 → 物理只读; +# uid 需与 DOCKER_READONLY_EXEC_UID 环境变量(默认 10001)保持一致。 +# - /etc/gitconfig 烤入 safe.directory=*:修复非属主身份跑 git 时的 +# "detected dubious ownership" 报错(后端 exec 也会用 env 注入,双保险)。 +# - 移除全部 setuid 位:缩小只读身份在容器内的提权面 +# (su/passwd/newgrp 等在本镜像的使用场景下不需要;ping 用的是 +# file capabilities 而非 setuid,不受影响)。 +ARG AGENT_UID=10001 +RUN useradd --create-home --uid ${AGENT_UID} --shell /bin/bash agent && \ + printf '[safe]\n\tdirectory = *\n' > /etc/gitconfig && \ + find / -xdev -perm -4000 -type f -exec chmod u-s {} + 2>/dev/null || true + diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index 96832c9b..9cc6ffc4 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -19,6 +19,7 @@ from modules.host_sandbox_runner import ( build_host_sandbox_readonly_plan, host_sandbox_enabled, ) +from modules.docker_readonly_exec import docker_readonly_exec_args from modules.i18n import tr @@ -223,9 +224,11 @@ class BackgroundCommandManager: container_workdir = mount_path.rstrip("/") if relative: container_workdir = f"{container_workdir}/{relative}" - exec_cmd = [ - docker_bin, - "exec", + exec_cmd = [docker_bin, "exec"] + if not sandbox_write_access: + # 只读执行:非特权 uid(内核 DAC 强制只读,见 modules/docker_readonly_exec.py) + exec_cmd += docker_readonly_exec_args() + exec_cmd += [ "-e", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "-e", diff --git a/modules/docker_readonly_exec.py b/modules/docker_readonly_exec.py new file mode 100644 index 00000000..6e3af2ad --- /dev/null +++ b/modules/docker_readonly_exec.py @@ -0,0 +1,56 @@ +"""docker 容器内只读执行:非特权执行角色(uid/gid)。 + +设计(2026-08-30 云端实测验证,详见项目记忆 sandbox_readonly_reform_research): + +- 容器主进程与可写执行保持 root;sandbox_write_access=False 的执行通道 + (run_command 前台/后台、只读语境下创建的持久终端)统一改用非特权 uid + (默认 10001:10001,可用环境变量覆盖)。 +- 强制力来自内核 DAC:工作区 bind 挂载的文件属主是宿主机 root,非属主且 + 无 o+w → 写/删/chmod/umount 一律 EACCES/EPERM;600 权限的 .env 等敏感 + 文件天然不可读。此前的「命令文本特征识别」降级为审批决策的启发式, + 不再是 docker 只读的安全边界。 +- 不依赖镜像中存在该用户(数字 uid 直接生效),旧镜像直接受益; + docker/terminal.Dockerfile 同步创建 agent 用户与 git safe.directory, + 供未来构建的镜像使用。 +- 逃逸门槛:需要先提权(setuid 漏洞/内核漏洞),远高于嵌套 namespace + 方案的 umount 逃逸;云端默认 seccomp 会拦 unshare(CLONE_NEWUSER), + 嵌套方案已被本方案取代。 +- 已知边界:macOS Docker Desktop 的 virtiofs(fakeowner)不按 uid 执行 + 权限检查,本机制仅在 Linux 宿主机(云端/ Linux 桌面)生效,属预期差异; + 持久终端在 approval/auto_approval 模式下同为只读身份,写入命令请走 + run_command(审批通过后以 root 重跑)。 +""" +from __future__ import annotations + +import os +from typing import List, Tuple + + +def docker_readonly_uid_gid() -> Tuple[str, str]: + """只读执行身份的 uid/gid(默认 10001:10001,环境变量可覆盖)。 + + 选取原则:避开宿主机工作区文件属主 uid(否则会被 DAC 认成主人而放行), + 10001 为冷门值;如部署环境文件属主恰为该值,用环境变量改。 + """ + uid = os.environ.get("DOCKER_READONLY_EXEC_UID", "10001").strip() or "10001" + gid = os.environ.get("DOCKER_READONLY_EXEC_GID", uid).strip() or uid + return uid, gid + + +def docker_readonly_exec_args() -> List[str]: + """构造只读执行的 docker exec 前缀参数(紧跟在 "exec" 之后)。 + + 含: + - ``-u uid:gid``:非特权执行身份(内核 DAC 强制只读的核心); + - ``HOME=/tmp``:uid 在 /etc/passwd 无条目时提供可写 HOME; + - git safe.directory 修复:工作区属主是 root,非属主 git 会报 + "detected dubious ownership",用 env 方式注入,不依赖镜像内 gitconfig。 + """ + uid, gid = docker_readonly_uid_gid() + return [ + "-u", f"{uid}:{gid}", + "-e", "HOME=/tmp", + "-e", "GIT_CONFIG_COUNT=1", + "-e", "GIT_CONFIG_KEY_0=safe.directory", + "-e", "GIT_CONFIG_VALUE_0=*", + ] diff --git a/modules/file_manager/path_mixin.py b/modules/file_manager/path_mixin.py index 9b95ae4c..2e33c26b 100644 --- a/modules/file_manager/path_mixin.py +++ b/modules/file_manager/path_mixin.py @@ -1,6 +1,7 @@ # modules/file_manager.py - 文件管理模块(添加行编辑功能) import os +import platform import shutil from pathlib import Path import re @@ -35,7 +36,13 @@ except ImportError: # 兼容全局环境中存在同名包的情况 LINUX_SAFETY, ) from modules.container_file_proxy import ContainerFileProxy -from modules.host_sandbox_policy import get_macos_writable_paths, get_macos_readable_paths +from modules.host_sandbox_policy import ( + get_macos_writable_paths, + get_macos_readable_paths, + get_macos_deny_read_paths, + get_macos_deny_read_regexes, +) +from modules.host_sandbox_runner import MACOS_MINIMAL_READABLE_PATHS from utils.logger import setup_logger from modules.i18n import tr @@ -163,7 +170,14 @@ class PathMixin: else: temp_roots = [Path("/tmp").resolve(), Path("/private/tmp").resolve()] roots: List[Path] = [self.project_path.resolve(), *temp_roots] - raw_items = get_macos_writable_paths() if access == "write" else get_macos_readable_paths() + if access == "write": + raw_items = get_macos_writable_paths() + else: + raw_items = get_macos_readable_paths() + if platform.system() == "Darwin": + # 读 roots 与只读沙箱白名单同源:系统路径(/usr、/System 等) + # 在只读沙箱里可读,原生读工具应对齐(2026-08-30 白名单化) + raw_items = list(MACOS_MINIMAL_READABLE_PATHS) + list(raw_items) for raw in raw_items: try: p = Path(raw).expanduser().resolve() @@ -173,6 +187,31 @@ class PathMixin: roots.append(p) return roots + def _host_read_denied(self, resolved: Path) -> bool: + """macOS 禁读清单检查(与沙箱 deny 规则同源):命中敏感路径或正则(如 .env)则拒绝。 + + 原生读工具不走 sandbox-exec,需在进程内复刻同一份禁读语义, + 避免出现「run_command 读不到、read_file 读得到」的逃逸口。 + """ + target = str(resolved) + for raw in get_macos_deny_read_paths(): + try: + base = Path(raw).expanduser().resolve() + except Exception: + continue + try: + resolved.relative_to(base) + return True + except ValueError: + continue + for pattern in get_macos_deny_read_regexes(): + try: + if re.search(pattern, target): + return True + except re.error: + continue + return False + def _ensure_host_access(self, full_path: Path, access: str) -> Tuple[bool, str]: if not self._is_host_mode(): return True, "" @@ -185,7 +224,11 @@ class PathMixin: if access == "write" and not full_path.exists(): check_target = full_path.parent.resolve() allowed_roots = self._host_allowed_roots(access) - if self._path_in_allowed_roots(check_target.resolve(), allowed_roots): + resolved_target = check_target.resolve() + if self._path_in_allowed_roots(resolved_target, allowed_roots): + # 读访问还需过 macOS 禁读清单(工作区内 .env 等,与沙箱 deny 同源) + if access == "read" and platform.system() == "Darwin" and self._host_read_denied(resolved_target): + return False, tr("file_manager.host_access_read_denied") return True, "" if access == "write": return False, tr("file_manager.host_access_write_denied") diff --git a/modules/host_sandbox_policy.py b/modules/host_sandbox_policy.py index 2c808bd5..eb3fd197 100644 --- a/modules/host_sandbox_policy.py +++ b/modules/host_sandbox_policy.py @@ -123,6 +123,13 @@ def save_policy(policy: Dict) -> Dict: def get_macos_writable_paths() -> List[str]: + """沙箱可写路径全集 = 部署环境变量 + policy 文件(合并去重)。 + + 仅两个来源(2026-08-30 收敛):真·环境变量 + HOST_SANDBOX_MACOS_WRITABLE_PATHS(部署通道)+ host_sandbox_policy.json + 的 macos_writable_paths(前端「路径授权」UI)。settings.json 的 + terminal.macos_writable_paths 已不再是来源(config/__init__.py 映射已移除)。 + """ file_items = load_policy().get("macos_writable_paths", []) merged: List[str] = [] for raw in list(HOST_SANDBOX_MACOS_WRITABLE_PATHS or []) + list(file_items or []): diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index 0e938d4a..ba46a164 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Dict, List, Optional from modules.host_sandbox_policy import ( get_macos_writable_paths, + get_macos_readable_paths, get_macos_deny_read_paths, get_macos_deny_read_regexes, ) @@ -30,38 +31,30 @@ class HostSandboxError(RuntimeError): pass -# macOS 最小可读系统路径集合(Codex 风格 deny-default + allow-list)。 -# 当前仅作为常量保留,供后续需要严格 allow-list 的只读沙箱模式使用。 -# 当前只读沙箱采用“全局可读 + 敏感路径拒绝”模型,以保证工具兼容性。 +# macOS 只读沙箱的系统路径白名单(deny-default + allow-list,Codex 风格)。 +# 2026-08-30 起正式启用:只读沙箱 = 默认拒绝全部读取,仅本列表 + 路径授权 +# (macos_writable_paths / macos_readable_extra_paths)+ 工作区可读。 +# 列表经真机 PoC 校准:/System 含 dyld 共享缓存(进程启动必需)、 +# /Library/Developer/CommandLineTools 是 Apple git 等开发工具的真身、 +# /opt/homebrew 为 arm64 工具链(Intel 的 /usr/local 已由 /usr 覆盖)、 +# /private/var 覆盖 $TMPDIR(/var/folders/...)。 MACOS_MINIMAL_READABLE_PATHS = [ "/bin", "/sbin", - "/usr/bin", - "/usr/sbin", - "/usr/libexec", - "/usr/lib", - "/usr/share", - "/usr/local/lib", - "/opt/homebrew/lib", + "/usr", "/lib", + "/System", + "/Library/Apple", + "/Library/Developer/CommandLineTools", + "/Applications", "/etc", "/private/etc", + "/dev", "/tmp", "/private/tmp", - "/var/tmp", - "/private/var/tmp", "/var", "/private/var", - "/dev", - "/System/Library/Frameworks", - "/System/Library/PrivateFrameworks", - "/System/Library/SubFrameworks", - "/System/Library/CoreServices", - "/System/Library/Extensions", - "/Library/Apple", - "/Library/Preferences", - "/Library/Filesystems/NetFSPlugins", - "/Applications", + "/opt/homebrew", ] @@ -108,6 +101,50 @@ def _build_macos_deny_regex_rules(patterns: List[str]) -> str: return "\n".join(rules) +def _build_macos_whitelist_read_rules(paths: List[str]) -> str: + """白名单读规则:每个允许路径的 subpath allow + 其全部祖先目录的 literal allow。 + + 两个实测要点(2026-08-30 真机 PoC): + 1. Seatbelt 路径解析需要对每个祖先目录的读权限(file-read*),缺一个祖先 + 进程 exec 会直接 Abort trap: 6(file-read-metadata 不够,必须 file-read*)。 + 代价:祖先目录的顶层文件名可列出(读文件内容、列子目录仍被拒)。 + 2. 符号链接路径必须「原始形式 + 解析形式」双写:/etc→/private/etc 这类 + 链接,只写任一种都会 Operation not permitted(链接遍历与目标各查一次)。 + """ + literals: set[str] = set() + subpaths: List[str] = [] + seen: set[str] = set() + + def _add(path_str: str) -> None: + if not path_str or path_str in seen: + return + seen.add(path_str) + subpaths.append(path_str) + for ancestor in Path(path_str).parents: + ancestor_str = str(ancestor) + if ancestor_str and ancestor_str != ".": + literals.add(ancestor_str) + + for raw in paths: + if not raw: + continue + try: + expanded = str(Path(raw).expanduser()) + except Exception: + continue + _add(expanded) + resolved = _expand_path(raw) + if resolved: + _add(resolved) + + rules: List[str] = [] + for literal in sorted(literals): + rules.append(f'(allow file-read* (literal "{literal}"))') + for subpath in subpaths: + rules.append(f'(allow file-read* (subpath "{subpath}"))') + return "\n".join(rules) + + # 宿主机网络权限档位 NETWORK_PERMISSION_RESTRICTED = "restricted" # macOS: 仅本地回环;Linux/Windows: 暂不隔离 NETWORK_PERMISSION_FULL = "full" # 完全开放 @@ -203,8 +240,11 @@ def _build_macos_plan( if not sandbox_exec: raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec")) profile = _macos_profile_for_workspace(work_path, network_permission) + # 白名单读模型下 ~/.gitconfig 不可读会使 git 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=env, cwd=str(work_path)) + return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path)) def _build_macos_readonly_plan( @@ -219,8 +259,16 @@ def _build_macos_readonly_plan( network_policy = _build_macos_network_policy(network_permission) workspace = str(work_path.resolve()) - # 只读沙箱:全局可读 + 敏感路径/文件拒绝,写权限仅 /dev/null。 - # 工作区在 deny 规则之后再显式 allow,保证“工作区内不受沙箱影响”。 + # 只读沙箱(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)) # 原始形式(可能含符号链接) + readable_paths.append(workspace) # 解析形式 + allow_rules = _build_macos_whitelist_read_rules(readable_paths) deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths()) regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes()) if regex_rules: @@ -232,13 +280,16 @@ def _build_macos_readonly_plan( '(allow sysctl-read)\n' '(allow process*)\n' f'{network_policy}' - '(allow file-read*)\n' + f'{allow_rules}\n' + # deny 必须位于所有 allow 之后(后规则覆盖先规则) f'{deny_rules}\n' - f'(allow file-read* (subpath "{workspace}"))\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=env, cwd=str(work_path)) + return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path)) def _build_macos_shell_plan( @@ -250,8 +301,11 @@ def _build_macos_shell_plan( if not sandbox_exec: raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec_shell")) 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") cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-i"] - return SandboxPlan(command=cmd, env=env, cwd=str(work_path)) + return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path)) def _macos_profile_for_workspace( @@ -275,7 +329,16 @@ def _macos_profile_for_workspace( write_rules.append(f'(subpath "{entry}")') write_expr = " ".join(write_rules) network_policy = _build_macos_network_policy(network_permission) - workspace = str(work_path.resolve()) + # 可写沙箱(2026-08-30 起)与只读沙箱共用同一白名单读模型: + # 默认拒绝全部读取,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读。 + # 权限模式只管工作区内读写——unrestricted 也不例外;工作区外读取的唯一途径 + # 是「路径授权」。历史模型为「全局可读 + 黑名单」,导致无限制模式/审批批准后 + # 能读授权范围外文件(读放大),本次按方案一修复:审批不放大读取。 + readable_paths = list(MACOS_MINIMAL_READABLE_PATHS) + readable_paths.extend(get_macos_readable_paths()) + readable_paths.append(str(work_path)) # 原始形式(可能含符号链接) + readable_paths.append(workspace) # 解析形式 + allow_rules = _build_macos_whitelist_read_rules(readable_paths) deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths()) regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes()) if regex_rules: @@ -286,9 +349,10 @@ def _macos_profile_for_workspace( '(allow sysctl-read)\n' '(allow process*)\n' f'{network_policy}' - '(allow file-read*)\n' + f'{allow_rules}\n' + # deny 必须位于所有 allow 之后(Seatbelt 后规则覆盖先规则), + # 否则工作区内的 .env 会被 workspace allow 覆盖成可读(旧顺序漏洞) f'{deny_rules}\n' - f'(allow file-read* (subpath "{workspace}"))\n' f'(allow file-write* {write_expr})' ) diff --git a/modules/i18n_messages/chat_flow.py b/modules/i18n_messages/chat_flow.py index dd3c7a7a..f29bb407 100644 --- a/modules/i18n_messages/chat_flow.py +++ b/modules/i18n_messages/chat_flow.py @@ -85,12 +85,12 @@ MESSAGES = { "en-US": "Waiting for user approval", }, "tool_loop.awaiting_approval_retry": { - "zh-CN": "检测到写权限受限,等待用户审批后重试", - "en-US": "Write permission is restricted; waiting for user approval before retrying", + "zh-CN": "检测到权限受限,等待用户审批后重试(审批只授予工作区内写权限)", + "en-US": "Permission is restricted; waiting for user approval before retrying (approval grants workspace-scoped write only)", }, "tool_loop.awaiting_approval_retry_short": { - "zh-CN": "检测到写权限受限,等待用户审批", - "en-US": "Write permission restricted; waiting for user approval", + "zh-CN": "检测到权限受限,等待用户审批", + "en-US": "Permission restricted; waiting for user approval", }, "tool_loop.rejected_by_user": { "zh-CN": "操作被用户拒绝", @@ -100,6 +100,10 @@ MESSAGES = { "zh-CN": "审批超时,操作未执行", "en-US": "Approval timed out; the operation was not executed", }, + "tool_loop.retry_still_denied_read_scope": { + "zh-CN": "审批已授予本次工作区内写权限,但命令仍被沙箱拒绝:很可能是读取了授权范围(工作区 / 路径授权 / 系统基础路径)之外的路径。读越界不随审批放行,请引导用户在「路径授权」中添加所需路径后重试。", + "en-US": "Approval granted workspace-scoped write for this command only, but the sandbox still rejected it: it most likely tried to read paths outside the authorized scope (workspace / path authorization / base system paths). Read escalation is not granted by approval; ask the user to add the needed path via Path Authorization and retry.", + }, # ── ask_user ── "tool_loop.question_missing": { diff --git a/modules/i18n_messages/server_core_leftovers.py b/modules/i18n_messages/server_core_leftovers.py index 9843ac27..9a6f8347 100644 --- a/modules/i18n_messages/server_core_leftovers.py +++ b/modules/i18n_messages/server_core_leftovers.py @@ -107,9 +107,9 @@ MESSAGES = { "zh-CN": "计划模式下执行环境锁定为沙箱,请先切换运行模式", "en-US": "Execution environment is locked to sandbox in plan mode; switch work mode first", }, - "main_terminal.readonly_mode_locks_sandbox": { - "zh-CN": "只读模式下执行环境锁定为沙箱,请先切换权限模式", - "en-US": "Execution environment is locked to sandbox in readonly mode; switch permission mode first", + "main_terminal.restricted_mode_locks_sandbox": { + "zh-CN": "只读/批准/自动审核模式下执行环境锁定为沙箱,切换到无限制模式后可选完全访问", + "en-US": "Execution environment is locked to sandbox in readonly/approval/auto-approval mode; switch to unrestricted for direct access", }, # ── core/main_terminal_parts/tools_policy.py ── diff --git a/modules/persistent_terminal/start.py b/modules/persistent_terminal/start.py index 7e8e9492..55e51863 100644 --- a/modules/persistent_terminal/start.py +++ b/modules/persistent_terminal/start.py @@ -62,6 +62,7 @@ except ImportError: TERMINAL_SANDBOX_REQUIRE, ) +from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid from modules.i18n import tr @@ -262,6 +263,9 @@ class StartMixin: "exec", "-i", ] + if self.sandbox_options.get("docker_readonly_exec"): + # 只读身份会话:与 run_command 只读执行同一非特权 uid(内核 DAC 强制) + cmd += docker_readonly_exec_args() if container_workdir: cmd += ["-w", container_workdir] @@ -324,6 +328,14 @@ class StartMixin: f"{working_dir}:{mount_path}", ] + if self.sandbox_options.get("docker_readonly_exec"): + # 只读身份会话(新建容器):docker run 用 --user 指定非特权 uid + ro_uid, ro_gid = docker_readonly_uid_gid() + cmd += ["--user", f"{ro_uid}:{ro_gid}"] + for ro_env in ("HOME=/tmp", "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.directory", "GIT_CONFIG_VALUE_0=*"): + cmd += ["-e", ro_env] + network = self.sandbox_options.get("network") if network: cmd += ["--network", network] diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index ef70e7e3..8c39b103 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -77,6 +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, ): """ 初始化终端管理器 @@ -128,6 +129,10 @@ 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 # 终端命令超时工具(兼容 macOS 无 coreutils 的情况) self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout") @@ -158,6 +163,8 @@ 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()) 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 diff --git a/modules/terminal_ops/run.py b/modules/terminal_ops/run.py index da9f103b..e52ef9bb 100644 --- a/modules/terminal_ops/run.py +++ b/modules/terminal_ops/run.py @@ -40,6 +40,7 @@ from modules.host_sandbox_runner import ( build_host_sandbox_readonly_plan, host_sandbox_enabled, ) +from modules.docker_readonly_exec import docker_readonly_exec_args from modules.i18n import tr if TYPE_CHECKING: @@ -185,9 +186,11 @@ class RunMixin: container_workdir = mount_path.rstrip("/") if relative: container_workdir = f"{container_workdir}/{relative}" - exec_cmd = [ - docker_bin, - "exec", + exec_cmd = [docker_bin, "exec"] + if not sandbox_write_access: + # 只读执行:非特权 uid(内核 DAC 强制只读,见 modules/docker_readonly_exec.py) + exec_cmd += docker_readonly_exec_args() + exec_cmd += [ "-e", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "-e", diff --git a/modules/user_container_manager.py b/modules/user_container_manager.py index a26e0d40..61bc4234 100644 --- a/modules/user_container_manager.py +++ b/modules/user_container_manager.py @@ -301,6 +301,10 @@ class UserContainerManager: container_name = self._build_container_name(username) self._kill_container(container_name, docker_path) + # 容器主进程刻意保持 root:可写执行(docker exec 不带 -u)依赖它; + # 只读权限模式的执行通道在 exec 时以非特权 uid 运行(内核 DAC 强制只读), + # 见 modules/docker_readonly_exec.py。工作区 bind 挂载是唯一可写资产, + # 切勿给容器挂载 docker.sock 或追加特权/capability(会直接击穿该模型)。 cmd = [ docker_path, "run", diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index de76ff23..03983036 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -175,6 +175,35 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool: return any(marker in joined for marker in markers) +def _is_file_permission_denied_result(result_data: Dict[str, Any]) -> bool: + """是否为文件读写权限拒绝(区别于网络受限报错)。 + + 用于审批批准后重试仍失败的场景:方案一(2026-08-30)起可写沙箱读边界 + 与只读同为白名单,批准不再放大读取;此时给模型追加「路径授权」引导。 + """ + if not isinstance(result_data, dict): + return False + if result_data.get("success") is True: + return False + fragments: List[str] = [] + for key in ("error", "message", "output"): + value = result_data.get(key) + if isinstance(value, str) and value.strip(): + fragments.append(value.lower()) + joined = "\n".join(fragments) + if not joined: + return False + markers = ( + "operation not permitted", + "permission denied", + "access denied", + "权限不足", + "无权限", + "不允许", + ) + return any(marker in joined for marker in markers) + + def _inject_runtime_mode_notice( *, web_terminal, @@ -1186,6 +1215,13 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages except Exception: result_data = {"output": retry_tool_result} tool_result = retry_tool_result + # 方案一(2026-08-30):批准只授予「本次工作区内写权限」,可写沙箱读边界 + # 与只读同为白名单(不再全局可读)。重试后仍为文件权限拒绝 ⇒ 大概率是 + # 读取了授权范围外的路径——提示模型引导用户走「路径授权」,审批不放大读取。 + if _is_file_permission_denied_result(result_data): + if isinstance(result_data, dict): + result_data["read_scope_hint"] = tr("tool_loop.retry_still_denied_read_scope") + tool_result = json.dumps(result_data, ensure_ascii=False) tool_failed = detect_tool_failure(result_data) diff --git a/server/conversation.py b/server/conversation.py index 2b131f34..8a8e3f9e 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -702,15 +702,17 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam safe_run_mode = candidate if candidate in {"fast", "thinking"} else "fast" safe_thinking = bool(thinking_mode) if thinking_mode is not None else safe_run_mode != "fast" previous_cm_current = getattr(cm, "current_conversation_id", None) - default_permission_mode = (prefs or {}).get("default_permission_mode") - if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): - default_permission_mode = None # 运行模式(work_mode):沿用 terminal 当前值(与正常创建路径一致); # plan 档不变量 ⇒ 权限必须只读,同时记录进入前权限供离开 plan 恢复 safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")() if safe_work_mode not in ("plan", "ask", "execute"): safe_work_mode = "plan" - safe_permission_mode = default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")() + # 权限模式同样沿用 terminal 当前值(/new 切换已同步到 terminal,见 + # _sync_workspace_terminal_mode);个性化 default_permission_mode 仅在 + # terminal 首次构造时生效,不能在此覆盖用户切换结果 + safe_permission_mode = getattr(terminal, "get_permission_mode", lambda: "unrestricted")() + if safe_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): + safe_permission_mode = "unrestricted" safe_pre_plan_permission = None if safe_work_mode == "plan": if safe_permission_mode != "readonly": diff --git a/server/multi_agent.py b/server/multi_agent.py index 6a403b57..30bd7cd5 100644 --- a/server/multi_agent.py +++ b/server/multi_agent.py @@ -351,16 +351,18 @@ def create_multi_agent_conversation(): candidate = "thinking" safe_run_mode = candidate if candidate in {"fast", "thinking"} else "fast" safe_thinking = bool(thinking_mode) if thinking_mode is not None else safe_run_mode != "fast" - default_permission_mode = (prefs or {}).get("default_permission_mode") - if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): - default_permission_mode = None # 运行模式(work_mode):沿用 terminal 当前值(与正常创建路径一致, # /new 页面切换后新对话继承);plan 档不变量 ⇒ 权限必须只读, # 同时记录进入前权限供离开 plan 恢复 safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")() if safe_work_mode not in ("plan", "ask", "execute"): safe_work_mode = "plan" - safe_permission_mode = default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")() + # 权限模式同样沿用 terminal 当前值(/new 切换已同步到 terminal,见 + # _sync_workspace_terminal_mode);个性化 default_permission_mode 仅在 + # terminal 首次构造时生效,不能在此覆盖用户切换结果 + safe_permission_mode = getattr(terminal, "get_permission_mode", lambda: "unrestricted")() + if safe_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): + safe_permission_mode = "unrestricted" safe_pre_plan_permission = None if safe_work_mode == "plan": if safe_permission_mode != "readonly": diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index e41fad97..5ffbcc93 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -504,11 +504,11 @@ type="button" class="permission-switcher__btn" :disabled="!isConnected" - :title="permissionLockedByPlan ? $t('input.permissionLockedByPlanTitle') : (executionLockedByReadonly ? $t('input.executionLockedReadonlyTitle') : '')" + :title="permissionLockedByPlan ? $t('input.permissionLockedByPlanTitle') : (executionLockedByRestricted ? $t('input.executionLockedRestrictedTitle') : '')" @click="$emit('toggle-permission-menu')" > {{ $t('input.executionEnv') }} {{ $t('input.planModeLocked') }} - {{ $t('input.readonlyModeLocked') }} + {{ $t('input.restrictedModeLocked') }}