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: if pending_execution:
current_exec = self.get_execution_mode() current_exec = self.get_execution_mode()
if pending_execution != current_exec: if pending_execution != current_exec:
self.set_execution_mode(pending_execution) applied = False
if hasattr(self, "build_runtime_mode_switch_notice"): try:
notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution) self.set_execution_mode(pending_execution)
elif hasattr(self, "_build_execution_mode_switch_notice"): applied = True
notice_text = self._build_execution_mode_switch_notice(pending_execution) except Exception:
else: # 锁定拒绝plan / readonly 下禁止 direct丢弃该 pending保持沙箱
notice_text = f"执行环境被用户修改为 {pending_execution}" applied = False
notices.append({ if applied:
"text": notice_text, if hasattr(self, "build_runtime_mode_switch_notice"):
"source": "execution_change", notice_text = self.build_runtime_mode_switch_notice("execution_mode", pending_execution)
"kind": "execution_mode", elif hasattr(self, "_build_execution_mode_switch_notice"):
"mode": pending_execution, notice_text = self._build_execution_mode_switch_notice(pending_execution)
}) else:
updates["execution_mode"] = pending_execution 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 updates["pending_execution_mode"] = None
self.pending_execution_mode = None self.pending_execution_mode = None
@ -424,6 +431,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
raise ValueError("计划模式下执行环境锁定为沙箱,请先切换运行模式") raise ValueError("计划模式下执行环境锁定为沙箱,请先切换运行模式")
except AttributeError: except AttributeError:
pass 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.host_execution_mode = normalized
self._apply_execution_mode_to_runtime() self._apply_execution_mode_to_runtime()
return self.get_execution_mode_state() return self.get_execution_mode_state()

View File

@ -358,8 +358,14 @@ class MainTerminalToolsPolicyMixin:
raise ValueError("计划模式下权限模式锁定为只读,请先切换运行模式") raise ValueError("计划模式下权限模式锁定为只读,请先切换运行模式")
except AttributeError: except AttributeError:
pass 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 self.current_permission_mode = normalized
if not persist: if not persist:
# 只读联动不依赖持久化:运行中 pending 切换路径apply_pending_runtime_mode_changes
# 也要内存级强制沙箱,只是不落 metadata无记录则切离时保持沙箱安全默认
self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=False)
return normalized return normalized
conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None)
@ -372,8 +378,57 @@ class MainTerminalToolsPolicyMixin:
self.context_manager.conversation_metadata["permission_mode"] = normalized self.context_manager.conversation_metadata["permission_mode"] = normalized
except Exception: except Exception:
pass pass
self._apply_readonly_execution_mode_link(entering_readonly, leaving_readonly, persist=True)
return normalized 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: def set_tool_category_enabled(self, category: str, enabled: bool) -> None:
"""设置工具类别的启用状态 / Toggle tool category enablement.""" """设置工具类别的启用状态 / Toggle tool category enablement."""
categories = self.tool_categories_map categories = self.tool_categories_map

View File

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

View File

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

View File

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