📌 本章目标:实现审批流程和安全机制,让 Agent 在安全约束下工作
⭐⭐ 难度:基础 | ⏱️ 预计时间:1 小时
9.1 为什么需要审批?
先想一个问题
如果你的 AI 助手可以直接执行命令、修改文件、访问数据库,你放心让它随便干吗?
用户:帮我清理一下磁盘空间 AI:好的,我执行rm-rf/* 来清理😱 这可不行!
在企业场景下,AI Agent 的每一个敏感操作都必须经过人工审批。这不仅是用户体验问题,更是安全问题。
审批机制的作用
| 作用 | 说明 |
|---|---|
| 安全保障 | 防止 AI 执行危险操作(删库、执行高危命令等) |
| 用户掌控 | 让用户知道 AI 在干嘛,对操作有决定权 |
| 合规要求 | 企业级场景下,重要操作需要留痕和审批 |
| 逐步授权 | 可以选择「单次允许」或「始终允许」,灵活控制 |
9.2 审批流程是怎样的?
完整的审批流程
1. AI 想要执行一个敏感操作 │ ▼2. AI 暂停,发起审批请求 │ ▼3. 前端展示审批卡片(显示:要做什么、有什么风险) │ ├─ 用户点「允许一次」→ AI 执行这次操作 ├─ 用户点「始终允许」→ 这类操作以后自动通过 └─ 用户点「拒绝」 → AI 取消这次操作,换个方式或解释原因 │ ▼4. 审批结果通过 WebSocket 发给后端,AI 继续执行或中止审批内容长什么样?
审批请求包含这些信息:
interfaceApprovalRequest{id:string;// 审批请求 IDkind:string;// 审批类型:'exec' | 'file_write' | 'database' | ...title:string;// 标题(简短描述要做什么)description:string;// 详细描述command?:string;// 要执行的命令(如果是命令执行)filePath?:string;// 要操作的文件路径(如果是文件操作)allowedActions:string[];// 允许的操作:['allow-once', 'allow-always', 'deny']expiresAt?:number;// 过期时间}审批类型
常见的审批类型:
| 类型 | 触发场景 | 敏感程度 |
|---|---|---|
exec | 执行终端命令 | 高 |
file_write | 修改/删除文件 | 中高 |
file_read | 读取敏感文件 | 中 |
database | 操作数据库 | 高 |
web_browse | 访问外部网站 | 低 |
api_call | 调用第三方 API | 中 |
9.3 审批卡片组件
审批卡片是审批流程的核心 UI,通常展示在 AI 消息的下方。
组件实现
创建src/pages/workbench/components/Conversation/ApprovalCard.tsx:
import React, { useState, useEffect } from 'react'; import { Button, Space, Tag, Alert } from 'antd'; import { CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined, WarningOutlined, } from '@ant-design/icons'; import { observer } from 'mobx-react-lite'; import { useStore } from '@/store/StoreContext'; import styles from './ApprovalCard.less'; interface Props { approval: ApprovalRequest; messageId: string; } // 审批类型对应的标签颜色 const kindColors: Record<string, string> = { exec: 'red', file_write: 'orange', file_read: 'blue', database: 'purple', web_browse: 'green', api_call: 'cyan', }; // 审批类型的中文名称 const kindNames: Record<string, string> = { exec: '命令执行', file_write: '文件写入', file_read: '文件读取', database: '数据库操作', web_browse: '网页浏览', api_call: 'API 调用', }; const ApprovalCard = observer(({ approval, messageId }: Props) => { const { rootStore } = useStore(); const [status, setStatus] = useState<'pending' | 'approved' | 'denied'>('pending'); const [loadingAction, setLoadingAction] = useState<string | null>(null); // 计算剩余时间 const [timeLeft, setTimeLeft] = useState<string>(''); useEffect(() => { if (!approval.expiresAt) return; const updateTimeLeft = () => { const diff = approval.expiresAt! - Date.now(); if (diff <= 0) { setTimeLeft('已过期'); return; } const minutes = Math.floor(diff / 60000); const seconds = Math.floor((diff % 60000) / 1000); setTimeLeft(`${minutes}分${seconds}秒`); }; updateTimeLeft(); const timer = setInterval(updateTimeLeft, 1000); return () => clearInterval(timer); }, [approval.expiresAt]); // 处理审批操作 const handleAction = async (action: string) => { setLoadingAction(action); try { await rootStore.submitApproval(approval.id, action, messageId); setStatus(action === 'deny' ? 'denied' : 'approved'); } catch (error) { console.error('审批操作失败:', error); } finally { setLoadingAction(null); } }; // 已处理完成的状态 if (status !== 'pending') { return ( <div className={`${styles.approvalCard} ${styles[status]}`}> <div className={styles.resultRow}> {status === 'approved' ? ( <CheckCircleOutlined className={styles.resultIcon} /> ) : ( <CloseCircleOutlined className={styles.resultIcon} /> )} <span className={styles.resultText}> {status === 'approved' ? '已允许执行' : '已拒绝'} </span> </div> </div> ); } return ( <div className={styles.approvalCard}> {/* 头部 */} <div className={styles.header}> <div className={styles.headerLeft}> <WarningOutlined className={styles.warningIcon} /> <span className={styles.title}>需要你的审批</span> <Tag color={kindColors[approval.kind] || 'default'}> {kindNames[approval.kind] || approval.kind} </Tag> </div> {approval.expiresAt && ( <div className={styles.expiry}> <ClockCircleOutlined /> <span>剩余 {timeLeft}</span> </div> )} </div> {/* 内容描述 */} <div className={styles.content}> <p className={styles.description}>{approval.description}</p> {/* 如果有命令,展示命令 */} {approval.command && ( <Alert type="warning" showIcon message="将执行以下命令" description={<code className={styles.command}>{approval.command}</code>} className={styles.commandAlert} /> )} {/* 如果有文件路径 */} {approval.filePath && ( <div className={styles.filePath}> <span className={styles.fileLabel}>文件路径:</span> <code>{approval.filePath}</code> </div> )} </div> {/* 操作按钮 */} <div className={styles.actions}> <Space> {approval.allowedActions.includes('allow-once') && ( <Button type="primary" loading={loadingAction === 'allow-once'} onClick={() => handleAction('allow-once')} > 允许一次 </Button> )} {approval.allowedActions.includes('allow-always') && ( <Button loading={loadingAction === 'allow-always'} onClick={() => handleAction('allow-always')} > 始终允许 </Button> )} {approval.allowedActions.includes('deny') && ( <Button danger loading={loadingAction === 'deny'} onClick={() => handleAction('deny')} > 拒绝 </Button> )} </Space> </div> </div> ); }); export default ApprovalCard; // 类型定义(实际项目中应该放在 types 文件里) interface ApprovalRequest { id: string; kind: string; title: string; description: string; command?: string; filePath?: string; allowedActions: string[]; expiresAt?: number; }样式src/pages/workbench/components/Conversation/ApprovalCard.less:
.approvalCard { background: #fffbe6; border: 1px solid #ffe58f; border-radius: @radius-md; padding: @spacing-md; margin: @spacing-sm 0; max-width: 600px; &.approved { background: #f6ffed; border-color: #b7eb8f; } &.denied { background: #fff2f0; border-color: #ffccc7; } } .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: @spacing-sm; } .headerLeft { display: flex; align-items: center; gap: @spacing-sm; } .warningIcon { color: @warning-color; font-size: 18px; } .title { font-weight: 500; font-size: 15px; color: @text-primary; } .expiry { font-size: 12px; color: @text-secondary; display: flex; align-items: center; gap: 4px; } .content { margin-bottom: @spacing-md; } .description { margin: 0 0 @spacing-sm; color: @text-primary; line-height: 1.6; } .commandAlert { margin-top: @spacing-sm !important; :global(.ant-alert-description) { margin-top: 8px; } } .command { display: block; background: rgba(0, 0, 0, 0.06); padding: @spacing-sm; border-radius: @radius-sm; font-family: 'Courier New', monospace; word-break: break-all; margin: 0; } .filePath { font-size: 13px; color: @text-secondary; margin-top: @spacing-sm; .fileLabel { color: @text-primary; } code { background: rgba(0, 0, 0, 0.06); padding: 2px 6px; border-radius: 4px; } } .actions { display: flex; justify-content: flex-end; } .resultRow { display: flex; align-items: center; gap: @spacing-sm; } .resultIcon { font-size: 18px; .approved & { color: @success-color; } .denied & { color: @error-color; } } .resultText { font-weight: 500; .approved & { color: @success-color; } .denied & { color: @error-color; } }9.4 审批数据的存储
审批请求是消息的一部分,所以我们把它存在Message类型里,作为消息的一个 content part。
更新 Message 类型
更新src/store/SessionStore.ts:
// 消息内容块的类型exporttypeMessagePart=|{type:'text';content:string}|{type:'tool';toolName:string;phase:string;input?:string;output?:string}|{type:'approval';approval:ApprovalRequest};// 更新 MessageexportinterfaceMessage{id:string;role:MessageRole;content:string;// 纯文本内容(用于搜索等)parts:MessagePart[];// 结构化内容块createdAt:string;status?:MessageStatus;}// 审批请求类型exportinterfaceApprovalRequest{id:string;kind:string;title:string;description:string;command?:string;filePath?:string;allowedActions:string[];expiresAt?:number;}💡设计思路:消息不是单一文本,而是由多个 Part 组成的。文本是一个 Part,工具调用是一个 Part,审批也是一个 Part。
这样流式渲染的时候,不同类型的 Part 可以边生成边追加,非常灵活。
在 SessionStore 中添加审批相关方法
classSessionStore{// ... 其他属性和方法// 给消息追加审批请求addApprovalPart(messageId:string,approval:ApprovalRequest){constmsg=this.messages.find(m=>m.id===messageId);if(msg){msg.parts.push({type:'approval',approval});}}// 更新审批状态(用户操作后)updateApprovalStatus(messageId:string,approvalId:string,status:'approved'|'denied'){constmsg=this.messages.find(m=>m.id===messageId);if(!msg)return;constapprovalPart=msg.parts.find(p=>p.type==='approval'&&p.approval.id===approvalId)asExtract<MessagePart,{type:'approval'}>|undefined;if(approvalPart){// 可以在 approval 对象上加 status 字段(approvalPart.approvalasany).status=status;}}}9.5 审批的实时事件
审批请求是通过 WebSocket 推送过来的,和流式回复一样。
在 RealtimeHandler 中处理
更新src/store/RealtimeHandler.ts:
// 在 RealtimeEvent 类型中新增exporttypeRealtimeEvent=// ... 其他事件|{type:'approval_request';data:{messageId:string;conversationId:string;approval:ApprovalRequest;};};// 在 handleEvent 中新增 casecase'approval_request':this._handleApprovalRequest(event.data);break;// 新增处理方法private_handleApprovalRequest(data:any){if(this.sessionStore.sessionId!==data.conversationId)return;this.sessionStore.addApprovalPart(data.messageId,data.approval);}提交审批的方法
在RootStore中添加提交审批的方法:
asyncsubmitApproval(approvalId:string,action:string,messageId:string){try{awaitthis.gateway.submitApproval({approvalId,action,});// 本地更新状态(乐观更新)this.sessionStore.updateApprovalStatus(messageId,approvalId,action==='deny'?'denied':'approved');}catch(error){console.error('审批失败:',error);throwerror;}}在网关层添加对应方法:
// WorkbenchGateway 接口中新增submitApproval(params:{approvalId:string;action:string}):Promise<void>;// HttpGateway 中实现asyncsubmitApproval(params:{approvalId:string;action:string}):Promise<void>{awaithttp.post('/approvals/submit',params);}9.6 安全模式(权限等级)
除了单次审批,Agent 还有不同的安全运行模式,控制它的自主程度。
三种安全模式
| 模式 | 说明 | 适用场景 |
|---|---|---|
| 完全自主 | 所有操作都自动执行,不需要审批 | 完全信任的环境、低风险操作 |
| 敏感操作审批 | 高风险操作需要审批,普通操作自动执行 | 大多数日常场景 |
| 全部审批 | 所有操作都需要人工确认 | 高安全要求环境、重要操作 |
安全模式的 Store
可以在 AgentStore 或单独的 SettingsStore 里管理:
exporttypeSecurityMode='full_auto'|'sensitive_approval'|'all_approval';classSettingsStore{securityMode:SecurityMode='sensitive_approval';constructor(){makeAutoObservable(this);}setSecurityMode(mode:SecurityMode){this.securityMode=mode;// 同步到后端// gateway.setSecurityMode(mode);}}在 Composer 中切换
在输入框工具栏加一个安全模式切换按钮,让用户可以快速切换:
// 在 Composer 组件中 const handleModeChange = (mode: SecurityMode) => { settingsStore.setSecurityMode(mode); }; // 渲染模式选择下拉 <Select value={securityMode} onChange={handleModeChange}> <Option value="full_auto">完全自主</Option> <Option value="sensitive_approval">敏感审批</Option> <Option value="all_approval">全部审批</Option> </Select>9.7 危险命令检测
除了后端控制,前端也可以加一层防护——检测用户输入的危险命令,二次确认。
危险命令检测函数
创建src/utils/dangerousCommand.ts:
// 危险命令模式列表(简化版,实际项目会更完善)constDANGEROUS_PATTERNS=[{pattern:/\brm\s+-rf\s+(\/|\*|~)/,severity:'high',description:'递归删除根目录'},{pattern:/\bdd\s+if=/,severity:'high',description:'dd 命令可能破坏磁盘'},{pattern:/\bmkfs\b/,severity:'high',description:'格式化文件系统'},{pattern:/\bDROP\s+TABLE\b/i,severity:'high',description:'删除数据库表'},{pattern:/\bDELETE\s+FROM\s+\w+\s*(;|$)/i,severity:'medium',description:'删除表数据(无 WHERE)'},{pattern:/\bUPDATE\s+\w+\s+SET\s+\w+\s*=/i,severity:'medium',description:'更新数据(请确认 WHERE 条件)'},];exportinterfaceDangerCheckResult{dangerous:boolean;severity:'none'|'low'|'medium'|'high';warnings:string[];}exportfunctioncheckDangerousCommand(input:string):DangerCheckResult{constwarnings:string[]=[];letmaxSeverity:'none'|'low'|'medium'|'high'='none';for(const{pattern,severity,description}ofDANGEROUS_PATTERNS){if(pattern.test(input)){warnings.push(description);if((severity==='high')||(severity==='medium'&&maxSeverity!=='high')||(severity==='low'&&maxSeverity==='none')){maxSeverity=severityasany;}}}return{dangerous:warnings.length>0,severity:maxSeverity,warnings,};}在发送前检测
在 Composer 的发送逻辑中加检测:
const handleSend = () => { // 危险命令检测 const check = checkDangerousCommand(content); if (check.dangerous && check.severity === 'high') { Modal.confirm({ title: '⚠️ 检测到危险操作', icon: <WarningOutlined />, content: ( <div> <p>你输入的内容包含以下危险操作:</p> <ul> {check.warnings.map((w, i) => <li key={i}>{w}</li>)} </ul> <p style={{ color: '#ff4d4f', marginTop: 12 }}> 请确认你知道自己在做什么,继续执行吗? </p> </div> ), okText: '确认执行', okType: 'danger', cancelText: '取消', onOk: doSend, }); return; } doSend(); };⚠️注意:前端检测只是辅助防护,真正的安全控制一定要在后端做。前端检测的目的是「提醒用户」和「提升体验」,不能替代后端校验。
9.8 本章小结
你学到了什么?
- ✅ 理解了审批机制的必要性和作用
- ✅ 掌握了审批流程的完整链路
- ✅ 实现了审批卡片组件
- ✅ 学会了在消息中存储结构化内容(Message Part)
- ✅ 了解了三种安全模式
- ✅ 学会了前端危险命令检测
安全机制总览
安全防线 ├── 第一道:前端危险命令检测(提醒用户) ├── 第二道:审批机制(敏感操作人工确认) │ ├── 允许一次 │ ├── 始终允许(加白名单) │ └── 拒绝 └── 第三道:安全模式(整体权限等级) ├── 完全自主 ├── 敏感操作审批 └── 全部审批✏️ 动手练习
- 跟着本章实现审批卡片组件
- 用 Mock 数据模拟一个审批流程,看看效果
- 增加「始终允许」功能:点击后把该类型加入白名单,下次自动通过
- 实现危险命令检测功能,高风险命令弹出二次确认
- 思考一下:审批过期了怎么办?前端和后端分别应该怎么处理?
下一章我们来看更多的业务模块——定时任务、记忆管理、设置面板… 学完你就知道一个完整的工作台还需要哪些功能了。