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:
parent
8257e4d34d
commit
b55f4a811d
@ -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 Desktop(virtiofs fakeowner)不执行 uid 权限,仅 Linux 宿主生效
|
- 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock(云端已验证);macOS Docker Desktop(virtiofs fakeowner)不执行 uid 权限,仅 Linux 宿主生效
|
||||||
- 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`docker_terminal_readonly_enabled` 判定,`docker_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道
|
- 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道;权限跨界切换(受限档⇄unrestricted)时销毁现有终端会话重建(`_apply_restricted_execution_mode_link` → `close_all()`)
|
||||||
- Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益
|
- 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 最小根文件系统(白名单)**——命名空间内只有系统目录+工作区,天然符合
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -172,22 +172,19 @@ 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 = (
|
||||||
"- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n"
|
"- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n"
|
||||||
@ -196,13 +193,9 @@ 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 = ""
|
||||||
|
|
||||||
|
|||||||
@ -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,19 +464,8 @@ 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():
|
return {"allowed": True, "mode": mode, "requires_approval": False}
|
||||||
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}
|
||||||
|
|||||||
@ -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.py);unrestricted 保持 root。
|
(内核 DAC 强制只读,见 modules/docker_readonly_exec.py),
|
||||||
|
宿主机沙箱为只读 profile(macOS Seatbelt 白名单只读 / Linux ro-bind
|
||||||
|
/ Windows WSL 只读挂载,见 host_sandbox_runner 的 shell plan
|
||||||
|
readonly 参数);unrestricted 保持可写身份(docker root /
|
||||||
|
宿主机可写 profile)。权限跨界切换(受限档⇄unrestricted)时
|
||||||
|
现有终端会被销毁,下次以新身份重建。
|
||||||
"""
|
"""
|
||||||
return self.get_permission_mode() != "unrestricted"
|
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),
|
||||||
|
跨界切换时销毁现有终端会话,强制下次以新身份重建;readonly⇄approval
|
||||||
|
互切身份不变,不销毁。与执行环境切换销毁(terminal_manager.set_host_execution_mode)同策略。
|
||||||
"""
|
"""
|
||||||
|
if entering or leaving:
|
||||||
|
try:
|
||||||
|
tm = getattr(self, "terminal_manager", None)
|
||||||
|
if tm is not None and hasattr(tm, "close_all"):
|
||||||
|
tm.close_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if entering:
|
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":
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 只读挂载):受限权限档(只读/批准/自动审核)的终端以此创建,
|
||||||
|
写入由系统直接拒绝(EPERM);unrestricted 档传 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 不可读时会 fatal(PoC 实测),指向 /dev/null 跳过全局配置
|
||||||
|
plan_env = dict(env)
|
||||||
|
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
||||||
|
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _macos_readonly_profile_for_workspace(
|
||||||
|
work_path: Path,
|
||||||
|
network_permission: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""macOS 只读沙箱 profile(deny-default 白名单读模型,2026-08-30 起)。
|
||||||
|
|
||||||
|
默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读,
|
||||||
|
写权限仅 /dev/null;deny 规则在白名单内做最后排除(如工作区内的 .env)。
|
||||||
|
历史模型为「全局可读 + 敏感路径黑名单」,且 deny 顺序在 workspace allow
|
||||||
|
之前导致工作区内 .env 实际可读(Seatbelt 后规则覆盖先规则),本次一并修复。
|
||||||
|
只读 run_command 与受限档持久终端(shell plan readonly=True)共用本函数。
|
||||||
|
"""
|
||||||
network_policy = _build_macos_network_policy(network_permission)
|
network_policy = _build_macos_network_policy(network_permission)
|
||||||
workspace = str(work_path.resolve())
|
workspace = str(work_path.resolve())
|
||||||
|
|
||||||
# 只读沙箱(2026-08-30 起):deny-default 白名单读模型。
|
|
||||||
# 默认全部不可读,仅系统路径白名单 + 路径授权(可写+仅可读)+ 工作区可读,
|
|
||||||
# 写权限仅 /dev/null;deny 规则在白名单内做最后排除(如工作区内的 .env)。
|
|
||||||
# 历史模型为「全局可读 + 敏感路径黑名单」,且 deny 顺序在 workspace allow
|
|
||||||
# 之前导致工作区内 .env 实际可读(Seatbelt 后规则覆盖先规则),本次一并修复。
|
|
||||||
readable_paths = list(MACOS_MINIMAL_READABLE_PATHS)
|
readable_paths = 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,22 +307,23 @@ 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 不可读时会 fatal(PoC 实测),指向 /dev/null 跳过全局配置
|
|
||||||
plan_env = dict(env)
|
|
||||||
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
||||||
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
|
|
||||||
return SandboxPlan(command=cmd, env=plan_env, cwd=str(work_path))
|
|
||||||
|
|
||||||
|
|
||||||
def _build_macos_shell_plan(
|
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"))
|
||||||
profile = _macos_profile_for_workspace(work_path, network_permission)
|
# 受限档终端以只读身份创建(与只读 run_command 同一 profile),写入 EPERM;
|
||||||
|
# unrestricted 保持可写 profile(白名单读 + 工作区/授权路径可写)。
|
||||||
|
if readonly:
|
||||||
|
profile = _macos_readonly_profile_for_workspace(work_path, network_permission)
|
||||||
|
else:
|
||||||
|
profile = _macos_profile_for_workspace(work_path, network_permission)
|
||||||
# 同 _build_macos_plan:白名单读下 git 需要 GIT_CONFIG_GLOBAL 兜底
|
# 同 _build_macos_plan:白名单读下 git 需要 GIT_CONFIG_GLOBAL 兜底
|
||||||
plan_env = dict(env)
|
plan_env = dict(env)
|
||||||
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
|
plan_env.setdefault("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 = ()
|
||||||
|
|||||||
@ -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
|
# 只读 profile(host_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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user