Skip to content

s16 - 权限与确认

Agent 能调工具了,但不是所有工具都该无条件执行。删文件、发邮件、部署代码——这些操作一旦错了,没有撤销键。 这一章要做的事情,是让 Agent 在执行危险操作之前先问你一句。

这一章要解决什么问题

s15 的工作流编辑器能画流程、能运行、能看日志。执行器会按拓扑顺序遍历节点,遇到 tool 节点就调用对应的 API。

问题在于:执行器对所有工具一视同仁。

read_filedelete_file 在它眼里没有区别——都是调个 API,拿个返回值。但对你来说,读文件是无害的,删文件是不可逆的。如果 Agent 自作主张删了你的代码,你不会觉得"日志里记了就行"。

这一章的目标:

  1. 给每个工具标注风险等级(safe / moderate / dangerous)
  2. 执行 dangerous 工具前弹出确认对话框
  3. 支持"始终允许"——信任某个工具后不再弹窗
  4. 所有权限决定记入日志,方便审计
  5. 在画布上直接看到工具节点的风险等级

风险分级

先定义工具的风险等级。这是一个简单的映射表:

typescript
// 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,而是作为独立的中间件,拦截在工具调用之前。

typescript
// 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 回调。遇到需要确认的工具调用时,它会暂停,等用户做出决定。

typescript
// 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 对象,展示三件事:

  1. 什么工具:工具名称和参数预览
  2. 什么风险:用颜色和文字标注风险等级
  3. 怎么决定:三个按钮——允许、拒绝、始终允许
tsx
// 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 更准确——"这个集合里的工具都被信任了"。

页面层的串联

主页面要做的事情比较多,但逻辑很清晰:

tsx
// 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 组件现在会显示风险等级:

tsx
// 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 不显示标签。如果每个工具都标"安全",标签就失去了意义。只有非安全的工具才需要提醒,这样才能引起注意。

权限日志

每次权限检查都会产生一条日志,记录:哪个节点、什么工具、什么风险、做了什么决定。

typescript
{
  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 自动运行时很有用——你不想让它一直等着。

tsx
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 要操作什么。

tsx
// 如果参数里有 path 字段,标红显示
{args.path && (
  <span className="text-red-400 font-mono">{args.path}</span>
)}

4. 权限配置面板

把"始终允许"的工具列表做成一个可管理的面板。显示当前信任的工具,支持手动移除。这比现在的"只增不减"更实用。

一句话记住

权限中间件是 Agent 和工具之间的关卡——safe 的直接放行,dangerous 的先问人。"始终允许"是用户的快捷方式,但所有决定都留了日志。