s16 - 权限与确认
Agent 能调工具了,但不是所有工具都该无条件执行。删文件、发邮件、部署代码——这些操作一旦错了,没有撤销键。 这一章要做的事情,是让 Agent 在执行危险操作之前先问你一句。
这一章要解决什么问题
s15 的工作流编辑器能画流程、能运行、能看日志。执行器会按拓扑顺序遍历节点,遇到 tool 节点就调用对应的 API。
问题在于:执行器对所有工具一视同仁。
read_file 和 delete_file 在它眼里没有区别——都是调个 API,拿个返回值。但对你来说,读文件是无害的,删文件是不可逆的。如果 Agent 自作主张删了你的代码,你不会觉得"日志里记了就行"。
这一章的目标:
- 给每个工具标注风险等级(safe / moderate / dangerous)
- 执行 dangerous 工具前弹出确认对话框
- 支持"始终允许"——信任某个工具后不再弹窗
- 所有权限决定记入日志,方便审计
- 在画布上直接看到工具节点的风险等级
风险分级
先定义工具的风险等级。这是一个简单的映射表:
// engine/permission.ts
const RISK_MAP: Record<string, RiskLevel> = {
// safe:只读,不改变任何状态
read_file: "safe",
list_files: "safe",
search: "safe",
// moderate:有副作用但可逆
http_request: "moderate",
write_file: "moderate",
// dangerous:不可逆或影响范围大
delete_file: "dangerous",
execute_command: "dangerous",
send_email: "dangerous",
};不在表里的工具默认 moderate——宁可多问一次,也不要漏掉风险。
三级风险对应的处理策略:
| 风险等级 | 处理方式 | 用户感知 |
|---|---|---|
| safe | 直接放行 | 无感 |
| moderate | 弹窗确认 | 看到参数,决定是否执行 |
| dangerous | 弹窗确认 + 警告色 | 红色警告,强调不可逆 |
权限中间件
核心设计是一个 PermissionChecker 类。它不是在执行器内部加 if-else,而是作为独立的中间件,拦截在工具调用之前。
// engine/permission.ts
export class PermissionChecker {
private session: SessionPermissions;
check(nodeId: string, toolName: string, args: unknown): PermissionCheckResult {
const riskLevel = getRiskLevel(toolName);
// 1. 会话级"始终允许"——用户之前选过"始终允许"
if (this.session.alwaysAllow.has(toolName)) {
return { allowed: true, riskLevel };
}
// 2. safe 级别直接放行
if (riskLevel === "safe") {
return { allowed: true, riskLevel };
}
// 3. 其他情况需要用户确认
return {
allowed: false,
riskLevel,
pendingConfirmation: { nodeId, toolName, args, riskLevel, ... },
};
}
}为什么是独立的中间件而不是写在执行器里?因为权限逻辑和执行逻辑是两件事。执行器关心"怎么跑",权限中间件关心"能不能跑"。分开之后,你可以单独测试权限逻辑,也可以在不改执行器的情况下替换权限策略。
执行器的变化
执行器现在接受一个 PermissionChecker 和一个 onConfirm 回调。遇到需要确认的工具调用时,它会暂停,等用户做出决定。
// engine/executor.ts
export async function executeWorkflow(
workflow: Workflow,
input: Record<string, unknown>,
checker: PermissionChecker,
onConfirm: (confirmation: PendingConfirmation) => Promise<boolean>,
listener?: Listener,
): Promise<ExecutionRun> {
// ...
for (const node of sorted) {
if (node.type === "tool") {
const toolName = node.data.tool as string;
const checkResult = checker.check(node.id, toolName, nodeInput);
if (!checkResult.allowed) {
if (checkResult.pendingConfirmation) {
// 暂停执行,等用户确认
const approved = await onConfirm(checkResult.pendingConfirmation);
if (approved) {
// 用户允许,继续执行
const output = await runToolNode(node.data, nodeInput);
// ...
} else {
// 用户拒绝,标记为 denied
logger.denyNode(node.id, node.type);
}
}
continue;
}
}
// 正常执行...
}
}关键在于 await onConfirm(...) 这一行。执行器在这里暂停,把控制权交给 UI。用户在弹窗里做出决定后,Promise resolve,执行器继续往下走。
这是一个典型的 human-in-the-loop 模式:Agent 自主运行,但在关键节点暂停等人。
确认对话框
弹窗组件接收一个 PendingConfirmation 对象,展示三件事:
- 什么工具:工具名称和参数预览
- 什么风险:用颜色和文字标注风险等级
- 怎么决定:三个按钮——允许、拒绝、始终允许
// components/ConfirmationDialog.tsx
<button onClick={onDeny}>拒绝</button>
<button onClick={onAllow}>允许</button>
<button onClick={onAlwaysAllow}>始终允许</button>"始终允许"的实现很简单:把工具名加入 session.alwaysAllow 这个 Set。后续同一个工具的调用,PermissionChecker.check() 直接返回 allowed: true,不再弹窗。
为什么用 Set 而不是数组?因为 Set 的 has() 是 O(1),而且自动去重。工具列表通常很短,性能差异可以忽略,但语义上 Set 更准确——"这个集合里的工具都被信任了"。
页面层的串联
主页面要做的事情比较多,但逻辑很清晰:
// app/page.tsx
export default function Home() {
// 弹窗状态
const [pendingConfirmation, setPendingConfirmation] = useState(null);
// 会话权限配置(用 ref 而不是 state,因为它不需要触发重渲染)
const sessionPermissionsRef = useRef({
alwaysAllow: new Set<string>(),
alwaysDeny: new Set<string>(),
});
// 执行器暂停时的 resolve 函数
const confirmResolveRef = useRef(null);
// 执行器调用这个函数,返回一个 Promise
const handleConfirm = (confirmation) => {
return new Promise((resolve) => {
setPendingConfirmation(confirmation);
confirmResolveRef.current = resolve;
});
};
// 用户点"允许"
const handleAllow = () => {
confirmResolveRef.current(true); // 唤醒执行器
setPendingConfirmation(null); // 关闭弹窗
};
// 用户点"拒绝"
const handleDeny = () => {
confirmResolveRef.current(false);
setPendingConfirmation(null);
};
// 用户点"始终允许"
const handleAlwaysAllow = () => {
sessionPermissionsRef.current.alwaysAllow.add(pendingConfirmation.toolName);
confirmResolveRef.current(true);
setPendingConfirmation(null);
};
}注意 sessionPermissionsRef 用了 useRef 而不是 useState。为什么?因为权限配置的变更不需要触发 UI 重渲染——它只在执行器内部被读取。用 ref 避免了不必要的渲染,也避免了闭包陷阱。
画布上的风险标识
ToolNode 组件现在会显示风险等级:
// components/nodes/ToolNode.tsx
const riskLevel = getRiskLevel(tool);
// safe 不显示任何标识——默认就是安全的
if (riskLevel === "safe") return null;
// moderate 和 dangerous 显示小标签
<span className={`text-[9px] px-1 py-0.5 rounded ${riskDisplay.bgColor} ${riskDisplay.color}`}>
{riskLevel === "dangerous" ? "危险" : "注意"}
</span>设计选择:safe 不显示标签。如果每个工具都标"安全",标签就失去了意义。只有非安全的工具才需要提醒,这样才能引起注意。
权限日志
每次权限检查都会产生一条日志,记录:哪个节点、什么工具、什么风险、做了什么决定。
{
id: "plog_1",
nodeId: "n_tool",
toolName: "delete_file",
args: { path: "/tmp/test.txt" },
riskLevel: "dangerous",
decision: "allow", // allow | deny | always_allow
timestamp: 1748400000000,
}右侧面板新增了"权限"标签页,展示这些日志。你可以看到:
- 哪些工具被检查了
- 每个工具的风险等级
- 用户做出了什么决定
- 是否有工具被"始终允许"
这不只是方便——它是审计的基础。如果你的 Agent 在生产环境运行,你需要知道它调用了什么工具、用户是否知情。
新增的节点状态
denied 是这一章新增的节点状态。它和 error 不同:
error:工具执行了,但失败了(网络错误、API 报错等)denied:工具没有执行,因为用户拒绝了
在画布上,denied 状态用橙色圆点表示——区别于 error 的红色,但同样醒目。时间线里,denied 节点显示为橙色条。
三栏布局的变化
右侧面板从三个标签页变成了四个:
┌──────────┬────────────────────┬──────────────┐
│ 节点库 │ │ 实时│历史│详情 │ ← s15
│ │ │ │
│ 属性编辑 │ 工作流画布 │ 权限│历史│详情 │ ← s16
│ │ │ │
│ │ │ 时间线 │
│ │ │ 节点日志 │
│ │ │ 权限日志 │
└──────────┴────────────────────┴──────────────┘左侧面板的工具编辑器也增加了风险等级提示——当你选择一个工具时,能直接看到它的风险等级和说明。
教学边界
这一章只做一件事:在工具执行前加入权限控制。
不涉及:
- 用户认证(谁有权执行这个工作流)
- 细粒度权限(只允许读特定目录的文件)
- 权限持久化(刷新页面后"始终允许"就没了)
- 审批流程(多人审批、审批链)
- 权限继承(工作流级别的权限策略)
这些都在后面的章节里。现在的权限系统是"会话级"的——一次浏览器会话内的临时信任关系。
试着改改
1. 添加"始终拒绝"
在确认对话框上增加一个"始终拒绝"按钮。实现方式和"始终允许"对称:把工具名加入 session.alwaysDeny。适合那些你确定永远不想执行的工具。
2. 权限倒计时
确认对话框加一个 30 秒倒计时。如果用户 30 秒内没有做出决定,自动拒绝。这在 Agent 自动运行时很有用——你不想让它一直等着。
const [countdown, setCountdown] = useState(30);
useEffect(() => {
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
onDeny(); // 超时自动拒绝
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, []);3. 参数高亮
在确认对话框里,把危险参数(文件路径、命令字符串)高亮显示。让用户一眼就能看到 Agent 要操作什么。
// 如果参数里有 path 字段,标红显示
{args.path && (
<span className="text-red-400 font-mono">{args.path}</span>
)}4. 权限配置面板
把"始终允许"的工具列表做成一个可管理的面板。显示当前信任的工具,支持手动移除。这比现在的"只增不减"更实用。
一句话记住
权限中间件是 Agent 和工具之间的关卡——safe 的直接放行,dangerous 的先问人。"始终允许"是用户的快捷方式,但所有决定都留了日志。