feat(permission): add auto-approval mode with approval agent, UI flow, and docs sync

Implemented a new permission mode  between  and , including backend policy, runtime behavior, frontend display, and documentation updates.

Backend & policy changes:

- Added  to permission mode validation and persistence paths.

- Updated tool permission evaluation: workspace-local  direct pass, out-of-workspace requires approval.

- Kept  readonly-first flow and added auto-approval decision path when permission denial occurs.

- Added approval reason/decider support in tool approval manager.

- Improved rejection tool-content format to natural language: '工具调用被拒绝\n原因:...'

- Fixed permission-mode sync edge cases on conversation create/load and restart-first-switch behavior.

Approval agent subsystem:

- Added  and .

- Added lightweight auto-approval config at  (name/url/key/model/extra_params + timeouts).

- Added optional transcript debug switch in code and transcript output under logs/approval_agent.

- Aligned transcript saving toward cumulative messages format and captured reasoning/content/tool_calls/tool sequence.

Frontend changes:

- Added  option to personalization and permission menus.

- Reworked approval panel auto-review display into a dedicated block with simplified lines: start, command, final approve/reject + reason.

- Added delayed sidebar auto-close (10s) after approval resolved.

- Added stricter permission-switch verification and rollback on mismatch/error.

Docs updated:

- Synced AGENTS.md, README.md, and docs/host_sandbox_and_permission_model.md with new permission mode, approval-agent behavior, config, and current constraints (including docker caveat).
This commit is contained in:
JOJO 2026-05-11 17:55:27 +08:00
parent 02d10ec7bb
commit 8d665ad6de
22 changed files with 743 additions and 47 deletions

View File

