fix(permission,docker): enforce docker-specific approval flow and polish permission menu layout

- Add docker risk markers config (config/docker_risk_markers.json) and load it in tools permission evaluation.

- In docker mode, pre-evaluate run_command/terminal_input: readonly-safe and no-risk -> direct run; otherwise require approval/auto-approval.

- Keep docker write_file/edit_file behavior aligned with agreed policy: approval requires review, auto_approval passes directly.

- Pass risk markers into auto approval payload and include current working directory context for approval agent.

- Fix permission dropdown layout: only use split two-column menu when execution mode section is enabled; remove empty right blank area in docker mode.

- Center mobile approval overlay to prevent sheet overflow on small screens.
This commit is contained in:
JOJO 2026-05-11 22:56:10 +08:00
parent 8d665ad6de
commit 2ec226b5eb
6 changed files with 147 additions and 2 deletions

View File

@ -0,0 +1,35 @@
{
"risk_markers": [
"rm",
"rmdir",
"mv",
"cp",
"dd",
"chmod",
"chown",
"chgrp",
"sudo",
"su",
"passwd",
"useradd",
"userdel",
"curl",
"wget",
"nc",
"ncat",
"scp",
"rsync",
".env",
".ssh",
"id_rsa",
"id_ed25519",
".aws",
"token",
"secret",
">/",
">>",
"| sh",
"| bash",
"| zsh"
]
}

View File

@ -97,6 +97,58 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
class MainTerminalToolsExecutionMixin:
_DEFAULT_DOCKER_RISK_MARKERS = {
"rm", "rmdir", "mv", "cp", "dd", "chmod", "chown", "chgrp",
"sudo", "su", "passwd", "useradd", "userdel",
"curl", "wget", "nc", "ncat", "scp", "rsync",
".env", ".ssh", "id_rsa", "id_ed25519", ".aws", "token", "secret",
">/", ">>", "| sh", "| bash", "| zsh",
}
def _is_docker_runtime(self) -> bool:
session = getattr(self, "container_session", None)
return bool(session and getattr(session, "mode", None) == "docker")
def _extract_command_risk_markers(self, command: Any) -> List[str]:
cmd = str(command or "")
lowered = cmd.lower()
markers = self._load_docker_risk_markers()
hits: List[str] = []
for marker in markers:
if marker in lowered:
hits.append(marker)
# 去重并保持顺序
dedup: List[str] = []
seen = set()
for m in hits:
if m in seen:
continue
seen.add(m)
dedup.append(m)
return dedup
def _load_docker_risk_markers(self) -> List[str]:
cache = getattr(self, "_docker_risk_markers_cache", None)
if isinstance(cache, list) and cache:
return cache
config_path = Path(__file__).resolve().parents[2] / "config" / "docker_risk_markers.json"
markers: List[str] = []
try:
if config_path.exists():
data = json.loads(config_path.read_text(encoding="utf-8"))
raw = data.get("risk_markers") if isinstance(data, dict) else None
if isinstance(raw, list):
for item in raw:
text = str(item or "").strip().lower()
if text:
markers.append(text)
except Exception:
markers = []
if not markers:
markers = sorted(self._DEFAULT_DOCKER_RISK_MARKERS)
self._docker_risk_markers_cache = markers
return markers
@staticmethod
def _mcp_disabled_message() -> str:
return "当前为docker模式MCP仅支持宿主机模式"
@ -332,9 +384,30 @@ class MainTerminalToolsExecutionMixin:
if mode == "approval":
if tool_name == "run_command":
if self._is_docker_runtime():
command_text = args.get("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,
}
return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name == "terminal_input":
command_text = args.get("command") or args.get("input")
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}
if tool_name in self._APPROVAL_REQUIRED_TOOLS:
@ -343,6 +416,8 @@ class MainTerminalToolsExecutionMixin:
if mode == "auto_approval":
if tool_name in {"write_file", "edit_file"}:
if self._is_docker_runtime():
return {"allowed": True, "mode": mode, "requires_approval": False}
path = args.get("file_path") or args.get("path") or ""
try:
valid, _, full_path = self.file_manager._validate_path(str(path))
@ -355,9 +430,30 @@ class MainTerminalToolsExecutionMixin:
pass
return {"allowed": True, "mode": mode, "requires_approval": True}
if tool_name == "run_command":
if self._is_docker_runtime():
command_text = args.get("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,
}
return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name == "terminal_input":
command_text = args.get("command") or args.get("input")
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}
if tool_name in self._APPROVAL_REQUIRED_TOOLS:

