feat(security): 沙箱只读真强制与权限模式边界收敛

- docker:只读/审批档 run_command、后台命令与持久终端改用非特权 uid(10001) 执行角色,内核 DAC 强制只读,取代文本特征识别;Dockerfile 加固(agent 用户 / git safe.directory / 去 setuid)
- macOS:只读与可写沙箱 profile 统一为白名单读模型(deny default + 系统目录/工作区/路径授权),修复 deny 顺序导致的工作区 .env 实际可读漏洞;可写 profile 白名单化后审批不再放大读取,越界读取唯一途径为路径授权
- 权限模式:受限档(readonly/approval/auto_approval)与 direct 执行环境硬互斥——进入受限档压回沙箱并记录,切回 unrestricted 恢复,存量受限+direct 对话加载自愈矫正
- 配置:路径授权来源收敛为 host_sandbox_policy.json + 环境变量两个通道(移除 settings.json 映射)
- 修复:新建对话权限模式被个性化默认值覆盖、/new 切只读后回落无限制的继承 bug
- 原生文件工具读边界与沙箱白名单同源对齐
This commit is contained in:
JOJO 2026-08-30 22:07:14 +08:00
parent f51a46bf9b
commit 8257e4d34d
29 changed files with 472 additions and 122 deletions

View File

@ -331,22 +331,28 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- `readonly` - `readonly`
- `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝 - `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝
- 读取同样受限(各平台只读强制的可读边界见 §10.8);读不到属预期边界,不是故障
- `write_file` / `edit_file` 等写入类工具直接拒绝 - `write_file` / `edit_file` 等写入类工具直接拒绝
- `approval` - `approval`
- `run_command` 先走只读沙箱 - `run_command` 先走只读沙箱(可读边界见 §10.8,读越界也会触发权限拒绝)
- 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批 - 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批
- 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果” - 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果”
- **审批只授予该次命令的工作区内写权限,不放大读取**2026-08-30 方案一):可写沙箱读边界与只读同一白名单,读越界唯一途径是「路径授权」;重试后仍为文件权限拒绝时,工具结果附带 `read_scope_hint` 引导模型说明
- **执行环境锁定为沙箱**2026-08-30 起direct 下无沙箱兜底,启发式漏判的写命令会直接执行成功、审批承诺被架空,故批准/自动审核与 direct 硬互斥(锁定机制同 plan/只读,见 §10.6
- `auto_approval` - `auto_approval`
- `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程 - `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程
- `run_command` 先走只读沙箱;触发权限拒绝后由后台审批智能体自动审核 - `run_command` 先走只读沙箱(可读边界见 §10.8;触发权限拒绝后由后台审批智能体自动审核
- 自动审核拒绝时,工具返回“被拒绝+理由”并继续主循环(不强制结束任务) - 自动审核拒绝时,工具返回“被拒绝+理由”并继续主循环(不强制结束任务)
- 批准后的读取边界与 approval 相同(不放大)
- 执行环境同样锁定为沙箱(同 approval 条目)
- `unrestricted` - `unrestricted`
- 不做权限模式拦截;是否沙箱由执行环境决定 - 权限层不做拦截:工作区内读写自由、命令免批准;是否沙箱由执行环境决定
- 注意「无限制」只管工作区内:宿主机沙箱执行时读边界仍是白名单(见 §10.8授权范围外读取需路径授权direct 模式才真正无边界
### 10.2 执行环境 ### 10.2 执行环境
- `sandbox`(默认):使用 OS 沙箱执行macOS: `sandbox-exec`Linux: `bwrap + seccomp`Windows: `WSL2` - `sandbox`(默认):使用 OS 沙箱执行macOS: `sandbox-exec`Linux: `bwrap + seccomp`Windows: `WSL2`
- `direct`:宿主机直接执行(高风险) - `direct`:宿主机直接执行(高风险)**仅 `unrestricted` 权限可选**:受限权限档(只读/批准/自动审核)与 plan 模式下后端 `set_execution_mode` 硬锁拒绝 directi18n 键 `main_terminal.restricted_mode_locks_sandbox` / `plan_mode_locks_sandbox`);进入受限档时若已是 direct 会被联动压回沙箱(记录 `pre_readonly_execution_mode`,切回 unrestricted 时恢复);存量「受限档+direct」对话在加载时自愈矫正回沙箱
- 切换后一直生效无自动回退机制2026-07 已移除原 TTL 自动回退) - 切换后一直生效无自动回退机制2026-07 已移除原 TTL 自动回退)
### 10.3 路径授权语义 ### 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 维护约束 ### 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 - **后端核心**`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 继承**三条创建路径prefer_defaults / 显式模式 / safe_navigation一律沿用 terminal 当前值,**不用个性化默认值覆盖**——/new 页面切换器显示什么新对话就是什么;个性化 `default_work_mode` 仅在 terminal 首次构造时生效。plan 锁存在于 `set_permission_mode`,任何创建路径不得在其之前调用非只读 set曾因此 500 - **创建对话的模式继承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` 第四种),通知携带完整新规则文本 - **提示词**`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` - **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 通知) - **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 ### 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 Desktopvirtiofs 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 ## 11) 多智能体对话类型multi-agent conversation type

View File

@ -59,10 +59,12 @@ def _load_dotenv():
os.environ[k] = str(v) os.environ[k] = str(v)
# 映射 settings.json 字段 → 旧版环境变量名(兼容现有 config/*.py 模块) # 映射 settings.json 字段 → 旧版环境变量名(兼容现有 config/*.py 模块)
# 注意terminal.macos_writable_paths 已于 2026-08-30 移除——
# 沙箱路径授权只读两个来源config/host_sandbox_policy.json前端
# 「路径授权」UI与真·环境变量settings.json 中的历史值一次性失效。
_LEGACY_MAP = { _LEGACY_MAP = {
"terminal.sandbox_mode": "TERMINAL_SANDBOX_MODE", "terminal.sandbox_mode": "TERMINAL_SANDBOX_MODE",
"terminal.execution_mode_default": "HOST_EXECUTION_MODE_DEFAULT", "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.max_active_containers": "MAX_ACTIVE_USER_CONTAINERS",
"terminal.project_max_storage_mb": "PROJECT_MAX_STORAGE_MB", "terminal.project_max_storage_mb": "PROJECT_MAX_STORAGE_MB",
"server.port": "WEB_SERVER_PORT", "server.port": "WEB_SERVER_PORT",

View File

@ -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_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048"))
PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024 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 = ( READONLY_RUN_COMMAND_ALLOWED = (
"grep", "grep",
"find", "find",

View File

@ -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")) 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")) 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() 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( HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "") os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
) )

