feat(security): 终端读写身份创建时钉死,权限跨界切换销毁重建

- 宿主机持久终端支持只读身份:build_host_sandbox_shell_plan 三平台(macOS Seatbelt 只读 profile / Linux ro-bind / Windows WSL 只读挂载)统一 readonly 参数;macOS 只读 profile 抽为 _macos_readonly_profile_for_workspace 与只读 run_command 共用
- docker_terminal_readonly_enabled 泛化为 terminal_readonly_enabled(docker uid 与宿主机沙箱同一判定:非 unrestricted 即只读身份)
- 权限跨界切换(受限档⇄unrestricted)销毁现有终端会话(close_all),受限档内部互切不销毁;与执行环境切换销毁策略一致
- terminal_input 工具层全面放开:只读白名单加入、审批集合移除、approval/auto_approval 命令文本分流删除;受限档终端内写入由系统 EPERM 兜底,审批写入走 run_command 两段式
This commit is contained in:
JOJO 2026-08-30 23:06:33 +08:00
parent 8257e4d34d
commit b55f4a811d
9 changed files with 105 additions and 78 deletions

View File

@ -333,12 +333,14 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝 - `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝
- 读取同样受限(各平台只读强制的可读边界见 §10.8);读不到属预期边界,不是故障 - 读取同样受限(各平台只读强制的可读边界见 §10.8);读不到属预期边界,不是故障
- `write_file` / `edit_file` 等写入类工具直接拒绝 - `write_file` / `edit_file` 等写入类工具直接拒绝
- 持久终端可自由创建与输入但以只读身份运行docker 非特权 uid / 宿主机只读 profile写入 EPERM 由系统强制权限跨界切换受限档⇄unrestricted时现有终端直接销毁重开生效
- `approval` - `approval`
- `run_command` 先走只读沙箱(可读边界见 §10.8,读越界也会触发权限拒绝) - `run_command` 先走只读沙箱(可读边界见 §10.8,读越界也会触发权限拒绝)
- 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批 - 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批
- 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果” - 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果”
- **审批只授予该次命令的工作区内写权限,不放大读取**2026-08-30 方案一):可写沙箱读边界与只读同一白名单,读越界唯一途径是「路径授权」;重试后仍为文件权限拒绝时,工具结果附带 `read_scope_hint` 引导模型说明 - **审批只授予该次命令的工作区内写权限,不放大读取**2026-08-30 方案一):可写沙箱读边界与只读同一白名单,读越界唯一途径是「路径授权」;重试后仍为文件权限拒绝时,工具结果附带 `read_scope_hint` 引导模型说明
- **执行环境锁定为沙箱**2026-08-30 起direct 下无沙箱兜底,启发式漏判的写命令会直接执行成功、审批承诺被架空,故批准/自动审核与 direct 硬互斥(锁定机制同 plan/只读,见 §10.6 - **执行环境锁定为沙箱**2026-08-30 起direct 下无沙箱兜底,启发式漏判的写命令会直接执行成功、审批承诺被架空,故批准/自动审核与 direct 硬互斥(锁定机制同 plan/只读,见 §10.6
- 持久终端以只读身份运行(同 readonly 条目):终端内写入永远 EPERM需审批的写入走 `run_command` 两段式通道
- `auto_approval` - `auto_approval`
- `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程 - `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程
- `run_command` 先走只读沙箱(可读边界见 §10.8);触发权限拒绝后由后台审批智能体自动审核 - `run_command` 先走只读沙箱(可读边界见 §10.8);触发权限拒绝后由后台审批智能体自动审核
@ -414,12 +416,13 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- 容器主进程保持 root可写执行不变`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖 - 容器主进程保持 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 类捷径 - 强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径
- 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock云端已验证macOS Docker Desktopvirtiofs fakeowner不执行 uid 权限,仅 Linux 宿主生效 - 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock云端已验证macOS Docker Desktopvirtiofs fakeowner不执行 uid 权限,仅 Linux 宿主生效
- 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`docker_terminal_readonly_enabled` 判定,`docker_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道 - 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道权限跨界切换受限档⇄unrestricted时销毁现有终端会话重建`_apply_restricted_execution_mode_link` → `close_all()`
- Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益 - Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益
- **macOS 宿主机 = Seatbelt 白名单读模型**`modules/host_sandbox_runner.py` - **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 均注入) - 只读/可写两个 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 曾实际可读) - **deny 规则(.env 正则、~/.ssh 等)必须位于所有 allow 之后**Seatbelt 后规则覆盖先规则);两个 profile 均已修复旧顺序漏洞(工作区 .env 曾实际可读)
- 可写 profile 已于 2026-08-30 白名单化(此前为全局可读,导致 unrestricted/审批批准后能读授权范围外文件);白名单固有代价:祖先目录顶层文件名可列出(读文件内容仍被拒) - 可写 profile 已于 2026-08-30 白名单化(此前为全局可读,导致 unrestricted/审批批准后能读授权范围外文件);白名单固有代价:祖先目录顶层文件名可列出(读文件内容仍被拒)
- 持久终端 shell plan 支持 readonly 参数(`_build_macos_shell_plan` 复用 `_macos_readonly_profile_for_workspace`):受限档终端以只读 profile 创建unrestricted 保持可写 profile
- 原生文件工具对齐(`file_manager/path_mixin.py`):读 roots 与沙箱白名单同源(系统路径 + 工作区 + 授权)+ 叠加同一禁读清单——至此 host+sandbox 下全部读通道(只读/可写沙箱命令、原生 read_file共享同一边界 - 原生文件工具对齐(`file_manager/path_mixin.py`):读 roots 与沙箱白名单同源(系统路径 + 工作区 + 授权)+ 叠加同一禁读清单——至此 host+sandbox 下全部读通道(只读/可写沙箱命令、原生 read_file共享同一边界
- **Linux 宿主机 = bwrap**:只读为 `--ro-bind / /`(全局只读);可写为 `--ro-bind / /` + 工作区可写 bind——**读侧仍是全局可读,尚未对齐白名单**(本机无 Linux 测试环境,刻意未动,待后续);**Windows = WSL2 最小根文件系统(白名单)**——命名空间内只有系统目录+工作区,天然符合 - **Linux 宿主机 = bwrap**:只读为 `--ro-bind / /`(全局只读);可写为 `--ro-bind / /` + 工作区可写 bind——**读侧仍是全局可读,尚未对齐白名单**(本机无 Linux 测试环境,刻意未动,待后续);**Windows = WSL2 最小根文件系统(白名单)**——命名空间内只有系统目录+工作区,天然符合

View File

@ -132,7 +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, terminal_readonly_getter=self.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)

View File

@ -172,21 +172,18 @@ class ModeMixin:
"- run_command统一在系统只读沙箱中执行写入会被系统直接拒绝\n" "- run_command统一在系统只读沙箱中执行写入会被系统直接拒绝\n"
"- 可读范围同样受限(宿主机 macOS仅系统目录/工作区/路径授权可读docker受文件属主权限限制600 等属主保护文件不可读);读不到属预期边界,不要尝试绕过\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 及会修改工作区的命令操作\n"
"- 终端会话terminal_*可自由创建与输入但终端以只读身份运行docker 非特权 uid / 宿主机只读沙箱 profile写入会被系统拒绝属预期边界"
) )
elif mode == "approval": elif mode == "approval":
detailed_rules = ( detailed_rules = (
"- run_command先在系统只读沙箱执行可读范围同样受限仅当出现权限拒绝时才触发审批\n" "- run_command先在系统只读沙箱执行可读范围同样受限仅当出现权限拒绝时才触发审批\n"
"- 审批通过后:仅当前这一次命令会以可写沙箱重试;审批只授予工作区内写权限,不放大读取范围\n" "- 审批通过后:仅当前这一次命令会以可写沙箱重试;审批只授予工作区内写权限,不放大读取范围\n"
"- 读取授权范围外的路径:审批无法放行,需用户在「路径授权」中添加\n" "- 读取授权范围外的路径:审批无法放行,需用户在「路径授权」中添加\n"
"- 需要用户批准:terminal_input、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n" "- 需要用户批准:write_file、edit_file、save_webpage 及其他会修改工作区的操作\n"
"- 被拒绝或超时:本次操作不会执行写入\n" "- 被拒绝或超时:本次操作不会执行写入\n"
"- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)" "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)\n"
) "- 终端会话terminal_*可直接创建与输入无需批准但以只读身份运行docker 非特权 uid / 宿主机只读沙箱 profile终端里的写入会被系统拒绝需要写入的命令请用 run_command审批通过后以可写身份重跑属预期边界"
if docker_runtime:
detailed_rules += (
"\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command审批通过后以可写身份重跑"
"终端里的写入会被系统拒绝,属预期边界"
) )
elif mode == "auto_approval": elif mode == "auto_approval":
detailed_rules = ( detailed_rules = (
@ -196,12 +193,8 @@ class ModeMixin:
"- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n" "- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n"
"- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n" "- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n"
"- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准\n" "- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准\n"
"- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)" "- 执行环境已锁定为沙箱(完全访问仅在无限制模式下可选)\n"
) "- 终端会话terminal_*以只读身份运行docker 非特权 uid / 宿主机只读沙箱 profile终端里的写入会被系统拒绝需要写入的命令请用 run_command审批通过后以可写身份重跑属预期边界"
if docker_runtime:
detailed_rules += (
"\n- docker 终端会话以只读身份运行:需要写入的命令请用 run_command审批通过后以可写身份重跑"
"终端里的写入会被系统拒绝,属预期边界"
) )
else: else:
detailed_rules = "" detailed_rules = ""

View File

@ -209,8 +209,11 @@ class MainTerminalToolsExecutionMixin:
"sleep", "sleep",
"ask_user", "ask_user",
# 以下为只读/调研性质或会话状态管理类工具,只读模式(含计划模式)放行: # 以下为只读/调研性质或会话状态管理类工具,只读模式(含计划模式)放行:
# 终端会话管理与快照terminal_input 仍禁止,防止经持久终端执行写命令) # 终端会话全生命周期terminal_input 自 2026-08-30 起放行:终端读写身份在
# 创建时已钉死受限档终端为只读身份——docker 非特权 uid / 宿主机只读
# profile写入由系统直接 EPERM无需工具层再拦
"terminal_session", "terminal_session",
"terminal_input",
"terminal_snapshot", "terminal_snapshot",
# 子智能体全生命周期(只读权限会传播给子智能体,计划模式调研主力) # 子智能体全生命周期(只读权限会传播给子智能体,计划模式调研主力)
"create_sub_agent", "create_sub_agent",
@ -225,7 +228,6 @@ class MainTerminalToolsExecutionMixin:
} }
_APPROVAL_REQUIRED_TOOLS = { _APPROVAL_REQUIRED_TOOLS = {
"run_command", "run_command",
"terminal_input",
"write_file", "write_file",
"edit_file", "edit_file",
"create_file", "create_file",
@ -428,20 +430,9 @@ class MainTerminalToolsExecutionMixin:
"requires_approval": requires, "requires_approval": requires,
"risk_markers": risk_markers, "risk_markers": risk_markers,
} }
if tool_name == "terminal_input": # terminal_input 不设审批/分流2026-08-30 起):受限档终端创建时
command_text = args.get("command") or args.get("input") # 已钉死为只读身份,写入由系统 EPERM 兜底;需要审批的写入请走
if self._is_docker_runtime(): # run_command 两段式通道。
readonly_ok = self._is_readonly_run_command_allowed(command_text)
risk_markers = self._extract_command_risk_markers(command_text)
requires = (not readonly_ok) or bool(risk_markers)
return {
"allowed": True,
"mode": mode,
"requires_approval": requires,
"risk_markers": risk_markers,
}
if self._is_readonly_run_command_allowed(command_text):
return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name in self._APPROVAL_REQUIRED_TOOLS: if tool_name in self._APPROVAL_REQUIRED_TOOLS:
return {"allowed": True, "mode": mode, "requires_approval": True} return {"allowed": True, "mode": mode, "requires_approval": True}
return {"allowed": True, "mode": mode} return {"allowed": True, "mode": mode}
@ -473,18 +464,7 @@ class MainTerminalToolsExecutionMixin:
"risk_markers": risk_markers, "risk_markers": risk_markers,
} }
if tool_name == "terminal_input": if tool_name == "terminal_input":
command_text = args.get("command") or args.get("input") # 与 approval 同理:受限档终端只读身份兜底,不审批不分流
if self._is_docker_runtime():
readonly_ok = self._is_readonly_run_command_allowed(command_text)
risk_markers = self._extract_command_risk_markers(command_text)
requires = (not readonly_ok) or bool(risk_markers)
return {
"allowed": True,
"mode": mode,
"requires_approval": requires,
"risk_markers": risk_markers,
}
if self._is_readonly_run_command_allowed(command_text):
return {"allowed": True, "mode": mode, "requires_approval": False} return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name in self._APPROVAL_REQUIRED_TOOLS: if tool_name in self._APPROVAL_REQUIRED_TOOLS:
return {"allowed": True, "mode": mode, "requires_approval": True} return {"allowed": True, "mode": mode, "requires_approval": True}

View File

@ -239,12 +239,17 @@ class MainTerminalToolsPolicyMixin:
return "unrestricted" return "unrestricted"
return mode return mode
def docker_terminal_readonly_enabled(self) -> bool: def terminal_readonly_enabled(self) -> bool:
"""docker 持久终端是否以只读身份(非特权 uid创建 """持久终端是否以只读身份创建docker 与宿主机沙箱通用)
readonly / approval / auto_approval 模式下为 True新建的 docker readonly / approval / auto_approval 模式下为 True新建的终端
终端会话与 run_command 只读执行同一身份内核 DAC 强制只读 会话与只读 run_command 同一身份docker 容器内为非特权 uid
modules/docker_readonly_exec.pyunrestricted 保持 root 内核 DAC 强制只读 modules/docker_readonly_exec.py
宿主机沙箱为只读 profilemacOS Seatbelt 白名单只读 / Linux ro-bind
/ Windows WSL 只读挂载 host_sandbox_runner shell plan
readonly 参数unrestricted 保持可写身份docker root /
宿主机可写 profile权限跨界切换受限档unrestricted
现有终端会被销毁下次以新身份重建
""" """
return self.get_permission_mode() != "unrestricted" return self.get_permission_mode() != "unrestricted"
@ -408,7 +413,18 @@ class MainTerminalToolsPolicyMixin:
切离受限档 unrestricted时仅当有明确进入前记录才恢复无记录保持 sandbox安全默认 切离受限档 unrestricted时仅当有明确进入前记录才恢复无记录保持 sandbox安全默认
注意metadata 键名保留 pre_readonly_execution_mode 不改语义已泛化为受限档共用 注意metadata 键名保留 pre_readonly_execution_mode 不改语义已泛化为受限档共用
存量对话的恢复记录不受影响 存量对话的恢复记录不受影响
终端身份联动终端读写身份在创建时钉死受限档=只读身份unrestricted=可写/root
跨界切换时销毁现有终端会话强制下次以新身份重建readonlyapproval
互切身份不变不销毁与执行环境切换销毁terminal_manager.set_host_execution_mode同策略
""" """
if entering or leaving:
try:
tm = getattr(self, "terminal_manager", None)
if tm is not None and hasattr(tm, "close_all"):
tm.close_all()
except Exception:
pass
if entering: if entering:
try: try:
if hasattr(self, "get_execution_mode") and self.get_execution_mode() == "direct": if hasattr(self, "get_execution_mode") and self.get_execution_mode() == "direct":

View File

@ -136,8 +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: if getattr(self.terminal_manager, "terminal_readonly_getter", None) is None:
self.terminal_manager.docker_readonly_getter = self.docker_terminal_readonly_enabled self.terminal_manager.terminal_readonly_getter = self.terminal_readonly_enabled
else: else:
self.terminal_manager = TerminalManager( self.terminal_manager = TerminalManager(
project_path=project_path, project_path=project_path,
@ -147,7 +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, terminal_readonly_getter=self.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)

View File

@ -219,14 +219,21 @@ def build_host_sandbox_shell_plan(
work_path: Path, work_path: Path,
env: Dict[str, str], env: Dict[str, str],
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan: ) -> SandboxPlan:
"""持久终端(交互式 shell沙箱计划。
readonly=True 时使用只读身份macOS 只读 profile / Linux bwrap ro-bind /
Windows WSL 只读挂载受限权限档只读/批准/自动审核的终端以此创建
写入由系统直接拒绝EPERMunrestricted 档传 False 保持可写
"""
system = platform.system() system = platform.system()
if system == "Darwin": if system == "Darwin":
return _build_macos_shell_plan(work_path, env, network_permission) return _build_macos_shell_plan(work_path, env, network_permission, readonly=readonly)
if system == "Linux": if system == "Linux":
return _build_linux_shell_plan(work_path, env, network_permission) return _build_linux_shell_plan(work_path, env, network_permission, readonly=readonly)
if system == "Windows": if system == "Windows":
return _build_windows_shell_plan(work_path, env, network_permission) return _build_windows_shell_plan(work_path, env, network_permission, readonly=readonly)
raise HostSandboxError(tr("sandbox.unsupported_system", system=system)) raise HostSandboxError(tr("sandbox.unsupported_system", system=system))
@ -256,14 +263,29 @@ def _build_macos_readonly_plan(
sandbox_exec = shutil.which("sandbox-exec") sandbox_exec = shutil.which("sandbox-exec")
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_readonly_profile_for_workspace(work_path, network_permission)
# 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]
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
def _macos_readonly_profile_for_workspace(
work_path: Path,
network_permission: Optional[str] = None,
) -> str:
"""macOS 只读沙箱 profiledeny-default 白名单读模型2026-08-30 起)。
默认全部不可读仅系统路径白名单 + 路径授权可写+仅可读+ 工作区可读
写权限仅 /dev/nulldeny 规则在白名单内做最后排除如工作区内的 .env
历史模型为全局可读 + 敏感路径黑名单 deny 顺序在 workspace allow
之前导致工作区内 .env 实际可读Seatbelt 后规则覆盖先规则本次一并修复
只读 run_command 与受限档持久终端shell plan readonly=True共用本函数
"""
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())
# 只读沙箱2026-08-30 起deny-default 白名单读模型。
# 默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读,
# 写权限仅 /dev/nulldeny 规则在白名单内做最后排除(如工作区内的 .env
# 历史模型为「全局可读 + 敏感路径黑名单」,且 deny 顺序在 workspace allow
# 之前导致工作区内 .env 实际可读Seatbelt 后规则覆盖先规则),本次一并修复。
readable_paths = list(MACOS_MINIMAL_READABLE_PATHS) readable_paths = list(MACOS_MINIMAL_READABLE_PATHS)
readable_paths.extend(get_macos_readable_paths()) readable_paths.extend(get_macos_readable_paths())
readable_paths.append(str(work_path)) # 原始形式(可能含符号链接) readable_paths.append(str(work_path)) # 原始形式(可能含符号链接)
@ -274,7 +296,7 @@ def _build_macos_readonly_plan(
if regex_rules: if regex_rules:
deny_rules += "\n" + regex_rules deny_rules += "\n" + regex_rules
profile = ( return (
'(version 1)\n' '(version 1)\n'
'(deny default)\n' '(deny default)\n'
'(allow sysctl-read)\n' '(allow sysctl-read)\n'
@ -285,21 +307,22 @@ def _build_macos_readonly_plan(
f'{deny_rules}\n' f'{deny_rules}\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]
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
def _build_macos_shell_plan( def _build_macos_shell_plan(
work_path: Path, work_path: Path,
env: Dict[str, str], env: Dict[str, str],
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan: ) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec") sandbox_exec = shutil.which("sandbox-exec")
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"))
# 受限档终端以只读身份创建(与只读 run_command 同一 profile写入 EPERM
# unrestricted 保持可写 profile白名单读 + 工作区/授权路径可写)。
if readonly:
profile = _macos_readonly_profile_for_workspace(work_path, network_permission)
else:
profile = _macos_profile_for_workspace(work_path, network_permission) profile = _macos_profile_for_workspace(work_path, network_permission)
# 同 _build_macos_plan白名单读下 git 需要 GIT_CONFIG_GLOBAL 兜底 # 同 _build_macos_plan白名单读下 git 需要 GIT_CONFIG_GLOBAL 兜底
plan_env = dict(env) plan_env = dict(env)
@ -401,6 +424,7 @@ def _build_linux_shell_plan(
work_path: Path, work_path: Path,
env: Dict[str, str], env: Dict[str, str],
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan: ) -> SandboxPlan:
bwrap = shutil.which("bwrap") bwrap = shutil.which("bwrap")
if not bwrap: if not bwrap:
@ -412,7 +436,7 @@ def _build_linux_shell_plan(
if not seccomp_path.exists(): if not seccomp_path.exists():
raise HostSandboxError(tr("sandbox.seccomp_bpf_not_found", path=seccomp_path)) raise HostSandboxError(tr("sandbox.seccomp_bpf_not_found", path=seccomp_path))
shell_cmd = ["/bin/bash", "-i"] shell_cmd = ["/bin/bash", "-i"]
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path) return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path, readonly=readonly)
def _build_linux_common_plan( def _build_linux_common_plan(
@ -627,8 +651,9 @@ def _build_windows_shell_plan(
work_path: Path, work_path: Path,
env: Dict[str, str], env: Dict[str, str],
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
readonly: bool = False,
) -> SandboxPlan: ) -> SandboxPlan:
return _build_windows_wsl_plan( return _build_windows_wsl_plan(
work_path, env, ["bash", "-i"], readonly=False, work_path, env, ["bash", "-i"], readonly=readonly,
network_permission=network_permission, network_permission=network_permission,
) )

View File

@ -157,8 +157,13 @@ class StartMixin:
try: try:
# 实时读取所属终端实例的权限设置,而非进程级 env多 terminal 并存时 env 会被覆盖) # 实时读取所属终端实例的权限设置,而非进程级 env多 terminal 并存时 env 会被覆盖)
network_permission = self._resolve_network_permission() network_permission = self._resolve_network_permission()
# 终端读写身份创建时钉死:受限权限档(只读/批准/自动审核)→ 只读 profile
# (写入 EPERM 由系统强制unrestricted → 可写 profile。
# 权限跨界切换时所属 terminal 会销毁现有会话,保证身份与当前档位一致。
readonly = bool(self.sandbox_options.get("host_terminal_readonly"))
plan = build_host_sandbox_shell_plan( plan = build_host_sandbox_shell_plan(
self.working_dir, env, network_permission=network_permission self.working_dir, env, network_permission=network_permission,
readonly=readonly,
) )
cmd_args = plan.command cmd_args = plan.command
pass_fds = () pass_fds = ()

View File

@ -77,7 +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, terminal_readonly_getter: Optional[Callable] = None,
): ):
""" """
初始化终端管理器 初始化终端管理器
@ -129,10 +129,12 @@ 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 时新建的 # 终端只读身份 getter由所属 WebTerminal 注入):返回 True 时新建的
# docker 终端会话以非特权 uid 运行(与 run_command 只读执行同一身份, # 终端会话以只读身份运行——docker 容器内为非特权 uid与 run_command
# 见 modules/docker_readonly_exec.py已存在的会话不受影响。 # 只读执行同一身份,见 modules/docker_readonly_exec.py宿主机沙箱为
self.docker_readonly_getter = docker_readonly_getter # 只读 profilehost_sandbox_runner shell plan 的 readonly 参数);
# 已存在的会话不受影响(权限跨界切换时由 set_permission_mode 销毁重建)。
self.terminal_readonly_getter = terminal_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")
@ -163,8 +165,11 @@ 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) readonly_getter = getattr(self, "terminal_readonly_getter", None)
options["docker_readonly_exec"] = bool(readonly_getter and readonly_getter()) readonly_terminal = bool(readonly_getter and readonly_getter())
options["docker_readonly_exec"] = readonly_terminal
# 宿主机沙箱持久终端同身份:受限档(非 unrestricted以只读 profile 创建
options["host_terminal_readonly"] = readonly_terminal
if self.container_session and self.container_session.mode == "docker": 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