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:
parent
f51a46bf9b
commit
8257e4d34d
39
AGENTS.md
39
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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", "")
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,27 +161,47 @@ 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 = ""
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
56
modules/docker_readonly_exec.py
Normal file
56
modules/docker_readonly_exec.py
Normal file
@ -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=*",
|
||||
]
|
||||
@ -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")
|
||||
|
||||
@ -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 []):
|
||||
|
||||
@ -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})'
|
||||
)
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 ──
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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')"
|
||||
>
|
||||
<svg
|
||||
v-if="permissionLockedByPlan || executionLockedByReadonly"
|
||||
v-if="permissionLockedByPlan || executionLockedByRestricted"
|
||||
class="permission-switcher__lock"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@ -566,7 +566,7 @@
|
||||
<div class="permission-switcher__group-title">
|
||||
{{ $t('input.executionEnv') }}
|
||||
<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>
|
||||
<button
|
||||
v-for="option in executionModeOptions"
|
||||
@ -3383,12 +3383,12 @@ const workModeLabel = computed(() => {
|
||||
/** 计划模式下权限被锁定为只读(UI 禁用 + 后端强制) */
|
||||
const permissionLockedByPlan = computed(() => props.currentWorkMode === 'plan');
|
||||
|
||||
/** 只读模式下执行环境锁定为沙箱(UI 禁用 + 后端强制) */
|
||||
const executionLockedByReadonly = computed(() => props.currentPermissionMode === 'readonly');
|
||||
/** 受限权限档(只读/批准/自动审核)执行环境锁定为沙箱(UI 禁用 + 后端强制);仅无限制可选完全访问 */
|
||||
const executionLockedByRestricted = computed(() => props.currentPermissionMode !== 'unrestricted');
|
||||
|
||||
/** 执行环境组禁用条件:plan 全锁 或 readonly 锁执行环境(权限组仅受 plan 锁) */
|
||||
/** 执行环境组禁用条件:plan 全锁 或 受限权限档锁执行环境(权限组仅受 plan 锁) */
|
||||
const executionModeLocked = computed(
|
||||
() => permissionLockedByPlan.value || executionLockedByReadonly.value
|
||||
() => permissionLockedByPlan.value || executionLockedByRestricted.value
|
||||
);
|
||||
|
||||
const currentExecutionShortLabel = computed(() => {
|
||||
|
||||
@ -18,11 +18,12 @@ export default {
|
||||
versionControl: 'Version Control',
|
||||
permissionLockedByPlanTitle:
|
||||
'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',
|
||||
planModeLocked: 'Locked by plan mode',
|
||||
executionEnv: 'Execution Environment',
|
||||
readonlyModeLocked: 'Locked by read-only mode',
|
||||
restrictedModeLocked: 'Locked by restricted mode',
|
||||
networkPermission: 'Network Access',
|
||||
usedPercent: '{n} used',
|
||||
|
||||
|
||||
@ -79,9 +79,9 @@ export default {
|
||||
writableMode: 'Read & write',
|
||||
readableMode: 'Read-only',
|
||||
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:
|
||||
'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',
|
||||
readablePlaceholder: 'One path per line, e.g. ~/Documents/reference',
|
||||
|
||||
|
||||
@ -17,11 +17,11 @@ export default {
|
||||
workModeTitle: '运行模式',
|
||||
versionControl: '版本控制',
|
||||
permissionLockedByPlanTitle: '计划模式下权限与执行环境已锁定,网络权限仍可调整',
|
||||
executionLockedReadonlyTitle: '只读模式下执行环境已锁定为沙箱',
|
||||
executionLockedRestrictedTitle: '只读/批准/自动审核模式下执行环境已锁定为沙箱',
|
||||
permissionGroup: '权限',
|
||||
planModeLocked: '计划模式锁定',
|
||||
executionEnv: '执行环境',
|
||||
readonlyModeLocked: '只读模式锁定',
|
||||
restrictedModeLocked: '受限模式锁定',
|
||||
networkPermission: '网络权限',
|
||||
usedPercent: '{n} 已用',
|
||||
|
||||
|
||||
@ -79,8 +79,8 @@ export default {
|
||||
pathAuthTitle: '路径授权',
|
||||
writableMode: '可读可写',
|
||||
readableMode: '仅可读',
|
||||
writableHint: '可读可写路径在 workspace-write 沙箱中可写入,只读沙箱中仅可读。',
|
||||
readableHint: '仅可读路径在只读沙箱中会被加入允许读取列表;工作区可写沙箱默认已可读。',
|
||||
writableHint: '可读可写路径在可写沙箱中可写入,在只读沙箱中仅可读。',
|
||||
readableHint: '仅可读路径会加入只读沙箱的读取白名单(只读沙箱默认拒绝所有读取,仅系统目录、工作区与授权路径可读)。',
|
||||
writablePlaceholder: '每行一个路径,例如:~/Desktop/agents-export',
|
||||
readablePlaceholder: '每行一个路径,例如:~/Documents/reference',
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user