View File

@ -132,6 +132,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
broadcast_callback=None, # CLI模式不需要广播 broadcast_callback=None, # CLI模式不需要广播
container_session=container_session, container_session=container_session,
network_permission_getter=self.get_network_permission, network_permission_getter=self.get_network_permission,
docker_readonly_getter=self.docker_terminal_readonly_enabled,
) )
# 让 run_command 复用终端容器,保持环境一致 # 让 run_command 复用终端容器,保持环境一致
self.terminal_ops.attach_terminal_manager(self.terminal_manager) 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_foreground_enabled: bool = False
self.skill_strict_run_command_background_enabled: bool = False self.skill_strict_run_command_background_enabled: bool = False
self.default_permission_mode: str = "unrestricted" 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_execution_mode: str = "sandbox"
self.host_network_permission: str = "restricted" self.host_network_permission: str = "restricted"
self.pending_permission_mode: Optional[str] = None self.pending_permission_mode: Optional[str] = None
@ -352,7 +356,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.set_execution_mode(pending_execution) self.set_execution_mode(pending_execution)
applied = True applied = True
except Exception: except Exception:
# 锁定拒绝plan / readonly 下禁止 direct丢弃该 pending保持沙箱 # 锁定拒绝plan / 受限权限档下禁止 direct丢弃该 pending保持沙箱
applied = False applied = False
if applied: if applied:
if hasattr(self, "build_runtime_mode_switch_notice"): 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")) raise ValueError(tr("main_terminal.plan_mode_locks_sandbox"))
except AttributeError: except AttributeError:
pass pass
# 只读模式下执行环境同样锁死为沙箱(与 plan 锁并列): # 受限权限档(只读/批准/自动审核)执行环境同样锁死为沙箱(与 plan 锁并列):
# 切到 readonly 权限时由 set_permission_mode 联动强制沙箱, # 切到受限档时由 set_permission_mode 联动强制沙箱这里拦直接调用API/队列)
# 这里拦直接调用API/队列)防止只读期间切回 direct 绕过 OS 沙箱硬限制。 # 防止受限档期间切回 direct——direct 下无沙箱,只读形同虚设、审批承诺被架空
# (启发式漏判的写命令无 EPERM 兜底)。仅 unrestricted 可选 direct。
if normalized == "direct" and getattr(self, "get_permission_mode", None): if normalized == "direct" and getattr(self, "get_permission_mode", None):
try: try:
if self.get_permission_mode() == "readonly": if self.get_permission_mode() != "unrestricted":
raise ValueError(tr("main_terminal.readonly_mode_locks_sandbox")) raise ValueError(tr("main_terminal.restricted_mode_locks_sandbox"))
except AttributeError: except AttributeError:
pass pass
self.host_execution_mode = normalized self.host_execution_mode = normalized
@ -679,7 +684,9 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
thinking_mode=self.thinking_mode, thinking_mode=self.thinking_mode,
run_mode=self.run_mode, run_mode=self.run_mode,
metadata_overrides={ 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(), "execution_mode": self.get_execution_mode(),
"pending_permission_mode": None, "pending_permission_mode": None,
"pending_execution_mode": None, "pending_execution_mode": None,

View File

@ -150,6 +150,10 @@ class ModeMixin:
mode = self.get_permission_mode() 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": if mode == "unrestricted":
detailed_rules = ( detailed_rules = (
@ -157,27 +161,47 @@ class ModeMixin:
"- run_command支持任意命令包括管道、重定向、子shell等\n" "- run_command支持任意命令包括管道、重定向、子shell等\n"
"- 文件操作read_file / write_file / edit_file 可直接使用;其他文件管理请通过 run_command 执行;需要 Python 时先探测并选择合适解释器" "- 文件操作read_file / write_file / edit_file 可直接使用;其他文件管理请通过 run_command 执行;需要 Python 时先探测并选择合适解释器"
) )
if not docker_runtime:
# 权限模式只管工作区内读写;宿主机沙箱的读边界与只读模式同一白名单
detailed_rules += (
"\n- 可读范围:宿主机沙箱执行时同样受限(仅系统目录/工作区/路径授权可读);"
"读取授权范围外的文件会被系统拒绝,需用户在「路径授权」中添加,属预期边界,不要尝试绕过"
)
elif mode == "readonly": elif mode == "readonly":
detailed_rules = ( detailed_rules = (
"- run_command统一在系统只读沙箱中执行\n" "- run_command统一在系统只读沙箱中执行,写入会被系统直接拒绝\n"
"- 若命令触发写入,系统会直接拒绝并返回权限错误\n" "- 可读范围同样受限(宿主机 macOS仅系统目录/工作区/路径授权可读docker受文件属主权限限制600 等属主保护文件不可读);读不到属预期边界,不要尝试绕过\n"
"- 文件操作:仅允许 read_file、view_image、view_video 等读取类工具\n" "- 文件操作:仅允许 read_file、view_image、view_video 等读取类工具\n"
"- 禁止write_file、edit_file 及会修改工作区的命令操作" "- 禁止write_file、edit_file 及会修改工作区的命令操作"
) )
elif mode == "approval": elif mode == "approval":
detailed_rules = ( detailed_rules = (
"- run_command先在系统只读沙箱执行仅当出现权限拒绝时才触发审批\n" "- run_command先在系统只读沙箱执行可读范围同样受限仅当出现权限拒绝时才触发审批\n"
"- 审批通过后:仅当前这一次命令会重试为可写沙箱执行\n" "- 审批通过后:仅当前这一次命令会以可写沙箱重试;审批只授予工作区内写权限,不放大读取范围\n"
"- 读取授权范围外的路径:审批无法放行,需用户在「路径授权」中添加\n"
"- 需要用户批准terminal_input、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n" "- 需要用户批准terminal_input、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n"
"- 被拒绝或超时:本次操作不会执行写入" "- 被拒绝或超时:本次操作不会执行写入\n"
"- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)"
)
if docker_runtime:
detailed_rules += (
"\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command审批通过后以可写身份重跑"
"终端里的写入会被系统拒绝,属预期边界"
) )
elif mode == "auto_approval": elif mode == "auto_approval":
detailed_rules = ( detailed_rules = (
"- write_file / edit_file若路径在当前工作区内直接执行工作区外路径会进入审批流程\n" "- write_file / edit_file若路径在当前工作区内直接执行工作区外路径会进入审批流程\n"
"- run_command先在系统只读沙箱执行仅当出现权限拒绝时才触发自动审批\n" "- run_command先在系统只读沙箱执行可读范围同样受限仅当出现权限拒绝时才触发自动审批\n"
"- 审批只授予工作区内写权限,不放大读取范围(读越界需用户在「路径授权」中添加)\n"
"- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n" "- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n"
"- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n" "- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n"
"- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准" "- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准\n"
"- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)"
)
if docker_runtime:
detailed_rules += (
"\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command审批通过后以可写身份重跑"
"终端里的写入会被系统拒绝,属预期边界"
) )
else: else:
detailed_rules = "" detailed_rules = ""

View File

@ -238,6 +238,10 @@ class MainTerminalToolsExecutionMixin:
"save_workflow", "save_workflow",
} }
# 只读命令文本判定(启发式):用于 approval/auto_approval 的「是否需要审批」决策。
# 2026-08-30 起不再是安全边界——docker 只读由非特权 uid 内核 DAC 强制
# modules/docker_readonly_exec.py宿主机由 OS 沙箱强制。已知可绕过
# (如 find . -delete绕过启发式只会让命令多走一次审批不会造成写入。
# 扩展的只读命令白名单(包含常用管道命令) # 扩展的只读命令白名单(包含常用管道命令)
_READONLY_ALLOWED_EXECUTABLES = { _READONLY_ALLOWED_EXECUTABLES = {
"grep", "find", "ls", "pwd", "tree", "cat", "head", "tail", "grep", "find", "ls", "pwd", "tree", "cat", "head", "tail",

View File

@ -88,6 +88,10 @@ from modules.i18n import tr
logger = setup_logger(__name__) logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True DISABLE_LENGTH_CHECK = True
PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
# 受限权限档:只读 / 批准 / 自动审核。三档都依赖宿主机 OS 沙箱硬限制兜底
# (批准后仍是沙箱可写+白名单读direct 下启发式漏判的写命令无任何兜底),
# 执行环境统一锁定为沙箱direct完全访问是无限制档的专属搭配。
RESTRICTED_PERMISSION_MODES = {"readonly", "approval", "auto_approval"}
# 运行模式(交互方式档):计划 / 询问 / 执行。 # 运行模式(交互方式档):计划 / 询问 / 执行。
# 与权限模式能力档正交plan 档会联动锁死权限为只读。 # 与权限模式能力档正交plan 档会联动锁死权限为只读。
WORK_MODES = {"plan", "ask", "execute"} WORK_MODES = {"plan", "ask", "execute"}
@ -235,6 +239,15 @@ class MainTerminalToolsPolicyMixin:
return "unrestricted" return "unrestricted"
return mode return mode
def docker_terminal_readonly_enabled(self) -> bool:
"""docker 持久终端是否以只读身份(非特权 uid创建。
readonly / approval / auto_approval 模式下为 True新建的 docker
终端会话与 run_command 只读执行同一身份内核 DAC 强制只读
modules/docker_readonly_exec.pyunrestricted 保持 root
"""
return self.get_permission_mode() != "unrestricted"
def get_work_mode(self) -> str: def get_work_mode(self) -> str:
mode = str(getattr(self, "current_work_mode", None) or WORK_MODE_DEFAULT).strip().lower() mode = str(getattr(self, "current_work_mode", None) or WORK_MODE_DEFAULT).strip().lower()
if mode not in WORK_MODES: if mode not in WORK_MODES:
@ -361,13 +374,15 @@ class MainTerminalToolsPolicyMixin:
except AttributeError: except AttributeError:
pass pass
previous = self.get_permission_mode() previous = self.get_permission_mode()
entering_readonly = normalized == "readonly" and previous != "readonly" # 受限档集合边界判定readonly⇄approval 互切不动执行环境;
leaving_readonly = previous == "readonly" and normalized != "readonly" # 仅 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 self.current_permission_mode = normalized
if not persist: if not persist:
# 只读联动不依赖持久化:运行中 pending 切换路径apply_pending_runtime_mode_changes # 受限档联动不依赖持久化:运行中 pending 切换路径apply_pending_runtime_mode_changes
# 也要内存级强制沙箱,只是不落 metadata无记录则切离时保持沙箱安全默认 # 也要内存级强制沙箱,只是不落 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 return normalized
conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) 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 self.context_manager.conversation_metadata["permission_mode"] = normalized
except Exception: except Exception:
pass 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 return normalized
def _apply_readonly_execution_mode_link(self, entering: bool, leaving: bool, *, persist: bool) -> None: def _apply_restricted_execution_mode_link(self, entering: bool, leaving: bool, *, persist: bool) -> None:
"""只读权限 ⇄ 执行环境联动:进入 readonly 强制切沙箱,切离恢复进入前执行环境。 """受限权限档(只读/批准/自动审核)⇄ 执行环境联动:进入受限档强制切沙箱,切离恢复进入前执行环境。
plan readonly+sandbox 双锁逻辑对称只读权限在宿主机依赖 OS 沙箱硬限制 plan readonly+sandbox 双锁逻辑对称受限档都依赖宿主机 OS 沙箱硬限制兜底
direct完全访问下无沙箱只读形同虚设必须一并锁回沙箱 批准后重试仍是可写沙箱+白名单读direct完全访问下无沙箱审批承诺会被架空
进入 readonly 时若执行环境为 direct先存 pre_readonly_execution_mode 供切离时恢复 启发式漏判的写命令直接执行成功 EPERM 触发审批必须一并锁回沙箱
切离 readonly 时仅当有明确进入前记录才恢复无记录保持 sandbox安全默认 进入受限档时若执行环境为 direct先存 pre_readonly_execution_mode 供切离时恢复
切离受限档 unrestricted时仅当有明确进入前记录才恢复无记录保持 sandbox安全默认
注意metadata 键名保留 pre_readonly_execution_mode 不改语义已泛化为受限档共用
存量对话的恢复记录不受影响
""" """
if entering: if entering:
try: try:

View File

@ -136,6 +136,8 @@ class WebTerminal(MainTerminal):
# 复用父类实例时也要确保 getter 已注入(父类创建时已传入,防御旧实例) # 复用父类实例时也要确保 getter 已注入(父类创建时已传入,防御旧实例)
if getattr(self.terminal_manager, "network_permission_getter", None) is None: if getattr(self.terminal_manager, "network_permission_getter", None) is None:
self.terminal_manager.network_permission_getter = self.get_network_permission 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: else:
self.terminal_manager = TerminalManager( self.terminal_manager = TerminalManager(
project_path=project_path, project_path=project_path,
@ -145,6 +147,7 @@ class WebTerminal(MainTerminal):
broadcast_callback=message_callback, broadcast_callback=message_callback,
container_session=self.container_session, container_session=self.container_session,
network_permission_getter=self.get_network_permission, network_permission_getter=self.get_network_permission,
docker_readonly_getter=self.docker_terminal_readonly_enabled,
) )
# 让 run_command 与实时终端共享同一容器环境 # 让 run_command 与实时终端共享同一容器环境
self.terminal_ops.attach_terminal_manager(self.terminal_manager) self.terminal_ops.attach_terminal_manager(self.terminal_manager)
@ -197,7 +200,9 @@ class WebTerminal(MainTerminal):
logger.warning("加载个性化偏好失败,将使用内置默认: %s", exc) logger.warning("加载个性化偏好失败,将使用内置默认: %s", exc)
preferred_model = prefs.get("default_model") preferred_model = prefs.get("default_model")
preferred_mode = prefs.get("default_run_mode") 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") preferred_effort = prefs.get("default_reasoning_effort")
if isinstance(preferred_effort, str): if isinstance(preferred_effort, str):
preferred_effort = preferred_effort.strip().lower() or None preferred_effort = preferred_effort.strip().lower() or None
@ -205,13 +210,6 @@ class WebTerminal(MainTerminal):
preferred_effort = None preferred_effort = None
else: else:
preferred_effort = None 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 candidate_mode = preferred_mode.lower() if isinstance(preferred_mode, str) else None
if candidate_mode == "deep": # 旧版标识符映射 if candidate_mode == "deep": # 旧版标识符映射
candidate_mode = "thinking" candidate_mode = "thinking"
@ -237,6 +235,8 @@ class WebTerminal(MainTerminal):
current_work_mode = self.get_work_mode() current_work_mode = self.get_work_mode()
except Exception: except Exception:
pass pass
# 权限模式permission_mode与 work_mode 同一原则,沿用 terminal 当前值,
# 不用个性化默认值覆盖(曾因此导致 /new 切只读后新建对话回落无限制)。
if current_work_mode == "plan": if current_work_mode == "plan":
# 不变量plan ⇒ 权限必须只读。记录进入前权限(供离开 plan 恢复)并锁定。 # 不变量plan ⇒ 权限必须只读。记录进入前权限(供离开 plan 恢复)并锁定。
# 注意不能在 plan 状态下调用非只读的 set_permission_modeplan 锁会 raise # 注意不能在 plan 状态下调用非只读的 set_permission_modeplan 锁会 raise
@ -250,14 +250,6 @@ class WebTerminal(MainTerminal):
self.set_permission_mode("readonly", persist=False) self.set_permission_mode("readonly", persist=False)
except Exception: except Exception:
pass 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): if isinstance(run_mode, str):
try: try:
@ -491,6 +483,16 @@ class WebTerminal(MainTerminal):
self.set_execution_mode("sandbox") self.set_execution_mode("sandbox")
except Exception: except Exception:
pass 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() network_permission = str(meta.get("network_permission") or "").strip().lower()
if network_permission in {"restricted", "full", "none"}: if network_permission in {"restricted", "full", "none"}:
try: try:

