feat(permission): 只读权限联动执行环境锁定沙箱

- set_permission_mode 进入 readonly 时强制切 sandbox(存 pre_readonly_execution_mode 供切离恢复)
- set_execution_mode 新增 readonly 锁(拦 direct,与 plan 锁并列)
- apply_pending_runtime_mode_changes 执行环境应用加 try/except 防御
- POST /api/permission-mode 响应带 execution state
- 前端执行环境组 readonly 时禁用+锁定标记,权限切换后同步执行环境
This commit is contained in:
JOJO 2026-08-13 15:24:24 +08:00
parent ec294e2545
commit 8d10effd56
5 changed files with 107 additions and 19 deletions

View File

@ -345,20 +345,27 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
if pending_execution:
current_exec = self.get_execution_mode()
if pending_execution != current_exec:
self.set_execution_mode(pending_execution)
if hasattr(self, "build_runtime_mode_switch_notice"):
notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution)
elif hasattr(self, "_build_execution_mode_switch_notice"):
notice_text = self._build_execution_mode_switch_notice(pending_execution)
else:
notice_text = f"执行环境被用户修改为 {pending_execution}"
notices.append({
"text": notice_text,
"source": "execution_change",
"kind": "execution_mode",
"mode": pending_execution,
})
updates["execution_mode"] = pending_execution
applied = False
try:
self.set_execution_mode(pending_execution)
applied = True
except Exception:
# 锁定拒绝plan / readonly 下禁止 direct丢弃该 pending保持沙箱
applied = False
if applied:
if hasattr(self, "build_runtime_mode_switch_notice"):
notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution)
elif hasattr(self, "_build_execution_mode_switch_notice"):
notice_text = self._build_execution_mode_switch_notice(pending_execution)
else:
notice_text = f"执行环境被用户修改为 {pending_execution}"
notices.append({
"text": notice_text,
"source": "execution_change",
"kind": "execution_mode",
"mode": pending_execution,
})
updates["execution_mode"] = pending_execution
updates["pending_execution_mode"] = None
self.pending_execution_mode = None
@ -424,6 +431,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
raise ValueError("计划模式下执行环境锁定为沙箱,请先切换运行模式")
except AttributeError:
pass
# 只读模式下执行环境同样锁死为沙箱(与 plan 锁并列):
# 切到 readonly 权限时由 set_permission_mode 联动强制沙箱,
# 这里拦直接调用API/队列)防止只读期间切回 direct 绕过 OS 沙箱硬限制。
if normalized == "direct" and getattr(self, "get_permission_mode", None):
try:
if self.get_permission_mode() == "readonly":
raise ValueError("只读模式下执行环境锁定为沙箱,请先切换权限模式")
except AttributeError:
pass
self.host_execution_mode = normalized
self._apply_execution_mode_to_runtime()
return self.get_execution_mode_state()

View File

@ -358,8 +358,14 @@ class MainTerminalToolsPolicyMixin:
raise ValueError("计划模式下权限模式锁定为只读,请先切换运行模式")
except AttributeError:
pass
previous = self.get_permission_mode()
entering_readonly = normalized == "readonly" and previous != "readonly"
leaving_readonly = previous == "readonly" and normalized != "readonly"
self.current_permission_mode = normalized
if not persist:
# 只读联动不依赖持久化:运行中 pending 切换路径apply_pending_runtime_mode_changes
# 也要内存级强制沙箱,只是不落 metadata无记录则切离时保持沙箱安全默认
self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=False)
return normalized
conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None)
@ -372,8 +378,57 @@ 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)
return normalized
def _apply_readonly_execution_mode_link(self, entering: bool, leaving: bool, *, persist: bool) -> None:
"""只读权限 ⇄ 执行环境联动:进入 readonly 强制切沙箱,切离恢复进入前执行环境。
plan readonly+sandbox 双锁逻辑对称只读权限在宿主机依赖 OS 沙箱硬限制
direct完全访问下无沙箱只读形同虚设必须一并锁回沙箱
进入 readonly 时若执行环境为 direct先存 pre_readonly_execution_mode 供切离时恢复
切离 readonly 时仅当有明确进入前记录才恢复无记录保持 sandbox安全默认
"""
if entering:
try:
if hasattr(self, "get_execution_mode") and self.get_execution_mode() == "direct":
if persist and hasattr(self, "_persist_runtime_mode_metadata"):
try:
self._persist_runtime_mode_metadata({"pre_readonly_execution_mode": "direct"})
except Exception:
pass
if hasattr(self, "set_execution_mode"):
self.set_execution_mode("sandbox")
if persist and hasattr(self, "_persist_runtime_mode_metadata"):
try:
self._persist_runtime_mode_metadata({"execution_mode": "sandbox"})
except Exception:
pass
except Exception:
pass
return
if leaving:
try:
meta = getattr(getattr(self, "context_manager", None), "conversation_metadata", None) or {}
pre_exec = str(meta.get("pre_readonly_execution_mode") or "").strip().lower()
except Exception:
pre_exec = ""
if pre_exec == "direct" and hasattr(self, "set_execution_mode"):
try:
self.set_execution_mode("direct")
if persist and hasattr(self, "_persist_runtime_mode_metadata"):
try:
self._persist_runtime_mode_metadata({"execution_mode": "direct", "pre_readonly_execution_mode": None})
except Exception:
pass
except Exception:
pass
elif persist and hasattr(self, "_persist_runtime_mode_metadata"):
try:
self._persist_runtime_mode_metadata({"pre_readonly_execution_mode": None})
except Exception:
pass
def set_tool_category_enabled(self, category: str, enabled: bool) -> None:
"""设置工具类别的启用状态 / Toggle tool category enablement."""
categories = self.tool_categories_map