View File

@ -13,6 +13,7 @@ def build_auto_approval_payload(
recent_tool_actions: List[Dict[str, Any]],
function_name: str,
arguments: Dict[str, Any],
risk_markers: Optional[List[str]] = None,
) -> str:
history = list(getattr(getattr(web_terminal, "context_manager", None), "conversation_history", []) or [])
user_input = ""
@ -38,6 +39,11 @@ def build_auto_approval_payload(
lines.append(f"{idx}. {row.get('tool_name')}: {json.dumps(row.get('arguments') or {}, ensure_ascii=False)}")
else:
lines.append("(无)")
lines += [
f"当前工作目录:{('/workspace' if bool(getattr(getattr(web_terminal, 'container_session', None), 'mode', None) == 'docker') else str(getattr(getattr(web_terminal, 'context_manager', None), 'project_path', '')))}",
]
if risk_markers:
lines.append(f"当前操作中触发风险特征的内容:{', '.join([str(x) for x in risk_markers if str(x).strip()])}")
lines += [
"请审核当前操作:",
f"{function_name}: {json.dumps(arguments or {}, ensure_ascii=False)}",
@ -55,6 +61,7 @@ async def run_auto_approval(
recent_tool_actions: List[Dict[str, Any]],
function_name: str,
arguments: Dict[str, Any],
risk_markers: Optional[List[str]],
sender,
) -> Dict[str, Any]:
def _progress(evt: Dict[str, Any]) -> None:
@ -73,6 +80,7 @@ async def run_auto_approval(
recent_tool_actions=recent_tool_actions,
function_name=function_name,
arguments=arguments,
risk_markers=risk_markers,
)
agent = ApprovalAgent(web_terminal=web_terminal)
result = await agent.review(payload_text=payload, progress_cb=_progress, cancel_check=_manual_takeover)
@ -89,4 +97,3 @@ async def run_auto_approval(
out = _manual_takeover() or {"decision": "rejected", "reason": "人工接管失败"}
_progress({"stage": "done", "message": "自动审批结束", "decision": (out or {}).get("decision")})
return out

View File

@ -497,6 +497,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
recent_tool_actions=previous_tool_actions,
function_name=function_name,
arguments=arguments,
risk_markers=permission_eval.get("risk_markers") if isinstance(permission_eval, dict) else None,
sender=sender,
)
else:
@ -731,6 +732,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
recent_tool_actions=previous_tool_actions,
function_name=function_name,
arguments=arguments,
risk_markers=permission_eval.get("risk_markers") if isinstance(permission_eval, dict) else None,
sender=sender,
)
else:

View File

@ -183,7 +183,11 @@
<span>权限{{ currentPermissionLabel }}</span>
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }"></span>
</button>
<div v-if="permissionMenuOpen" class="permission-switcher__menu permission-switcher__menu--split">
<div
v-if="permissionMenuOpen"
class="permission-switcher__menu"
:class="{ 'permission-switcher__menu--split': executionModeEnabled }"
>
<div class="permission-switcher__group">
<div class="permission-switcher__group-title">权限</div>
<button

View File

@ -943,6 +943,7 @@ body[data-theme='dark'] {
/* 手机端审批面板样式 - 与电脑端保持一致 */
.mobile-approval-overlay {
pointer-events: auto;
justify-content: center !important;
}
.mobile-approval-overlay .mobile-panel-sheet--approval {