View File

@ -76,3 +76,18 @@ ENV AGENT_TOOLBOX_VENV=/opt/agent-venv
ENV PATH="/opt/agent-venv/bin:${PATH}" ENV PATH="/opt/agent-venv/bin:${PATH}"
ENV NODE_PATH="/usr/lib/node_modules" ENV NODE_PATH="/usr/lib/node_modules"
# 5. 只读执行角色与安全加固2026-08-30
# - agent 用户uid 10001可用 --build-arg 调整)供只读权限模式的
# docker exec/run --user 使用;容器主进程与可写执行仍为 root。
# 内核 DAC 强制:工作区文件属主为宿主机 rootagent 非属主 → 物理只读;
# 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

View File

@ -19,6 +19,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args
from modules.i18n import tr from modules.i18n import tr
@ -223,9 +224,11 @@ class BackgroundCommandManager:
container_workdir = mount_path.rstrip("/") container_workdir = mount_path.rstrip("/")
if relative: if relative:
container_workdir = f"{container_workdir}/{relative}" container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [ exec_cmd = [docker_bin, "exec"]
docker_bin, if not sandbox_write_access:
"exec", # 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args()
exec_cmd += [
"-e", "-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"-e", "-e",

View File

@ -0,0 +1,56 @@
"""docker 容器内只读执行非特权执行角色uid/gid
设计2026-08-30 云端实测验证详见项目记忆 sandbox_readonly_reform_research
- 容器主进程与可写执行保持 rootsandbox_write_access=False 的执行通道
run_command 前台/后台只读语境下创建的持久终端统一改用非特权 uid
默认 10001:10001可用环境变量覆盖
- 强制力来自内核 DAC工作区 bind 挂载的文件属主是宿主机 root非属主且
o+w //chmod/umount 一律 EACCES/EPERM600 权限的 .env 等敏感
文件天然不可读此前的命令文本特征识别降级为审批决策的启发式
不再是 docker 只读的安全边界
- 不依赖镜像中存在该用户数字 uid 直接生效旧镜像直接受益
docker/terminal.Dockerfile 同步创建 agent 用户与 git safe.directory
供未来构建的镜像使用
- 逃逸门槛需要先提权setuid 漏洞/内核漏洞远高于嵌套 namespace
方案的 umount 逃逸云端默认 seccomp 会拦 unshare(CLONE_NEWUSER)
嵌套方案已被本方案取代
- 已知边界macOS Docker Desktop virtiofsfakeowner不按 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=*",
]

View File

@ -1,6 +1,7 @@
# modules/file_manager.py - 文件管理模块(添加行编辑功能) # modules/file_manager.py - 文件管理模块(添加行编辑功能)
import os import os
import platform
import shutil import shutil
from pathlib import Path from pathlib import Path
import re import re
@ -35,7 +36,13 @@ except ImportError: # 兼容全局环境中存在同名包的情况
LINUX_SAFETY, LINUX_SAFETY,
) )
from modules.container_file_proxy import ContainerFileProxy 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 utils.logger import setup_logger
from modules.i18n import tr from modules.i18n import tr
@ -163,7 +170,14 @@ class PathMixin:
else: else:
temp_roots = [Path("/tmp").resolve(), Path("/private/tmp").resolve()] temp_roots = [Path("/tmp").resolve(), Path("/private/tmp").resolve()]
roots: List[Path] = [self.project_path.resolve(), *temp_roots] 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: for raw in raw_items:
try: try:
p = Path(raw).expanduser().resolve() p = Path(raw).expanduser().resolve()
@ -173,6 +187,31 @@ class PathMixin:
roots.append(p) roots.append(p)
return roots 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]: def _ensure_host_access(self, full_path: Path, access: str) -> Tuple[bool, str]:
if not self._is_host_mode(): if not self._is_host_mode():
return True, "" return True, ""
@ -185,7 +224,11 @@ class PathMixin:
if access == "write" and not full_path.exists(): if access == "write" and not full_path.exists():
check_target = full_path.parent.resolve() check_target = full_path.parent.resolve()
allowed_roots = self._host_allowed_roots(access) 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, "" return True, ""
if access == "write": if access == "write":
return False, tr("file_manager.host_access_write_denied") return False, tr("file_manager.host_access_write_denied")

View File

@ -123,6 +123,13 @@ def save_policy(policy: Dict) -> Dict:
def get_macos_writable_paths() -> List[str]: def get_macos_writable_paths() -> List[str]:
"""沙箱可写路径全集 = 部署环境变量 + policy 文件(合并去重)。
仅两个来源2026-08-30 收敛·环境变量
HOST_SANDBOX_MACOS_WRITABLE_PATHS部署通道+ host_sandbox_policy.json
macos_writable_paths前端路径授权UIsettings.json
terminal.macos_writable_paths 已不再是来源config/__init__.py 映射已移除
"""
file_items = load_policy().get("macos_writable_paths", []) file_items = load_policy().get("macos_writable_paths", [])
merged: List[str] = [] merged: List[str] = []
for raw in list(HOST_SANDBOX_MACOS_WRITABLE_PATHS or []) + list(file_items or []): for raw in list(HOST_SANDBOX_MACOS_WRITABLE_PATHS or []) + list(file_items or []):

View File

@ -10,6 +10,7 @@ from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from modules.host_sandbox_policy import ( from modules.host_sandbox_policy import (
get_macos_writable_paths, get_macos_writable_paths,
get_macos_readable_paths,
get_macos_deny_read_paths, get_macos_deny_read_paths,
get_macos_deny_read_regexes, get_macos_deny_read_regexes,
) )
@ -30,38 +31,30 @@ class HostSandboxError(RuntimeError):
pass pass
# macOS 最小可读系统路径集合Codex 风格 deny-default + allow-list # macOS 只读沙箱的系统路径白名单deny-default + allow-listCodex 风格)。
# 当前仅作为常量保留,供后续需要严格 allow-list 的只读沙箱模式使用。 # 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 = [ MACOS_MINIMAL_READABLE_PATHS = [
"/bin", "/bin",
"/sbin", "/sbin",
"/usr/bin", "/usr",
"/usr/sbin",
"/usr/libexec",
"/usr/lib",
"/usr/share",
"/usr/local/lib",
"/opt/homebrew/lib",
"/lib", "/lib",
"/System",
"/Library/Apple",
"/Library/Developer/CommandLineTools",
"/Applications",
"/etc", "/etc",
"/private/etc", "/private/etc",
"/dev",
"/tmp", "/tmp",
"/private/tmp", "/private/tmp",
"/var/tmp",
"/private/var/tmp",
"/var", "/var",
"/private/var", "/private/var",
"/dev", "/opt/homebrew",
"/System/Library/Frameworks",
"/System/Library/PrivateFrameworks",
"/System/Library/SubFrameworks",
"/System/Library/CoreServices",
"/System/Library/Extensions",
"/Library/Apple",
"/Library/Preferences",
"/Library/Filesystems/NetFSPlugins",
"/Applications",
] ]
@ -108,6 +101,50 @@ def _build_macos_deny_regex_rules(patterns: List[str]) -> str:
return "\n".join(rules) 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: 6file-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_RESTRICTED = "restricted" # macOS: 仅本地回环Linux/Windows: 暂不隔离
NETWORK_PERMISSION_FULL = "full" # 完全开放 NETWORK_PERMISSION_FULL = "full" # 完全开放
@ -203,8 +240,11 @@ def _build_macos_plan(
if not sandbox_exec: if not sandbox_exec:
raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec")) raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec"))
profile = _macos_profile_for_workspace(work_path, network_permission) profile = _macos_profile_for_workspace(work_path, network_permission)
# 白名单读模型下 ~/.gitconfig 不可读会使 git fatalPoC 实测),指向 /dev/null 跳过
plan_env = dict(env)
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/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=plan_env, cwd=str(work_path))
def _build_macos_readonly_plan( def _build_macos_readonly_plan(
@ -219,8 +259,16 @@ def _build_macos_readonly_plan(
network_policy = _build_macos_network_policy(network_permission) network_policy = _build_macos_network_policy(network_permission)
workspace = str(work_path.resolve()) workspace = str(work_path.resolve())
# 只读沙箱:全局可读 + 敏感路径/文件拒绝,写权限仅 /dev/null。 # 只读沙箱2026-08-30 起deny-default 白名单读模型。
# 工作区在 deny 规则之后再显式 allow保证“工作区内不受沙箱影响”。 # 默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读,
# 写权限仅 /dev/nulldeny 规则在白名单内做最后排除(如工作区内的 .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()) deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths())
regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes()) regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes())
if regex_rules: if regex_rules:
@ -232,13 +280,16 @@ def _build_macos_readonly_plan(
'(allow sysctl-read)\n' '(allow sysctl-read)\n'
'(allow process*)\n' '(allow process*)\n'
f'{network_policy}' f'{network_policy}'
'(allow file-read*)\n' f'{allow_rules}\n'
# deny 必须位于所有 allow 之后(后规则覆盖先规则)
f'{deny_rules}\n' f'{deny_rules}\n'
f'(allow file-read* (subpath "{workspace}"))\n'
'(allow file-write* (literal "/dev/null"))' '(allow file-write* (literal "/dev/null"))'
) )
# git 在 ~/.gitconfig 不可读时会 fatalPoC 实测),指向 /dev/null 跳过全局配置
plan_env = dict(env)
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/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=plan_env, cwd=str(work_path))
def _build_macos_shell_plan( def _build_macos_shell_plan(
@ -250,8 +301,11 @@ def _build_macos_shell_plan(
if not sandbox_exec: if not sandbox_exec:
raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec_shell")) raise HostSandboxError(tr("sandbox.macos_no_sandbox_exec_shell"))
profile = _macos_profile_for_workspace(work_path, network_permission) 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"] 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( def _macos_profile_for_workspace(
@ -275,7 +329,16 @@ def _macos_profile_for_workspace(
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) 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()) deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths())
regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes()) regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes())
if regex_rules: if regex_rules:
@ -286,9 +349,10 @@ def _macos_profile_for_workspace(
'(allow sysctl-read)\n' '(allow sysctl-read)\n'
'(allow process*)\n' '(allow process*)\n'
f'{network_policy}' f'{network_policy}'
'(allow file-read*)\n' f'{allow_rules}\n'
# deny 必须位于所有 allow 之后Seatbelt 后规则覆盖先规则),
# 否则工作区内的 .env 会被 workspace allow 覆盖成可读(旧顺序漏洞)
f'{deny_rules}\n' f'{deny_rules}\n'
f'(allow file-read* (subpath "{workspace}"))\n'
f'(allow file-write* {write_expr})' f'(allow file-write* {write_expr})'
) )

View File

@ -85,12 +85,12 @@ MESSAGES = {
"en-US": "Waiting for user approval", "en-US": "Waiting for user approval",
}, },
"tool_loop.awaiting_approval_retry": { "tool_loop.awaiting_approval_retry": {
"zh-CN": "检测到权限受限,等待用户审批后重试", "zh-CN": "检测到权限受限,等待用户审批后重试(审批只授予工作区内写权限)",
"en-US": "Write permission is restricted; waiting for user approval before retrying", "en-US": "Permission is restricted; waiting for user approval before retrying (approval grants workspace-scoped write only)",
}, },
"tool_loop.awaiting_approval_retry_short": { "tool_loop.awaiting_approval_retry_short": {
"zh-CN": "检测到权限受限,等待用户审批", "zh-CN": "检测到权限受限,等待用户审批",
"en-US": "Write permission restricted; waiting for user approval", "en-US": "Permission restricted; waiting for user approval",
}, },
"tool_loop.rejected_by_user": { "tool_loop.rejected_by_user": {
"zh-CN": "操作被用户拒绝", "zh-CN": "操作被用户拒绝",
@ -100,6 +100,10 @@ MESSAGES = {
"zh-CN": "审批超时,操作未执行", "zh-CN": "审批超时,操作未执行",
"en-US": "Approval timed out; the operation was not executed", "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 ── # ── ask_user ──
"tool_loop.question_missing": { "tool_loop.question_missing": {

View File

@ -107,9 +107,9 @@ MESSAGES = {
"zh-CN": "计划模式下执行环境锁定为沙箱,请先切换运行模式", "zh-CN": "计划模式下执行环境锁定为沙箱,请先切换运行模式",
"en-US": "Execution environment is locked to sandbox in plan mode; switch work mode first", "en-US": "Execution environment is locked to sandbox in plan mode; switch work mode first",
}, },
"main_terminal.readonly_mode_locks_sandbox": { "main_terminal.restricted_mode_locks_sandbox": {
"zh-CN": "只读模式下执行环境锁定为沙箱,请先切换权限模式", "zh-CN": "只读/批准/自动审核模式下执行环境锁定为沙箱,切换到无限制模式后可选完全访问",
"en-US": "Execution environment is locked to sandbox in readonly mode; switch permission mode first", "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 ── # ── core/main_terminal_parts/tools_policy.py ──

View File

@ -62,6 +62,7 @@ except ImportError:
TERMINAL_SANDBOX_REQUIRE, TERMINAL_SANDBOX_REQUIRE,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid
from modules.i18n import tr from modules.i18n import tr
@ -262,6 +263,9 @@ class StartMixin:
"exec", "exec",
"-i", "-i",
] ]
if self.sandbox_options.get("docker_readonly_exec"):
# 只读身份会话:与 run_command 只读执行同一非特权 uid内核 DAC 强制)
cmd += docker_readonly_exec_args()
if container_workdir: if container_workdir:
cmd += ["-w", container_workdir] cmd += ["-w", container_workdir]
@ -324,6 +328,14 @@ class StartMixin:
f"{working_dir}:{mount_path}", 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") network = self.sandbox_options.get("network")
if network: if network:
cmd += ["--network", network] cmd += ["--network", network]

View File

@ -77,6 +77,7 @@ class TerminalManager:
sandbox_options: Optional[Dict] = None, sandbox_options: Optional[Dict] = None,
container_session: Optional["ContainerHandle"] = None, container_session: Optional["ContainerHandle"] = None,
network_permission_getter: Optional[Callable] = None, network_permission_getter: Optional[Callable] = None,
docker_readonly_getter: Optional[Callable] = None,
): ):
""" """
初始化终端管理器 初始化终端管理器
@ -128,6 +129,10 @@ class TerminalManager:
# 网络权限 getter由所属 WebTerminal 注入):新建持久终端时透传, # 网络权限 getter由所属 WebTerminal 注入):新建持久终端时透传,
# 使 shell 沙箱实时跟随该终端实例的权限设置而非进程级 env。 # 使 shell 沙箱实时跟随该终端实例的权限设置而非进程级 env。
self.network_permission_getter = network_permission_getter 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 的情况) # 终端命令超时工具(兼容 macOS 无 coreutils 的情况)
self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout") self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout")
@ -158,6 +163,8 @@ class TerminalManager:
"""构造当前终端应使用的沙箱参数。""" """构造当前终端应使用的沙箱参数。"""
options = dict(self.sandbox_options) options = dict(self.sandbox_options)
options["allow_direct_host_execution"] = self.sandbox_mode == "host" and self.host_execution_mode == "direct" 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": if self.container_session and self.container_session.mode == "docker":
options["container_name"] = self.container_session.container_name options["container_name"] = self.container_session.container_name
options["mount_path"] = self.container_session.mount_path options["mount_path"] = self.container_session.mount_path

View File

@ -40,6 +40,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args
from modules.i18n import tr from modules.i18n import tr
if TYPE_CHECKING: if TYPE_CHECKING:
@ -185,9 +186,11 @@ class RunMixin:
container_workdir = mount_path.rstrip("/") container_workdir = mount_path.rstrip("/")
if relative: if relative:
container_workdir = f"{container_workdir}/{relative}" container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [ exec_cmd = [docker_bin, "exec"]
docker_bin, if not sandbox_write_access:
"exec", # 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args()
exec_cmd += [
"-e", "-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"-e", "-e",

View File

@ -301,6 +301,10 @@ class UserContainerManager:
container_name = self._build_container_name(username) container_name = self._build_container_name(username)
self._kill_container(container_name, docker_path) self._kill_container(container_name, docker_path)
# 容器主进程刻意保持 root可写执行docker exec 不带 -u依赖它
# 只读权限模式的执行通道在 exec 时以非特权 uid 运行(内核 DAC 强制只读),
# 见 modules/docker_readonly_exec.py。工作区 bind 挂载是唯一可写资产,
# 切勿给容器挂载 docker.sock 或追加特权/capability会直接击穿该模型
cmd = [ cmd = [
docker_path, docker_path,
"run", "run",

View File

@ -175,6 +175,35 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
return any(marker in joined for marker in markers) 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( def _inject_runtime_mode_notice(
*, *,
web_terminal, web_terminal,
@ -1186,6 +1215,13 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
except Exception: except Exception:
result_data = {"output": retry_tool_result} result_data = {"output": retry_tool_result}
tool_result = 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) tool_failed = detect_tool_failure(result_data)

View File

@ -702,15 +702,17 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
safe_run_mode = candidate if candidate in {"fast", "thinking"} else "fast" 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" 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) 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 当前值(与正常创建路径一致); # 运行模式work_mode沿用 terminal 当前值(与正常创建路径一致);
# plan 档不变量 ⇒ 权限必须只读,同时记录进入前权限供离开 plan 恢复 # plan 档不变量 ⇒ 权限必须只读,同时记录进入前权限供离开 plan 恢复
safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")() safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")()
if safe_work_mode not in ("plan", "ask", "execute"): if safe_work_mode not in ("plan", "ask", "execute"):
safe_work_mode = "plan" 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 safe_pre_plan_permission = None
if safe_work_mode == "plan": if safe_work_mode == "plan":
if safe_permission_mode != "readonly": if safe_permission_mode != "readonly":

View File

@ -351,16 +351,18 @@ def create_multi_agent_conversation():
candidate = "thinking" candidate = "thinking"
safe_run_mode = candidate if candidate in {"fast", "thinking"} else "fast" 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" 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 当前值(与正常创建路径一致, # 运行模式work_mode沿用 terminal 当前值(与正常创建路径一致,
# /new 页面切换后新对话继承plan 档不变量 ⇒ 权限必须只读, # /new 页面切换后新对话继承plan 档不变量 ⇒ 权限必须只读,
# 同时记录进入前权限供离开 plan 恢复 # 同时记录进入前权限供离开 plan 恢复
safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")() safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")()
if safe_work_mode not in ("plan", "ask", "execute"): if safe_work_mode not in ("plan", "ask", "execute"):
safe_work_mode = "plan" 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 safe_pre_plan_permission = None
if safe_work_mode == "plan": if safe_work_mode == "plan":
if safe_permission_mode != "readonly": if safe_permission_mode != "readonly":

View File

@ -504,11 +504,11 @@
type="button" type="button"
class="permission-switcher__btn" class="permission-switcher__btn"
:disabled="!isConnected" :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')" @click="$emit('toggle-permission-menu')"
> >
<svg <svg
v-if="permissionLockedByPlan || executionLockedByReadonly" v-if="permissionLockedByPlan || executionLockedByRestricted"
class="permission-switcher__lock" class="permission-switcher__lock"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -566,7 +566,7 @@
<div class="permission-switcher__group-title"> <div class="permission-switcher__group-title">
{{ $t('input.executionEnv') }} {{ $t('input.executionEnv') }}
<span v-if="permissionLockedByPlan" class="permission-switcher__group-lock">{{ $t('input.planModeLocked') }}</span> <span v-if="permissionLockedByPlan" class="permission-switcher__group-lock">{{ $t('input.planModeLocked') }}</span>
<span v-else-if="executionLockedByReadonly" class="permission-switcher__group-lock">{{ $t('input.readonlyModeLocked') }}</span> <span v-else-if="executionLockedByRestricted" class="permission-switcher__group-lock">{{ $t('input.restrictedModeLocked') }}</span>
</div> </div>
<button <button
v-for="option in executionModeOptions" v-for="option in executionModeOptions"
@ -3383,12 +3383,12 @@ const workModeLabel = computed(() => {
/** 计划模式下权限被锁定为只读UI 禁用 + 后端强制) */ /** 计划模式下权限被锁定为只读UI 禁用 + 后端强制) */
const permissionLockedByPlan = computed(() => props.currentWorkMode === 'plan'); const permissionLockedByPlan = computed(() => props.currentWorkMode === 'plan');
/** 只读模式下执行环境锁定为沙箱UI 禁用 + 后端强制) */ /** 受限权限档(只读/批准/自动审核)执行环境锁定为沙箱UI 禁用 + 后端强制);仅无限制可选完全访问 */
const executionLockedByReadonly = computed(() => props.currentPermissionMode === 'readonly'); const executionLockedByRestricted = computed(() => props.currentPermissionMode !== 'unrestricted');
/** 执行环境组禁用条件plan 全锁 或 readonly 锁执行环境(权限组仅受 plan 锁) */ /** 执行环境组禁用条件plan 全锁 或 受限权限档锁执行环境(权限组仅受 plan 锁) */
const executionModeLocked = computed( const executionModeLocked = computed(
() => permissionLockedByPlan.value || executionLockedByReadonly.value () => permissionLockedByPlan.value || executionLockedByRestricted.value
); );
const currentExecutionShortLabel = computed(() => { const currentExecutionShortLabel = computed(() => {

View File

@ -18,11 +18,12 @@ export default {
versionControl: 'Version Control', versionControl: 'Version Control',
permissionLockedByPlanTitle: permissionLockedByPlanTitle:
'In plan mode, permission and execution environment are locked; network access can still be adjusted', 'In plan mode, permission and execution environment are locked; network access can still be adjusted',
executionLockedReadonlyTitle: 'In read-only mode, the execution environment is locked to sandbox', executionLockedRestrictedTitle:
'In readonly/approval/auto-approval mode, the execution environment is locked to sandbox',
permissionGroup: 'Permission', permissionGroup: 'Permission',
planModeLocked: 'Locked by plan mode', planModeLocked: 'Locked by plan mode',
executionEnv: 'Execution Environment', executionEnv: 'Execution Environment',
readonlyModeLocked: 'Locked by read-only mode', restrictedModeLocked: 'Locked by restricted mode',
networkPermission: 'Network Access', networkPermission: 'Network Access',
usedPercent: '{n} used', usedPercent: '{n} used',

View File

@ -79,9 +79,9 @@ export default {
writableMode: 'Read & write', writableMode: 'Read & write',
readableMode: 'Read-only', readableMode: 'Read-only',
writableHint: writableHint:
'Read-write paths are writable in the workspace-write sandbox and read-only in the read-only sandbox.', 'Read-write paths are writable in the writable sandbox and read-only in the read-only sandbox.',
readableHint: readableHint:
'Read-only paths are added to the allowed read list in the read-only sandbox; the writable workspace sandbox is readable by default.', 'Read-only paths join the read whitelist of the read-only sandbox (which denies all reads by default except system dirs, the workspace, and authorized paths).',
writablePlaceholder: 'One path per line, e.g. ~/Desktop/agents-export', writablePlaceholder: 'One path per line, e.g. ~/Desktop/agents-export',
readablePlaceholder: 'One path per line, e.g. ~/Documents/reference', readablePlaceholder: 'One path per line, e.g. ~/Documents/reference',

View File

@ -17,11 +17,11 @@ export default {
workModeTitle: '运行模式', workModeTitle: '运行模式',
versionControl: '版本控制', versionControl: '版本控制',
permissionLockedByPlanTitle: '计划模式下权限与执行环境已锁定,网络权限仍可调整', permissionLockedByPlanTitle: '计划模式下权限与执行环境已锁定,网络权限仍可调整',
executionLockedReadonlyTitle: '只读模式下执行环境已锁定为沙箱', executionLockedRestrictedTitle: '只读/批准/自动审核模式下执行环境已锁定为沙箱',
permissionGroup: '权限', permissionGroup: '权限',
planModeLocked: '计划模式锁定', planModeLocked: '计划模式锁定',
executionEnv: '执行环境', executionEnv: '执行环境',
readonlyModeLocked: '只读模式锁定', restrictedModeLocked: '受限模式锁定',
networkPermission: '网络权限', networkPermission: '网络权限',
usedPercent: '{n} 已用', usedPercent: '{n} 已用',

View File

@ -79,8 +79,8 @@ export default {
pathAuthTitle: '路径授权', pathAuthTitle: '路径授权',
writableMode: '可读可写', writableMode: '可读可写',
readableMode: '仅可读', readableMode: '仅可读',
writableHint: '可读可写路径在 workspace-write 沙箱中可写入,只读沙箱中仅可读。', writableHint: '可读可写路径在可写沙箱中可写入,在只读沙箱中仅可读。',
readableHint: '仅可读路径在只读沙箱中会被加入允许读取列表;工作区可写沙箱默认已可读。', readableHint: '仅可读路径会加入只读沙箱的读取白名单(只读沙箱默认拒绝所有读取,仅系统目录、工作区与授权路径可读)。',
writablePlaceholder: '每行一个路径,例如:~/Desktop/agents-export', writablePlaceholder: '每行一个路径,例如:~/Desktop/agents-export',
readablePlaceholder: '每行一个路径,例如:~/Documents/reference', readablePlaceholder: '每行一个路径,例如:~/Documents/reference',