# 本地模式工具权限级别 技术设计
## 1. 需求概述
在 LOCAL 执行模式下,引入四级工具权限体系,用户可在会话输入框下方实时切换。切换对下一次工具调用生效,已触发的审批请求保持原状需手动处理。
| 级别 | 名称 | 行为 |
|------|------|------|
| `READ_ONLY` | 只读(默认) | 与现状一致:read_file/glob_search/grep_search 自动执行,shell/write_file/edit_file 需审批 |
| `READ_WRITE` | 读写 | 在只读基础上,write_file/edit_file 自动执行,shell 仍需审批 |
| `SMART` | 智能审批 | 在读写基础上,shell 调用前先经 LLM 判断危险性,高危需审批,低危自动执行 |
| `FULL` | 完全权限 | 所有工具调用自动执行,无需审批 |
CLOUD 模式不受影响,无需显示权限切换器。
---
## 2. 数据模型
### 2.1 Session 表新增字段
```sql
-- V027__add_session_permission_level.sql
ALTER TABLE `session`
ADD COLUMN `permission_level` VARCHAR(20) NOT NULL DEFAULT 'READ_ONLY'
COMMENT 'LOCAL mode tool permission: READ_ONLY|READ_WRITE|SMART|FULL';
```
- 默认值 `READ_ONLY`,存量数据无需迁移。
- 字段仅在 LOCAL 模式下有意义,CLOUD 模式下忽略。
### 2.2 Session 实体变更
`Session.java` 新增字段:
```java
private String permissionLevel; // READ_ONLY | READ_WRITE | SMART | FULL
```
### 2.3 权限级别枚举
新建 `PermissionLevel.java`(包 `cn.etarch.mao.session.entity`):
```java
public enum PermissionLevel {
READ_ONLY,
READ_WRITE,
SMART,
FULL;
public static PermissionLevel fromString(String value) {
if (value == null) return READ_ONLY;
try {
return valueOf(value);
} catch (IllegalArgumentException e) {
return READ_ONLY;
}
}
}
```
---
## 3. API 变更
### 3.1 权限级别更新接口
复用现有 `PATCH /api/v1/sessions/{id}` 接口,`UpdateSessionRequest` 新增字段:
```java
@Data
public static class UpdateSessionRequest {
private String title;
private String summary;
private String projectKey;
private String permissionLevel; // 新增
}
```
`SessionController.updateSession()` 新增分支:
```java
if (request.getPermissionLevel() != null) {
sessionService.updatePermissionLevel(id, request.getPermissionLevel());
}
```
`SessionService.updatePermissionLevel()` 校验枚举合法性后更新。
### 3.2 SessionVO 新增字段
```java
private String permissionLevel; // 返回给前端
```
### 3.3 CreateSessionRequest 新增字段(可选)
```java
private String permissionLevel; // 创建时可指定,默认 READ_ONLY
```
不指定时默认 `READ_ONLY`,与数据库默认值一致。
---
## 4. 后端核心逻辑
### 4.1 整体决策流程
权限判断发生在后端 `ToolDispatcher` 层,不在 Electron 客户端。后端根据当前 session 的 `permissionLevel` 决定是否将审批标记下发给客户端。
```
AgentLoop.dispatchTool()
→ ToolDispatcher.dispatch(toolName, args, executionMode, sessionId, workspace)
→ 查询 session.permissionLevel
→ 判断该工具在该级别下是否需要审批
→ 如果不需要审批:设置 needApproval=false,直接下发
→ 如果需要审批:
→ 如果是 SMART 模式下的 shell:先调 LLM 判断危险性
→ 低危:设置 needApproval=false,直接下发
→ 高危:设置 needApproval=true,下发
→ 其他情况:设置 needApproval=true,下发
→ LocalToolExecutor.execute(sessionId, toolName, args, workspace, needApproval)
```
### 4.2 ToolDispatcher 改造
当前 `ToolDispatcher.dispatch(toolName, args, executionMode, sessionId, workspace)` 方法签名不变,内部逻辑增加权限判断。
新增依赖注入 `SessionService`(或 `SessionMapper`,为避免循环依赖优先用 Mapper)和 `DangerAssessor`。
核心改动(伪代码):
```java
public String dispatch(String toolName, String arguments,
String executionMode, Long sessionId, String workspace) {
if (SERVER_ONLY_TOOLS.contains(toolName)) {
// 服务端工具不变
...
}
if ("LOCAL".equals(executionMode)) {
// 查询权限级别
PermissionLevel level = getPermissionLevel(sessionId);
// 判断是否需要审批
boolean needApproval = shouldRequireApproval(toolName, level, sessionId, arguments);
return localToolExecutor.execute(sessionId, toolName, arguments, workspace, needApproval);
}
// CLOUD 模式不变
...
}
private boolean shouldRequireApproval(String toolName, PermissionLevel level,
Long sessionId, String arguments) {
return switch (level) {
case READ_ONLY -> isWriteOrShellTool(toolName);
case READ_WRITE -> "shell".equals(toolName);
case SMART -> "shell".equals(toolName) && dangerAssessor.isDangerous(arguments);
case FULL -> false;
};
}
private boolean isWriteOrShellTool(String toolName) {
return "shell".equals(toolName) || "write_file".equals(toolName) || "edit_file".equals(toolName);
}
```
**注意**:查询 `permissionLevel` 应走内存缓存或在执行上下文中传递,避免每次工具调用都查 DB。方案是在 `AgentExecutionContext` 中携带 `permissionLevel`,由 `HarnessService` 构建上下文时从 session 读取。但用户可以在执行过程中切换级别,所以需要实时读取最新值。折中方案:`ToolDispatcher` 通过 `SessionMapper.selectById()` 读取,利用 MyBatis 一级缓存(同一线程内同一 session 只查一次不现实,因为权限可变),直接查 DB 即可——工具调用频率不高,单次查询开销可忽略。
### 4.3 LocalToolExecutor 改造
`execute()` 方法签名新增 `needApproval` 参数:
```java
public String execute(Long sessionId, String toolName, String arguments,
String workspace, boolean needApproval)
```
### 4.4 LocalToolSessionRegistry 改造
`sendToolRequest()` 方法签名新增 `needApproval` 参数,在 WS 消息中下发:
```java
streamingWsRegistry.send(userId, WsEvent.of("tool_execute", sessionId, Map.of(
"requestId", requestId,
"toolName", toolName,
"arguments", arguments != null ? arguments : "{}",
"workspace", workspace != null ? workspace : "",
"needApproval", needApproval // 新增
)));
```
### 4.5 DangerAssessor — 智能审批模式的 LLM 危险判断
新建 `cn.etarch.mao.harness.tool.DangerAssessor`:
```java
@Slf4j
@Component
@RequiredArgsConstructor
public class DangerAssessor {
private final LlmAdapter llmAdapter;
private final LlmModelMapper llmModelMapper;
// 危险判断 prompt(固定模板,不随会话上下文变化)
private static final String SYSTEM_PROMPT = """
You are a security classifier. Given a shell command, determine if it is dangerous.
Dangerous commands include but are not limited to:
- Deleting files or directories (rm, rmdir, unlink)
- Formatting or repartitioning disks (mkfs, fdisk, dd)
- Changing permissions broadly (chmod, chown on system paths)
- Network exfiltration (curl/wget to unknown hosts, nc, ssh tunneling)
- Modifying system configuration (/etc, /boot, cron, systemd)
- Package management that could break the environment (apt remove, pip uninstall system packages)
- Process killing (kill, killall, pkill on critical processes)
- Writing to /dev, /proc, /sys
- Any command with sudo or su
Safe commands include:
- Reading files (cat, less, head, tail, grep, find)
- Listing directory contents (ls, tree, du)
- Running build tools (mvn, npm, gradle, make)
- Git operations (git status, git log, git diff, git add, git commit)
- Package info queries (npm list, pip list, mvn dependency:tree)
- Standard development workflows
Reply with ONLY "DANGEROUS" or "SAFE". No explanation.
""";
/**
* 使用 Agent 关联的 LLM 模型判断 shell 命令是否危险。
* 复用 session 关联的 agent 的 modelConfig。
*/
public boolean isDangerous(String arguments, LlmModelConfig modelConfig) {
// 从 arguments JSON 中提取 command
String command = extractCommand(arguments);
ChatRequest request = ChatRequest.builder()
.messages(List.of(
ChatRequest.Message.builder()
.role("system")
.content(SYSTEM_PROMPT)
.build(),
ChatRequest.Message.builder()
.role("user")
.content(command)
.build()
))
.maxTokens(10)
.temperature(0.0)
.build();
try {
ChatResponse response = llmAdapter.chat(request, modelConfig);
String verdict = response.getChoices().get(0).getMessage().getContent().trim().toUpperCase();
boolean dangerous = verdict.contains("DANGEROUS");
log.info("Danger assessment for command [{}]: {} -> {}", command, verdict, dangerous);
return dangerous;
} catch (Exception e) {
// LLM 调用失败时,安全起见视为危险
log.error("Danger assessment failed, defaulting to DANGEROUS: {}", e.getMessage());
return true;
}
}
private String extractCommand(String arguments) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(arguments);
JsonNode commandNode = node.get("command");
return commandNode != null ? commandNode.asText() : arguments;
} catch (Exception e) {
return arguments;
}
}
}
```
**关键设计决策**:
1. **LLM 调用方式**:使用 `llmAdapter.chat()`(同步),不使用流式。危险判断是一次性的、token 消耗极小的调用,同步调用即可。
2. **模型选择**:复用当前 session 关联的 agent 的 `LlmModelConfig`。该配置需要从 `ToolDispatcher` 传递下来。方案:在 `ToolDispatcher.dispatch()` 中查询 session 获取 agentId,再查 agent 获取 modelId,再查 LlmModel 构建 config。或者更优:在 `AgentExecutionContext` 中携带 `modelConfig`,通过 `ToolDispatcher` 的新重载方法传入。
3. **失败策略**:LLM 调用异常时默认判定为危险,触发审批。宁可多审批一次,不能误放行。
4. **延迟影响**:每次 shell 调用增加一次 LLM 调用延迟(约 1-3 秒)。仅在 SMART 模式下的 shell 工具触发,其他工具和其他级别不受影响。
5. **无上下文传递**:危险判断是一次独立的、与主对话无关的 LLM 调用,不携带会话历史,prompt 固定。
### 4.6 AgentExecutionContext 扩展
`AgentExecutionContext` 新增字段,用于在调用链中传递权限级别和模型配置:
```java
private String permissionLevel; // 从 session 读取
// modelConfig 已存在,可直接复用
```
`HarnessService` 构建上下文时:
```java
context.setPermissionLevel(session.getPermissionLevel());
```
`ToolDispatcher.dispatch()` 新增一个重载,接受 `permissionLevel` 和 `modelConfig`:
```java
public String dispatch(String toolName, String arguments, String executionMode,
Long sessionId, String workspace,
String permissionLevel, LlmModelConfig modelConfig)
```
`AgentLoop.dispatchTool()` 调用此新重载:
```java
private String dispatchTool(String toolName, String arguments, AgentExecutionContext context) {
try {
return toolDispatcher.dispatch(toolName, arguments,
context.getExecutionMode(), context.getSessionId(), context.getWorkspace(),
context.getPermissionLevel(), context.getModelConfig());
} catch (Exception e) {
return "Tool execution failed: " + e.getMessage();
}
}
```
旧的 5 参数 `dispatch()` 重载保留但标记废弃,或直接删除(项目处于初版阶段,无需向后兼容)。
---
## 5. WebSocket 协议变更
### 5.1 tool_execute 消息(服务端 → 客户端)
新增 `needApproval` 字段:
```json
{
"type": "tool_execute",
"sessionId": 123,
"data": {
"requestId": "uuid-xxx",
"toolName": "shell",
"arguments": "{\"command\":\"ls -la\"}",
"workspace": "/path/to/workspace",
"needApproval": true
}
}
```
### 5.2 tool_result / tool_error 消息(客户端 → 服务端)
无变更。
---
## 6. Electron 客户端改造
### 6.1 main.cjs — 审批逻辑由后端控制
当前审批逻辑硬编码在 Electron 侧(哪些工具需要审批)。改造后,审批决策权移到后端,Electron 侧只根据 `needApproval` 标记决定是否弹审批。
**`executeToolByName` 改造**:
接收 `needApproval` 参数,传递给各 handler:
```js
async function executeToolByName(toolName, argsObj, requestId, workspace, sessionId, needApproval) {
switch (toolName) {
case 'shell':
return await handleShellFromWebSocket(argsObj, sessionId, needApproval)
case 'write_file':
return await handleLocalWriteFile(argsObj, sessionId, needApproval)
case 'edit_file':
return await handleLocalEditFile(argsObj, sessionId, needApproval)
// read_file, glob_search, grep_search 不变(本身不需要审批)
...
}
}
```
**`handleShellFromWebSocket` 改造**:
```js
async function handleShellFromWebSocket(args, sessionId, needApproval) {
// ... 现有 action 路由逻辑 ...
if (action === 'exec') {
// 已有 shell session 的复用逻辑不变(复用不需要审批)
if (session_id && shellSessions.has(session_id)) {
// 复用已有 session,不需要审批
...
}
// 新建 session 或无 session_id 的一次性执行
if (needApproval) {
const approved = await requestToolApproval('shell', command, sessionId)
if (!approved) {
return { exit_code: -1, output: 'User denied command execution.' }
}
}
// 创建 bash session 或执行一次性命令
...
}
}
```
**`handleLocalWriteFile` 和 `handleLocalEditFile` 改造**:
```js
async function handleLocalWriteFile(args, sessionId, needApproval) {
if (needApproval) {
const approved = await requestToolApproval('write_file', args.path, sessionId)
if (!approved) {
return { success: false, error: 'User denied file write.' }
}
}
// 执行写入
...
}
```
**ipcMain.handle('tool-execute') 改造**:
从 WS 消息中提取 `needApproval` 并传递:
```js
ipcMain.handle('tool-execute', async (event, { toolName, args, requestId, workspace, sessionId, needApproval }) => {
const argsObj = typeof args === 'string' ? JSON.parse(args) : args
const result = await executeToolByName(toolName, argsObj, requestId, workspace, sessionId, needApproval)
return { requestId, result: JSON.stringify(result), error: null }
})
```
### 6.2 preload.cjs
`toolExecute` 签名新增 `needApproval` 参数:
```js
toolExecute: (toolName, args, requestId, workspace, sessionId, needApproval) =>
ipcRenderer.invoke('tool-execute', { toolName, args, requestId, workspace, sessionId, needApproval }),
```
### 6.3 useStreamWS.ts — tool_execute 处理
从 WS 消息中提取 `needApproval` 并传递给 Electron:
```ts
case 'tool_execute': {
if (!sessionId || !data) break
const { requestId, toolName, arguments: toolArgs, workspace, needApproval } = data
if (typeof window !== 'undefined' && (window as any).electronAPI?.toolExecute) {
(window as any).electronAPI
.toolExecute(toolName, toolArgs, requestId, workspace, Number(sessionId), !!needApproval)
.then(...)
}
break
}
```
---
## 7. 前端 UI 变更
### 7.1 权限切换组件 — PermissionLevelSwitcher.vue
新建组件,放置在 `ChatInput.vue` 下方工具栏区域。
**位置**:`ChatInput.vue` 的 bottom toolbar 区域,在 workspace indicator 和 model name 之间,或作为一个独立行。
**仅 LOCAL 模式显示**:通过 `executionMode` prop 控制。
```vue
{{ levelLabels[currentLevel] }}
{{ levelLabels[level] }}
{{ levelDescriptions[level] }}
```
**级别标签和描述**:
```ts
const levels = ['READ_ONLY', 'READ_WRITE', 'SMART', 'FULL'] as const
const levelLabels: Record = {
READ_ONLY: '只读',
READ_WRITE: '读写',
SMART: '智能审批',
FULL: '完全权限'
}
const levelDescriptions: Record = {
READ_ONLY: '搜索和读取自动执行,写入和命令需审批',
READ_WRITE: '文件读写自动执行,命令执行需审批',
SMART: '文件读写自动执行,命令经 AI 判断后自动执行或审批',
FULL: '所有操作自动执行,无需审批'
}
```
**切换逻辑**:
```ts
const emit = defineEmits<{
'update:permissionLevel': [level: string]
}>()
async function handleSwitch(level: string) {
emit('update:permissionLevel', level)
}
```
### 7.2 TaskView.vue — 传递权限级别
```html
```
`handlePermissionLevelChange` 调用 `PATCH /api/v1/sessions/{id}` 更新权限级别,同时更新本地 session store。
### 7.3 Session Store 扩展
`Session` 类型新增 `permissionLevel` 字段:
```ts
interface Session {
// ... 现有字段
permissionLevel?: string // READ_ONLY | READ_WRITE | SMART | FULL
}
```
`fetchSessions` 和 `createSession` 的响应解析中包含此字段。
### 7.4 ChatInput.vue 改造
新增 props:
```ts
defineProps<{
// ... 现有 props
permissionLevel?: string
}>()
defineEmits<{
// ... 现有 emits
'update:permissionLevel': [level: string]
}>()
```
在模板中引入 `PermissionLevelSwitcher` 组件。
---
## 8. 容易被忽略的点
### 8.1 权限切换的实时性
用户切换权限级别后,`PATCH` 请求更新 DB。后端 `ToolDispatcher` 在每次工具调用时从 DB 读取最新 `permissionLevel`。这意味着:
- **切换对下一次工具调用生效**:符合需求。正在执行中的工具调用不受影响。
- **已触发的审批请求**:审批请求已经通过 WS 发送到客户端,处于 `pendingApprovals` 队列中。这些请求的 `needApproval` 已经确定,不受后续切换影响。用户需手动处理(执行或拒绝)。符合需求。
### 8.2 SMART 模式下 LLM 调用失败
`DangerAssessor.isDangerous()` 在 LLM 调用异常时默认返回 `true`(视为危险),触发审批。这是安全兜底策略。
### 8.3 SMART 模式的模型配置传递
`DangerAssessor` 需要 `LlmModelConfig` 来调用 LLM。该配置从 `AgentExecutionContext.getModelConfig()` 获取,通过 `ToolDispatcher` 新重载方法传入。不需要额外查 DB。
但如果用户在执行过程中切换了 Agent(理论上不应该发生,因为会话绑定了 Agent),modelConfig 不会变。这是可接受的。
### 8.4 shell 的 session 复用
当前 shell 工具有复用已有 bash session 的逻辑(同一个 `session_id` 的后续 `write_stdin` 操作不需要审批)。改造后:
- `exec` action(创建新 session):根据 `needApproval` 决定
- `write_stdin` action(复用 session):不需要审批,无论权限级别如何。这是合理的——shell session 一旦创建,后续 stdin 写入是同一次会话的延续
### 8.5 并发工具调用
`AgentLoop.executeToolCalls()` 支持多工具并行执行(`CompletableFuture.runAsync`)。每个工具调用独立判断 `needApproval`,不存在竞态问题。但多个审批请求可能同时到达客户端,`pendingApprovals` 队列已支持并发审批。
### 8.6 WAITING_APPROVAL session phase
当前 `WAITING_APPROVAL` phase 已定义但从未使用。本次改造可以考虑在发送审批请求时设置 phase 为 `WAITING_APPROVAL`,审批完成后恢复为 `RUNNING`。但这会增加复杂度:后端需要知道审批何时完成,而当前审批发生在 Electron 侧,后端只是阻塞等待 `CompletableFuture`。
**建议**:本次不改造 phase 同步,保持现状。后端 session phase 在工具调用期间始终为 `RUNNING`。
### 8.7 CLOUD 模式不显示权限切换器
前端通过 `executionMode` prop 控制 `PermissionLevelSwitcher` 的显示。CLOUD 模式下后端也忽略 `permissionLevel` 字段,所有工具直接在服务端执行。
### 8.8 权限级别的持久化与会话恢复
权限级别存储在 DB 中,会话恢复(页面刷新、重新打开)后自动恢复上次设置的级别。无需额外处理。
---
## 9. 改动文件清单
### 后端
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `V027__add_session_permission_level.sql` | 新建 | DB migration |
| `Session.java` | 修改 | 新增 `permissionLevel` 字段 |
| `PermissionLevel.java` | 新建 | 权限级别枚举 |
| `SessionService.java` | 修改 | 新增 `updatePermissionLevel()` 方法 |
| `SessionController.java` | 修改 | `UpdateSessionRequest`/`SessionVO`/`CreateSessionRequest` 新增字段,PATCH 接口增加分支 |
| `AgentExecutionContext.java` | 修改 | 新增 `permissionLevel` 字段 |
| `HarnessService.java` | 修改 | 构建上下文时设置 `permissionLevel` |
| `ToolDispatcher.java` | 修改 | 新增带 `permissionLevel` + `modelConfig` 的重载,权限判断逻辑,注入 `DangerAssessor` |
| `LocalToolExecutor.java` | 修改 | `execute()` 新增 `needApproval` 参数 |
| `LocalToolSessionRegistry.java` | 修改 | `sendToolRequest()` 新增 `needApproval` 参数,WS 消息增加字段 |
| `DangerAssessor.java` | 新建 | LLM 危险性判断服务 |
### Electron 客户端
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `desktop/electron/main.cjs` | 修改 | 审批逻辑改为由 `needApproval` 参数驱动 |
| `desktop/electron/preload.cjs` | 修改 | `toolExecute` 新增 `needApproval` 参数 |
### 前端
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `PermissionLevelSwitcher.vue` | 新建 | 权限级别切换组件 |
| `ChatInput.vue` | 修改 | 引入切换组件,新增 props/emits |
| `TaskView.vue` | 修改 | 传递 `permissionLevel`,处理切换事件 |
| `useStreamWS.ts` | 修改 | `tool_execute` 提取并传递 `needApproval` |
| `stores/session.ts` | 修改 | `Session` 类型新增 `permissionLevel` 字段 |
---
## 10. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| SMART 模式 LLM 判断延迟 | 每次 shell 调用增加 1-3 秒 | prompt 极简(10 tokens),同步调用,可接受 |
| SMART 模式 LLM 判断误判 | 低危误判为高危:用户体验差(多审批一次);高危误判为低危:安全风险 | 失败默认视为高危;prompt 经过安全类别列举;后续可迭代优化 prompt |
| 权限切换与审批竞态 | 用户切换级别时有审批请求在途 | 已触发的审批保持原状,符合需求 |
| DB 查询开销 | 每次工具调用查一次 permissionLevel | 工具调用频率低(每轮对话 1-3 次),单次查询 <1ms |