View File

@ -195,6 +195,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
"pending_mode": target_mode,
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
"state": (terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else None),
"message": "权限模式将在当前工具执行完成后生效",
})
@ -226,6 +227,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
"pending_mode": None,
"options": PERMISSION_MODE_OPTIONS,
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
"state": (terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else None),
"message": "权限模式已更新并立即生效",
})

View File

@ -85,6 +85,11 @@ export const permissionMethods = {
if (typeof payload?.mode === 'string') {
this.currentPermissionMode = payload.mode;
}
// readonly 联动:后端在切到只读时会强制执行环境切到沙箱,同步前端显示
const execState = payload?.state || {};
if (typeof execState.mode === 'string') {
this.currentExecutionMode = execState.mode;
}
this.pendingPermissionMode = '';
this.uiPushToast({
title: '权限已更新',

View File

@ -494,11 +494,11 @@
type="button"
class="permission-switcher__btn"
:disabled="!isConnected"
:title="permissionLockedByPlan ? '计划模式下权限与执行环境已锁定,网络权限仍可调整' : ''"
:title="permissionLockedByPlan ? '计划模式下权限与执行环境已锁定,网络权限仍可调整' : (executionLockedByReadonly ? '只读模式下执行环境已锁定为沙箱' : '')"
@click="$emit('toggle-permission-menu')"
>
<svg
v-if="permissionLockedByPlan"
v-if="permissionLockedByPlan || executionLockedByReadonly"
class="permission-switcher__lock"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@ -551,10 +551,12 @@
<div
v-if="executionModeEnabled"
class="permission-switcher__group"
:class="{ 'permission-switcher__group--disabled': permissionLockedByPlan }"
:class="{ 'permission-switcher__group--disabled': executionModeLocked }"
>
<div class="permission-switcher__group-title">
执行环境<span v-if="permissionLockedByPlan" class="permission-switcher__group-lock">计划模式锁定</span>
执行环境
<span v-if="permissionLockedByPlan" class="permission-switcher__group-lock">计划模式锁定</span>
<span v-else-if="executionLockedByReadonly" class="permission-switcher__group-lock">只读模式锁定</span>
</div>
<button
v-for="option in executionModeOptions"
@ -562,7 +564,7 @@
type="button"
class="permission-switcher__item"
:class="{ active: option.value === currentExecutionMode }"
:disabled="permissionLockedByPlan"
:disabled="executionModeLocked"
@click="$emit('change-execution-mode', option.value)"
>
<span
@ -2960,6 +2962,14 @@ const workModeLabel = computed(() => {
/** 计划模式下权限被锁定为只读UI 禁用 + 后端强制) */
const permissionLockedByPlan = computed(() => props.currentWorkMode === 'plan');
/** 只读模式下执行环境锁定为沙箱UI 禁用 + 后端强制) */
const executionLockedByReadonly = computed(() => props.currentPermissionMode === 'readonly');
/** 执行环境组禁用条件plan 全锁 或 readonly 锁执行环境(权限组仅受 plan 锁) */
const executionModeLocked = computed(
() => permissionLockedByPlan.value || executionLockedByReadonly.value
);
const currentExecutionShortLabel = computed(() => {
const mode = String(props.currentExecutionMode || '');
if (mode === 'direct') return '完全访问';