@ -161,7 +161,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
为避免误解,当前系统有两套独立但叠加的控制:
1. **权限模式Permission Mode**`readonly` / `approval` / `unrestricted`
1. **权限模式Permission Mode**`readonly` / `approval` / `auto_approval` / `unrestricted`
2. **执行环境Execution Mode**`sandbox` / `direct`(仅宿主机模式可切换)
### 10.1 权限模式
@ -173,6 +173,10 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- `run_command` 先走只读沙箱
- 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批
- 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果”
- `auto_approval`
- `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程
- `run_command` 先走只读沙箱;触发权限拒绝后由后台审批智能体自动审核
- 自动审核拒绝时,工具返回“被拒绝+理由”并继续主循环(不强制结束任务)
- `unrestricted`
- 不做权限模式拦截;是否沙箱由执行环境决定
@ -195,3 +199,12 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等)
- 文案应面向用户能力边界与操作建议,不描述内部判定算法
### 10.5 自动审批智能体配置与调试
- 自动审批配置文件:`config/auto_approval.json`
- 建议字段:`name` / `url` / `key` / `model` / `extra_params`
- 额外控制:`timeout_seconds` / `max_rounds` / `max_command_timeout`
- 调试开关(代码变量):`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`
- 开启后写入:`logs/approval_agent/`
- 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式

View File

@ -136,12 +136,18 @@ HOST_WORKSPACES_FILE=./config/host_workspaces.json
- **权限模式Permission Mode**
- `readonly``run_command` 在只读沙箱执行;写入会被系统拒绝
- `approval``run_command` 先按只读沙箱执行;若遇权限拒绝,再触发前端审批;审批通过后仅该次命令以可写沙箱重试
- `auto_approval`:工作区内 `write_file/edit_file` 直通;高风险操作由后台审批智能体自动审核。`run_command` 仍先只读执行,遇权限拒绝后再自动审批
- `unrestricted`:不做权限模式拦截(仍受执行环境约束)
- **路径授权Path Authorization**
- 可配置两类路径:`可读可写` 与 `仅可读`
- 语义:`可读集合 = 可读可写 + 仅可读``可写集合 = 可读可写`
- 默认建议:仅工作区 + 临时目录可写,其余按需白名单
- **自动审批智能体Auto Approval Agent**
- 配置文件:`config/auto_approval.json`
- 核心字段:`name` / `url` / `key` / `model` / `extra_params`
- 调试记录:可在 `modules/approval_agent.py` 打开 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`,输出到 `logs/approval_agent/`
`HOST_WORKSPACES_FILE` 对应 JSON 示例:
```json

14
config/auto_approval.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "auto-approval-agent",
"url": "https://api.deepseek.com",
"key": "${API_KEY_DEEPSEEK}",
"model": "deepseek-v4-flash",
"extra_params": {
"thinking": {
"type": "enabled"
}
},
"timeout_seconds": 60,
"max_rounds": 3,
"max_command_timeout": 60
}

View File

@ -93,12 +93,14 @@ class MainTerminalContextMixin:
"unrestricted": "当前处于无限制模式,所有工具均可直接使用,无需额外批准。",
"readonly": "当前处于只读模式,仅能执行不会修改工作区的读取类操作。",
"approval": "当前处于批准模式,修改工作区的操作需要用户批准后方可执行。",
"auto_approval": "当前处于自动审核模式,工作区内文件编辑可直接执行,高风险操作由后台审批智能体自动审核。",
}
_PERMISSION_MODE_LABEL = {
"unrestricted": "无限制",
"readonly": "只读",
"approval": "批准",
"auto_approval": "自动审核",
}
_EXECUTION_MODE_LABEL = {
@ -135,6 +137,14 @@ class MainTerminalContextMixin:
"- 需要用户批准terminal_input、run_python、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n"
"- 被拒绝或超时:本次操作不会执行写入"
)
elif mode == "auto_approval":
detailed_rules = (
"- write_file / edit_file若路径在当前工作区内直接执行工作区外路径会进入审批流程\n"
"- run_command先在系统只读沙箱执行仅当出现权限拒绝时才触发自动审批\n"
"- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n"
"- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n"
"- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准"
)
else:
detailed_rules = ""

View File

@ -341,6 +341,29 @@ class MainTerminalToolsExecutionMixin:
return {"allowed": True, "mode": mode, "requires_approval": True}
return {"allowed": True, "mode": mode}
if mode == "auto_approval":
if tool_name in {"write_file", "edit_file"}:
path = args.get("file_path") or args.get("path") or ""
try:
valid, _, full_path = self.file_manager._validate_path(str(path))
if valid and full_path is not None:
project_root = Path(self.context_manager.project_path).resolve()
resolved = Path(full_path).resolve()
if resolved == project_root or project_root in resolved.parents:
return {"allowed": True, "mode": mode, "requires_approval": False}
except Exception:
pass
return {"allowed": True, "mode": mode, "requires_approval": True}
if tool_name == "run_command":
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_readonly_run_command_allowed(command_text):
return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name in self._APPROVAL_REQUIRED_TOOLS:
return {"allowed": True, "mode": mode, "requires_approval": True}
return {"allowed": True, "mode": mode}
return {"allowed": True, "mode": mode}
@staticmethod
@ -1272,7 +1295,7 @@ class MainTerminalToolsExecutionMixin:
write_granted_once = bool(arguments.get("_approval_write_granted", False))
sandbox_write_access = not (
permission_mode == "readonly"
or (permission_mode == "approval" and not write_granted_once)
or (permission_mode in {"approval", "auto_approval"} and not write_granted_once)
)
run_in_background = bool(arguments.get("run_in_background", False))
timeout_value = arguments.get("timeout")

View File

@ -84,7 +84,7 @@ from config.model_profiles import (
logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
class MainTerminalToolsPolicyMixin:
def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]:
@ -209,7 +209,7 @@ class MainTerminalToolsPolicyMixin:
def set_permission_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> str:
normalized = str(mode or "").strip().lower()
if normalized not in PERMISSION_MODES:
raise ValueError("无效权限模式,仅支持 readonly / approval / unrestricted")
raise ValueError("无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted")
self.current_permission_mode = normalized
if not persist:
return normalized

View File

@ -130,7 +130,13 @@ class WebTerminal(MainTerminal):
logger.warning("忽略无效默认模型 %s: %s", preferred_model, exc)
preferred_mode = prefs.get("default_run_mode")
preferred_permission_mode = prefs.get("default_permission_mode") or "unrestricted"
preferred_permission_mode = None
try:
preferred_permission_mode = self.get_permission_mode()
except Exception:
preferred_permission_mode = None
if not isinstance(preferred_permission_mode, str) or not preferred_permission_mode.strip():
preferred_permission_mode = prefs.get("default_permission_mode") or "unrestricted"
if isinstance(preferred_mode, str) and preferred_mode.lower() in {"fast", "thinking", "deep"}:
try:
self.set_run_mode(preferred_mode.lower())
@ -210,6 +216,12 @@ class WebTerminal(MainTerminal):
self.thinking_mode = mode
self.api_client.thinking_mode = mode
self.api_client.start_new_task()
permission_mode = str(meta.get("permission_mode") or "").strip().lower()
if permission_mode:
try:
self.set_permission_mode(permission_mode, persist=False)
except Exception:
pass
except Exception:
pass
# 重置相关状态

View File

@ -29,6 +29,7 @@
- `readonly`
- `approval`
- `auto_approval`
- `unrestricted`
作用:决定工具是否允许调用、是否先只读执行、是否需要用户审批。
@ -89,7 +90,19 @@
- 工具结果返回“最终执行结果”(即重试后结果),不会把中间权限拒绝作为最终结果返回给模型
- 审批拒绝或超时:返回 rejected不执行写入重试
## 5.3 unrestricted
## 5.3 auto_approval
- `write_file` / `edit_file`
- 目标路径在当前工作区内:直接执行
- 目标路径不在当前工作区内:进入审批流程
- `run_command` 采用“两段式”:
1) 先在只读沙箱执行
2) 若出现权限拒绝,触发自动审批(审批智能体)
3) 自动审批通过后,仅该次命令可写重试
- 自动审批拒绝:返回“工具调用被拒绝 + 原因”,主智能体继续下一步(不强制中断整轮任务)
- 用户可随时人工接管审批结果(同意/拒绝/切换无限制)
## 5.4 unrestricted
- 权限模式层不拦截工具
- 是否沙箱执行取决于执行环境:
@ -121,6 +134,7 @@
- 支持权限模式联动readonly/approval/unrestricted
- 在 host + sandbox 下可走只读沙箱或可写沙箱
- 在 approval 模式可触发“权限拒绝 -> 审批 -> 单次可写重试”
- 在 auto_approval 模式可触发“权限拒绝 -> 自动审批 -> 单次可写重试”
### 7.2 run_python
@ -140,6 +154,19 @@
- `direct`:直接宿主机启动
- Docker 模式维持容器执行
### 7.5 自动审批approval agent
- 自动审批由独立审批智能体执行(与主智能体分离)
- 配置文件:`config/auto_approval.json`
- `name` / `url` / `key` / `model` / `extra_params`
- `timeout_seconds` / `max_rounds` / `max_command_timeout`
- 审批智能体可调用能力:
- `run_command`(用于只读核查)
- 审批决策工具approved / rejected + reason
- 调试开关(代码变量):
- `modules/approval_agent.py``DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`
- 开启后记录到 `logs/approval_agent/`
---
## 8. 配置与运行建议
@ -167,6 +194,7 @@
1. `run_command` 若严格收紧读路径,可能导致大量系统命令可用性下降
2. 权限拒绝识别基于错误特征,需持续优化误判/漏判边界
3. 不同 OS 的沙箱能力不完全对齐Windows 侧为 WSL2 路径)
4. Docker 模式下目前不存在与宿主机同等的 OS 只读沙箱机制;`sandbox_write_access=False` 不会提供同等级只读隔离(需后续补充容器侧只读策略)
---
@ -175,4 +203,3 @@
- 面向用户的提示词仅描述“能力边界”和“可操作建议”
- 不暴露内部实现细节(如内部判定逻辑、实现细节名称)
- 审批流程文案要强调“单次授权、最小权限、可回退”

323
modules/approval_agent.py Normal file
View File

@ -0,0 +1,323 @@
from __future__ import annotations
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import httpx
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "auto_approval.json"
DEFAULT_MAX_ROUNDS = 3
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_COMMAND_TIMEOUT = 20
DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True
DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "approval_agent"
def _resolve_env_token(value: str) -> str:
text = str(value or "").strip()
if text.startswith("${") and text.endswith("}"):
key = text[2:-1].strip()
return os.environ.get(key, "")
return text
def load_approval_agent_config() -> Dict[str, Any]:
base = {
"name": "auto-approval-agent",
"url": "",
"key": "",
"model": "deepseek-v4-flash",
"extra_params": {},
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
"max_rounds": DEFAULT_MAX_ROUNDS,
"max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT,
}
try:
if not CONFIG_PATH.exists():
return base
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(raw, dict):
base.update(raw)
base["url"] = _resolve_env_token(base.get("url", ""))
base["key"] = _resolve_env_token(base.get("key", ""))
base["model"] = str(base.get("model") or "deepseek-v4-flash").strip()
base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {}
base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
base["max_rounds"] = max(1, int(base.get("max_rounds") or DEFAULT_MAX_ROUNDS))
base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT))
return base
except Exception:
return base
class ApprovalAgent:
def __init__(self, *, web_terminal: Any):
self.web_terminal = web_terminal
self.cfg = load_approval_agent_config()
@staticmethod
def _system_prompt() -> str:
return (
"你是审批智能体,只负责风险与越权判断,不判断任务必要性。"
"审批模式允许正常文件修改,因此“写入/删除”不能一概拒绝,应结合用户输入意图判断。"
"若操作涉及凭据读取(如 .env、密钥文件、token、越权访问工作区外敏感目录"
"危险破坏(如 rm -rf、权限变更、系统级配置修改、可疑网络外传"
"且用户输入并未明确要求这些行为,则应拒绝。"
"需要特别注意:提权命令、删除系统文件、批量破坏性 git 操作、访问 SSH/GPG/AWS 凭据目录。"
"你可最多使用少量只读检查尽量在3轮内完成然后必须调用 approve_decision 给出 approved/rejected + reason。"
)
async def _run_readonly_command(self, command: str) -> Dict[str, Any]:
work_path = Path(getattr(self.web_terminal.context_manager, "project_path", "."))
timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)
try:
result = await self.web_terminal.terminal_ops.run_command(
command=command,
working_dir=str(work_path),
timeout=timeout,
sandbox_write_access=False,
)
return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"}
except Exception as exc:
return {"success": False, "error": str(exc)}
async def review(
self,
*,
payload_text: str,
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
) -> Dict[str, Any]:
debug_transcript: List[Dict[str, Any]] = []
def _trace(event: str, payload: Dict[str, Any]) -> None:
if not DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT:
return
debug_transcript.append({
"ts": int(time.time() * 1000),
"event": event,
"payload": payload,
})
def _flush_trace(final_result: Dict[str, Any]) -> None:
if not DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT:
return
try:
DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
row = {
"created_at": int(time.time() * 1000),
"messages": messages,
}
file = DEBUG_TRANSCRIPT_DIR / f"approval_{int(time.time() * 1000)}.json"
file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
url = str(self.cfg.get("url") or "").strip()
key = str(self.cfg.get("key") or "").strip()
if not url or not key:
out = {"decision": "rejected", "reason": "审批智能体配置缺失", "source": "approval_agent"}
_flush_trace(out)
return out
endpoint = f"{url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
max_rounds = int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
extra_params = dict(self.cfg.get("extra_params") or {})
tools = [
{
"type": "function",
"function": {
"name": "run_command",
"description": (
"在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、"
"查看 git 状态与差异、确认路径是否存在、收集审批判断所需证据。"
"禁止用于写入、删除、改权限或其他修改性操作。"
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": (
"要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、"
"rg、git status、git diff、pwd、stat 等。"
),
}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "approve_decision",
"description": (
"提交最终审批结果并结束本次审批流程。只能返回 approved 或 rejected"
"并提供简洁、可审计的理由。"
),
"parameters": {
"type": "object",
"properties": {
"decision": {
"type": "string",
"enum": ["approved", "rejected"],
"description": "审批结论approved 表示同意执行rejected 表示拒绝执行。",
},
"reason": {
"type": "string",
"description": (
"审批理由。应明确风险点或放行依据,例如是否涉及凭据访问、越权路径、"
"破坏性命令、以及是否与用户输入意图一致。"
),
},
},
"required": ["decision", "reason"],
},
},
},
]
messages: List[Dict[str, Any]] = [
{"role": "system", "content": self._system_prompt()},
{"role": "user", "content": payload_text},
]
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
rounds = 0
while rounds < max_rounds + 1:
rounds += 1
if cancel_check:
external = cancel_check()
if external:
return external
if progress_cb:
progress_cb({"stage": "model_call", "round": rounds, "message": f"审批轮次 {rounds}"})
req = {
"model": self.cfg.get("model"),
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.0,
**extra_params,
}
_trace("request", {"round": rounds, "request": req})
try:
resp = await client.post(endpoint, headers=headers, json=req)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
body = ""
try:
body = exc.response.text
except Exception:
body = str(exc)
_trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body})
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params
if extra_params:
retry_req = {
"model": self.cfg.get("model"),
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.0,
}
_trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req})
retry_resp = await client.post(endpoint, headers=headers, json=retry_req)
try:
retry_resp.raise_for_status()
resp = retry_resp
except httpx.HTTPStatusError:
out = {
"decision": "rejected",
"reason": f"审批智能体请求失败({retry_resp.status_code})",
"source": "approval_agent",
}
_flush_trace(out)
return out
else:
out = {
"decision": "rejected",
"reason": f"审批智能体请求失败({exc.response.status_code if exc.response else 'unknown'})",
"source": "approval_agent",
}
_flush_trace(out)
return out
choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {}
reasoning_content = (
choice.get("reasoning_content")
or choice.get("reasoning")
or choice.get("thinking")
or ""
)
_trace(
"response",
{
"round": rounds,
"message": choice,
"reasoning_content": reasoning_content,
},
)
tool_calls = choice.get("tool_calls") or []
content = choice.get("content")
if not tool_calls:
if content or reasoning_content:
messages.append(
{
"role": "assistant",
"content": str(content or ""),
"reasoning_content": str(reasoning_content or ""),
}
)
if rounds > max_rounds:
messages.append({"role": "user", "content": "请立刻根据当前已知信息决定:同意或拒绝,并给出理由。"})
continue
out = {"decision": "rejected", "reason": "审批智能体未产出可执行决策", "source": "approval_agent"}
_flush_trace(out)
return out
# 有 tool_calls 时追加一条完整 assistant 消息(与主智能体结构对齐)
assistant_message = {
"role": "assistant",
"content": str(content or ""),
"reasoning_content": str(reasoning_content or ""),
"tool_calls": tool_calls,
}
messages.append(assistant_message)
for call in tool_calls:
fn = (call.get("function") or {}).get("name")
raw_args = (call.get("function") or {}).get("arguments") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
except Exception:
args = {}
if fn == "approve_decision":
decision = str(args.get("decision") or "").strip().lower()
reason = str(args.get("reason") or "").strip() or "无理由"
if decision in {"approved", "rejected"}:
out = {"decision": decision, "reason": reason, "source": "approval_agent"}
_flush_trace(out)
return out
out = {"decision": "rejected", "reason": "审批智能体返回非法决策", "source": "approval_agent"}
_flush_trace(out)
return out
if fn == "run_command":
cmd = str(args.get("command") or "").strip()
if progress_cb:
progress_cb({"stage": "run_command", "round": rounds, "command": cmd})
tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"}
_trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result})
tool_content = json.dumps(tool_result, ensure_ascii=False)
messages.append({
"role": "tool",
"content": tool_content,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + f".{int((time.time()%1)*1000000):06d}",
"message_id": f"msg_{uuid.uuid4().hex}",
"tool_call_id": call.get("id"),
"name": "run_command",
})
out = {"decision": "rejected", "reason": "审批智能体超出最大轮数", "source": "approval_agent"}
_flush_trace(out)
return out

View File

@ -0,0 +1,92 @@
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from modules.approval_agent import ApprovalAgent
from server.state import tool_approval_manager
def build_auto_approval_payload(
*,
web_terminal: Any,
recent_tool_actions: List[Dict[str, Any]],
function_name: str,
arguments: Dict[str, Any],
) -> str:
history = list(getattr(getattr(web_terminal, "context_manager", None), "conversation_history", []) or [])
user_input = ""
for msg in reversed(history):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
metadata = msg.get("metadata") or {}
if bool(metadata.get("is_auto_generated")):
continue
content = msg.get("content")
if isinstance(content, str) and content.strip():
user_input = content.strip()
break
recent = recent_tool_actions[-3:] if isinstance(recent_tool_actions, list) else []
lines = [
"请按照流程审批:",
f"用户输入:{user_input or '(空)'}",
"AI运行的前三步操作",
]
if recent:
for idx, row in enumerate(recent, start=1):
lines.append(f"{idx}. {row.get('tool_name')}: {json.dumps(row.get('arguments') or {}, ensure_ascii=False)}")
else:
lines.append("(无)")
lines += [
"请审核当前操作:",
f"{function_name}: {json.dumps(arguments or {}, ensure_ascii=False)}",
"仅判断危险性和越权风险,不判断任务必要性。",
]
return "\n".join(lines)
async def run_auto_approval(
*,
web_terminal: Any,
username: str,
approval_id: str,
conversation_id: Optional[str],
recent_tool_actions: List[Dict[str, Any]],
function_name: str,
arguments: Dict[str, Any],
sender,
) -> Dict[str, Any]:
def _progress(evt: Dict[str, Any]) -> None:
sender("auto_approval_progress", {"approval_id": approval_id, "progress": evt, "conversation_id": conversation_id})
def _manual_takeover() -> Optional[Dict[str, Any]]:
row = tool_approval_manager.get(approval_id)
status = (row or {}).get("status")
if status in {"approved", "rejected"}:
return {"decision": status, "item": row, "source": "manual"}
return None
_progress({"stage": "start", "message": "自动审批开始"})
payload = build_auto_approval_payload(
web_terminal=web_terminal,
recent_tool_actions=recent_tool_actions,
function_name=function_name,
arguments=arguments,
)
agent = ApprovalAgent(web_terminal=web_terminal)
result = await agent.review(payload_text=payload, progress_cb=_progress, cancel_check=_manual_takeover)
if not _manual_takeover():
decided = tool_approval_manager.decide(
approval_id=approval_id,
username=username,
decision=str(result.get("decision") or "rejected"),
reason=str(result.get("reason") or "").strip(),
decider="approval_agent",
)
out = {"decision": decided.get("status"), "item": decided, "source": "approval_agent"}
else:
out = _manual_takeover() or {"decision": "rejected", "reason": "人工接管失败"}
_progress({"stage": "done", "message": "自动审批结束", "decision": (out or {}).get("decision")})
return out

View File

@ -16,7 +16,7 @@ from core.tool_config import TOOL_CATEGORIES
from config.model_profiles import get_registered_model_keys
ALLOWED_RUN_MODES = {"fast", "thinking", "deep"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
ALLOWED_THEMES = {"classic", "light", "dark"}
PERSONALIZATION_FILENAME = "personalization.json"

View File

@ -60,7 +60,15 @@ class ToolApprovalManager:
rows.sort(key=lambda x: x.get("created_at", 0.0))
return rows
def decide(self, approval_id: str, username: str, decision: str) -> Dict[str, Any]:
def decide(
self,
approval_id: str,
username: str,
decision: str,
*,
reason: Optional[str] = None,
decider: Optional[str] = None,
) -> Dict[str, Any]:
normalized = str(decision or "").strip().lower()
if normalized not in {"approved", "rejected"}:
raise ValueError("decision 仅支持 approved / rejected")
@ -75,5 +83,8 @@ class ToolApprovalManager:
item["status"] = normalized
item["decision"] = normalized
item["decided_at"] = time.time()
if isinstance(reason, str) and reason.strip():
item["reason"] = reason.strip()
if isinstance(decider, str) and decider.strip():
item["decider"] = decider.strip()
return dict(item)

View File

@ -43,7 +43,7 @@ UPLOAD_FOLDER_NAME = "user_upload"
chat_bp = Blueprint('chat', __name__)
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"]
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
@chat_bp.route('/api/thinking-mode', methods=['POST'])
@ -615,7 +615,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
if target_mode not in PERMISSION_MODE_OPTIONS:
return jsonify({
"success": False,
"error": "无效权限模式,仅支持 readonly / approval / unrestricted"
"error": "无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted"
}), 400
try:
applied_mode = terminal.set_permission_mode(target_mode, persist=True)

View File

@ -20,6 +20,7 @@ from utils.tool_result_formatter import (
from utils.context_manager import AUTO_SHALLOW_PLACEHOLDER
from config import TOOL_CALL_COOLDOWN
from modules.personalization_manager import load_personalization_config, resolve_context_compression_settings
from modules.auto_approval_service import run_auto_approval
from .deep_compression import run_deep_compression
@ -177,6 +178,11 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
return any(marker in joined for marker in markers)
def _format_rejected_tool_text(reason: str) -> str:
clean_reason = str(reason or "").strip() or "未提供"
return f"工具调用被拒绝\n原因:{clean_reason}"
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
started = time.time()
while True:
@ -193,6 +199,7 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se
await asyncio.sleep(0.2)
async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None):
previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False)
web_terminal._tool_loop_active = True
@ -205,6 +212,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
allowed_tool_names.add(name)
except Exception as exc:
debug_log(f"构建工具白名单失败(降级继续): {exc}")
recent_tool_actions = list(getattr(web_terminal, "_recent_tool_actions", []) or [])
# 执行每个工具
for tool_call in tool_calls:
@ -409,6 +417,13 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
continue
debug_log(f"执行工具: {function_name} (ID: {tool_call_id})")
previous_tool_actions = list(recent_tool_actions)
recent_tool_actions.append({
"tool_name": function_name,
"arguments": arguments,
})
recent_tool_actions = recent_tool_actions[-3:]
setattr(web_terminal, "_recent_tool_actions", list(recent_tool_actions))
permission_eval = web_terminal.evaluate_tool_permission(function_name, arguments)
if not permission_eval.get("allowed", True):
@ -470,19 +485,38 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
'message': '等待用户审批',
'conversation_id': conversation_id
})
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
wait_result = None
permission_mode = str(permission_eval.get("mode") or "")
if permission_mode == "auto_approval":
approval_id = approval_item.get("approval_id")
wait_result = await run_auto_approval(
web_terminal=web_terminal,
username=username,
approval_id=approval_id,
conversation_id=conversation_id,
recent_tool_actions=previous_tool_actions,
function_name=function_name,
arguments=arguments,
sender=sender,
)
else:
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"),
'decision': wait_result.get("decision"),
'reason': ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason")),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
reason = ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason") or "").strip()
if reason:
reject_message = f"{reject_message}{reason}"
reject_payload = {
"success": False,
"status": "rejected",
@ -498,7 +532,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
'message': reject_message,
'conversation_id': conversation_id
})
reject_content = json.dumps(reject_payload, ensure_ascii=False)
reject_content = _format_rejected_tool_text(reason or reject_message)
web_terminal.context_manager.add_conversation(
"tool",
reject_content,
@ -511,13 +545,15 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
"name": function_name,
"content": reject_content
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
if permission_mode != "auto_approval":
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
continue
# 发送工具开始事件
tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}"
@ -653,7 +689,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
# 批准模式下 run_command 采用“先只读执行,触发权限拒绝后再审批并单次可写重试”。
if (
function_name == "run_command"
and permission_eval.get("mode") == "approval"
and permission_eval.get("mode") in {"approval", "auto_approval"}
and not bool(arguments.get("_approval_write_granted", False))
and _is_permission_denied_result(result_data)
):
@ -683,19 +719,38 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
'message': '检测到写权限受限,等待用户审批',
'conversation_id': conversation_id
})
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
wait_result = None
permission_mode = str(permission_eval.get("mode") or "")
if permission_mode == "auto_approval":
approval_id = approval_item.get("approval_id")
wait_result = await run_auto_approval(
web_terminal=web_terminal,
username=username,
approval_id=approval_id,
conversation_id=conversation_id,
recent_tool_actions=previous_tool_actions,
function_name=function_name,
arguments=arguments,
sender=sender,
)
else:
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"),
'decision': wait_result.get("decision"),
'reason': ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason")),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
reason = ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason") or "").strip()
if reason:
reject_message = f"{reject_message}{reason}"
reject_payload = {
"success": False,
"status": "rejected",
@ -711,7 +766,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
'message': reject_message,
'conversation_id': conversation_id
})
reject_content = json.dumps(reject_payload, ensure_ascii=False)
reject_content = _format_rejected_tool_text(reason or reject_message)
web_terminal.context_manager.add_conversation(
"tool",
reject_content,
@ -724,13 +779,15 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
"name": function_name,
"content": reject_content
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
if permission_mode == "approval":
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
continue
retry_arguments = dict(arguments or {})
retry_arguments["_approval_write_granted"] = True

View File

@ -347,6 +347,8 @@
:width="rightWidth"
:approvals="pendingToolApprovals"
:deciding-approval-ids="decidingApprovalIds"
:auto-approval-feed-lines="autoApprovalFeedLines"
:auto-approval-final-message="autoApprovalFinalMessage"
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
@approve="approveToolApproval"
@reject="rejectToolApproval"
@ -720,6 +722,8 @@
:width="Math.min(rightWidth || 420, 460)"
:approvals="pendingToolApprovals"
:deciding-approval-ids="decidingApprovalIds"
:auto-approval-feed-lines="autoApprovalFeedLines"
:auto-approval-final-message="autoApprovalFinalMessage"
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
@approve="approveToolApproval"
@reject="rejectToolApproval"

View File

@ -515,6 +515,9 @@ export const taskPollingMethods = {
case 'tool_approval_resolved':
this.handleToolApprovalResolved(eventData, eventIdx);
break;
case 'auto_approval_progress':
this.handleAutoApprovalProgress(eventData, eventIdx);
break;
case 'append_payload':
this.handleAppendPayload(eventData, eventIdx);
@ -1171,6 +1174,13 @@ export const taskPollingMethods = {
} else {
this.pendingToolApprovals.push(approval);
}
if (this.approvalAutoCloseTimer) {
clearTimeout(this.approvalAutoCloseTimer);
this.approvalAutoCloseTimer = null;
}
if ((this.autoApprovalFeedLines || []).length === 0) {
this.autoApprovalFinalMessage = '';
}
this.rightCollapsed = false;
if (this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
@ -1189,10 +1199,39 @@ export const taskPollingMethods = {
this.pendingToolApprovals = this.pendingToolApprovals.filter(
(item: any) => item && item.approval_id !== approvalId
);
// 电脑端:审批完成后自动折叠面板
if (!this.pendingToolApprovals.length && !this.isMobileViewport) {
this.rightCollapsed = true;
const decision = String(data?.decision || '').trim().toLowerCase();
const reason = String(data?.reason || '').trim();
if (decision === 'approved' || decision === 'rejected') {
const decisionText = decision === 'approved' ? '批准通过' : '拒绝';
this.autoApprovalFinalMessage = `${decisionText} 原因:${reason || '未提供'}`;
}
// 电脑端:审批完成后延迟折叠面板(给用户留出查看结果时间)
if (!this.pendingToolApprovals.length && !this.isMobileViewport) {
if (this.approvalAutoCloseTimer) {
clearTimeout(this.approvalAutoCloseTimer);
}
this.approvalAutoCloseTimer = setTimeout(() => {
this.rightCollapsed = true;
}, 10000);
}
this.$forceUpdate();
},
handleAutoApprovalProgress(data: any) {
const progress = data?.progress;
if (!progress || typeof progress !== 'object') {
return;
}
if (!Array.isArray(this.autoApprovalFeedLines)) {
this.autoApprovalFeedLines = [];
}
if (progress.stage === 'start') {
this.autoApprovalFeedLines = ['自动审批开始'];
this.autoApprovalFinalMessage = '';
} else if (progress.stage === 'run_command' && progress.command) {
this.autoApprovalFeedLines.push(String(progress.command));
}
this.autoApprovalFeedLines = this.autoApprovalFeedLines.slice(-20);
this.$forceUpdate();
},

View File

@ -1141,10 +1141,11 @@ export const uiMethods = {
const target = String(mode || '')
.trim()
.toLowerCase();
if (!target || target === this.currentPermissionMode) {
if (!target) {
this.closePermissionMenu();
return;
}
const previousMode = this.currentPermissionMode;
try {
const response = await fetch('/api/permission-mode', {
method: 'POST',
@ -1157,7 +1158,23 @@ export const uiMethods = {
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '切换权限失败');
}
this.currentPermissionMode = payload.mode || target;
const reportedMode = typeof payload?.mode === 'string' ? payload.mode : '';
if (!reportedMode) {
throw new Error('切换权限失败:服务端未返回最新权限');
}
this.currentPermissionMode = reportedMode;
// 二次确认,避免前端状态与后端实际权限漂移
const verifyResponse = await fetch('/api/permission-mode');
const verifyPayload = await verifyResponse.json().catch(() => ({}));
if (!verifyResponse.ok || !verifyPayload?.success || typeof verifyPayload?.mode !== 'string') {
throw new Error('切换权限后校验失败,请重试');
}
this.currentPermissionMode = verifyPayload.mode;
if (verifyPayload.mode !== target) {
throw new Error(
`权限未生效(期望:${this.getPermissionModeLabel(target)},实际:${this.getPermissionModeLabel(verifyPayload.mode)}`
);
}
this.uiPushToast({
title: '权限已切换',
message: `当前权限:${this.getPermissionModeLabel(this.currentPermissionMode)}`,
@ -1165,6 +1182,7 @@ export const uiMethods = {
duration: 1800
});
} catch (error) {
this.currentPermissionMode = previousMode;
const msg = error instanceof Error ? error.message : String(error || '切换权限失败');
this.uiPushToast({
title: '切换权限失败',

View File

@ -143,6 +143,11 @@ export function dataState() {
label: '批准',
description: '对工作区文件进行修改的工具需用户批准后才会执行'
},
{
value: 'auto_approval',
label: '自动审核',
description: '工作区内写入直通,高风险操作由后台审核智能体自动审批'
},
{
value: 'unrestricted',
label: '无限制',
@ -163,6 +168,9 @@ export function dataState() {
],
pendingToolApprovals: [],
decidingApprovalIds: [],
autoApprovalFeedLines: [],
autoApprovalFinalMessage: '',
approvalAutoCloseTimer: null,
imageEntries: [],
imageLoading: false,
videoEntries: [],

View File

@ -341,7 +341,7 @@ const props = defineProps<{
blockTokenPanel?: boolean;
blockCompressConversation?: boolean;
blockConversationReview?: boolean;
currentPermissionMode: 'readonly' | 'approval' | 'unrestricted';
currentPermissionMode: 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
permissionMenuOpen: boolean;
permissionOptions: Array<{ value: string; label: string; description: string }>;
executionModeEnabled?: boolean;

View File

@ -75,7 +75,6 @@
<div><strong>工具</strong>{{ item.tool_name }}</div>
<div v-if="item.preview?.summary"><strong>说明</strong>{{ item.preview.summary }}</div>
</div>
<div class="approval-actions">
<button
type="button"
@ -104,6 +103,11 @@
</div>
</div>
</div>
<div v-if="autoApprovalFeedLines.length || autoApprovalFinalMessage" class="auto-approval-block">
<div class="auto-approval-block__title">自动审批记录</div>
<pre class="auto-approval-block__content">{{ autoApprovalFeedLines.join('\n') }}</pre>
<div v-if="autoApprovalFinalMessage" class="auto-approval-block__final">{{ autoApprovalFinalMessage }}</div>
</div>
</div>
</aside>
</template>
@ -118,6 +122,8 @@ const props = defineProps<{
width: number;
approvals: Array<any>;
decidingApprovalIds?: string[];
autoApprovalFeedLines?: string[];
autoApprovalFinalMessage?: string;
}>();
const emit = defineEmits<{
@ -203,3 +209,34 @@ const getEditNewLines = (item: any): string[] => {
};
</script>
<style scoped>
.auto-approval-block {
margin-top: 12px;
padding: 10px;
border: 1px solid var(--border-color, #2a2f3a);
border-radius: 8px;
background: var(--panel-bg, rgba(0, 0, 0, 0.12));
}
.auto-approval-block__title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.auto-approval-block__content {
margin: 0;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.auto-approval-block__final {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
font-weight: 600;
}
</style>

View File

@ -1595,7 +1595,7 @@ const personalTabs = computed(() => {
const activeTab = ref<PersonalTab>('preferences');
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
type PermissionModeValue = 'readonly' | 'approval' | 'unrestricted';
type PermissionModeValue = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
type CompressionField =
| 'shallow_compress_trigger_tokens'
| 'shallow_compress_keep_recent_tools'
@ -1623,6 +1623,7 @@ const permissionModeOptions: Array<{
}> = [
{ id: 'readonly', label: '只读', desc: '仅允许读取/检索类工具,修改操作将被拒绝。' },
{ id: 'approval', label: '批准', desc: '对工作区文件进行修改的工具需人工批准后执行。' },
{ id: 'auto_approval', label: '自动审核', desc: '工作区内写入直通;高风险操作由后台审核智能体自动审批。' },
{ id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行。' }
];

View File

@ -3,7 +3,7 @@ import { useModelStore } from './model';
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
type RunMode = 'fast' | 'thinking' | 'deep';
type PermissionMode = 'readonly' | 'approval' | 'unrestricted';
type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
interface PersonalForm {
enabled: boolean;
@ -279,6 +279,7 @@ export const usePersonalizationStore = defineStore('personalization', {
default_permission_mode:
data.default_permission_mode === 'readonly' ||
data.default_permission_mode === 'approval' ||
data.default_permission_mode === 'auto_approval' ||
data.default_permission_mode === 'unrestricted'
? data.default_permission_mode
: 'unrestricted',
@ -540,7 +541,7 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback();
},
setDefaultPermissionMode(mode: PermissionMode) {
const allowed: PermissionMode[] = ['readonly', 'approval', 'unrestricted'];
const allowed: PermissionMode[] = ['readonly', 'approval', 'auto_approval', 'unrestricted'];
const target = allowed.includes(mode) ? mode : 'unrestricted';
this.form = {
...this.form,