Skip to content

18 工具策略与审批状态机实现实战(tool policy + exec approval)

目标:把"工具安全"做成真正可控的代码系统,而不是提示词约束。

对应源码入口

  • src/agents/pi-tools.ts
  • src/agents/tool-policy.ts
  • src/agents/pi-tools.policy.ts
  • src/agents/tool-policy-pipeline.ts
  • src/agents/pi-tools.before-tool-call.ts
  • src/agents/pi-tool-definition-adapter.ts
  • src/infra/exec-approvals.ts
  • src/gateway/exec-approval-manager.ts
  • src/gateway/server-methods/exec-approval.ts

一、工具策略流水线(谁能用哪些工具)

核心调用在 createOpenClawCodingTools(...),执行顺序固定为 5 步:

Step 1  applyOwnerOnlyToolPolicy(tools, senderIsOwner)
Step 2  applyToolPolicyPipeline(tools, steps)
Step 3  normalized = subagentFiltered.map(normalizeToolParameters)
Step 4  withHooks  = normalized.map(wrapToolWithBeforeToolCallHook)
Step 5  withAbort  = abortSignal ? withHooks.map(wrapToolWithAbortSignal) : withHooks

Step 1 — owner-only 裁剪

ts
// 默认 false:安全 opt-in,不是 opt-out
const senderIsOwner = options?.senderIsOwner === true;
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner);

Step 2 — pipeline 的精确步骤标签(源码字面量)

buildDefaultToolPolicyPipelineSteps(...) 生成的步骤:

ts
[
  // 以下三步 stripPluginOnlyAllowlist: true
  { label: `tools.profile (${profile})`,          stripPluginOnlyAllowlist: true },
  { label: `tools.provider-profile (${profile})`, stripPluginOnlyAllowlist: true },
  { label: "group tools.allow",                   stripPluginOnlyAllowlist: true },

  // 以下四步不 strip
  { label: "tools.global"                   },
  { label: "tools.global-provider"          },
  { label: `tools.agent (${agentId})`       },  // 仅 agentId 非空时
  { label: `tools.agent-provider (${agentId})` }, // 仅 agentId 非空时

  // pi-tools.ts 在 pipeline 外追加
  { label: "sandbox tools.allow"   },
  { label: "subagent tools.allow"  },
]

stripPluginOnlyAllowlist: true 保护:若 allowlist 只含插件工具名(插件未启用), 自动剥离该 allowlist,阻止"配置写了但不生效导致核心工具全消失"的问题。

Step 3 — schema 规范化(防 OpenAI 拒绝)

ts
const normalized = subagentFiltered.map(normalizeToolParameters);

OpenAI 会拒绝根级 union schema(如 anyOf 出现在 parameters 根节点)。 没有这一步,有些工具在 OpenAI provider 下无声地被服务端拒绝。 这一步必须在 Hook 注入之前,因为 wrapToolWithBeforeToolCallHook 会包裹整个 execute。

Step 4 — before_tool_call Hook 注入

wrapToolWithBeforeToolCallHook 的精确实现模式:

ts
const wrappedTool: AnyAgentTool = {
  ...tool,
  execute: async (toolCallId, params, signal, onUpdate) => {
    // 1. 运行 before_tool_call hook
    const outcome = await runBeforeToolCallHook({ toolName, params, toolCallId });

    // 2. 被阻断 → 直接 throw
    if (outcome.blocked) { throw new Error(outcome.reason); }

    // 3. 参数被改写 → 暂存到 adjustedParamsByToolCallId
    if (toolCallId) {
      adjustedParamsByToolCallId.set(toolCallId, outcome.params);
      // Map 有上限,超出后淘汰最旧条目(防内存泄漏)
      if (adjustedParamsByToolCallId.size > MAX_TRACKED_ADJUSTED_PARAMS) {
        const oldest = adjustedParamsByToolCallId.keys().next().value;
        if (oldest) adjustedParamsByToolCallId.delete(oldest);
      }
    }

    // 4. 用 outcome.params 执行工具(不是原始 params)
    return await execute(toolCallId, outcome.params, signal, onUpdate);
  },
};
// 标记已包裹,避免 pi-tool-definition-adapter 再次包裹
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, { value: true, enumerable: false });

参数最终如何取回(pi-tool-definition-adapter.ts):

ts
// after_tool_call 需要的是"hook 改写后的参数",不是原始参数
const afterParams = beforeHookWrapped
  ? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams)
  : executeParams;

多插件 before_tool_call 合并规则:

ts
// 顺序执行,last-write-wins
mergeResults: (acc, next) => ({
  params:      next.params      ?? acc?.params,
  block:       next.block       ?? acc?.block,
  blockReason: next.blockReason ?? acc?.blockReason,
})
// 参数合并方式(单插件):{ ...originalParams, ...hookResult.params }

after_tool_call 的双路径触发(成功 + 失败)

ts
// 成功路径(try 块末尾)
hookRunner.runAfterToolCall({ toolName, params: afterParams, result }, ctx);

// 失败路径(catch 块)
hookRunner.runAfterToolCall({ toolName, params, error: described.message }, ctx);

两条路径均为 fire-and-forget(不 await),Hook 失败只记 debug log,不影响工具结果。

二、审批判定逻辑(要不要人工确认)

requiresExecApproval(...) 的核心规则:

ts
ask === "always"
|| (
  ask === "on-miss"
  && security === "allowlist"
  && (!analysisOk || !allowlistSatisfied)
)
  • always:一律审批
  • on-miss + allowlist:分析失败或白名单不满足才审批
  • 其他情况:不审批

三、审批状态机(请求 -> 等待 -> 决策)

ExecApprovalManager 是核心状态机:

create(...) 生成 id/createdAtMs/expiresAtMs

register(...) 同步写入 pending map 并返回 promise。 同 id pending 时幂等返回同一 promise(防止两次 waitDecision 拿到不同 promise)。 超时 resolve null(不是 reject,调用方需要判断 null = timeout)。

resolve(...) 写入 decision/resolvedAtMs/resolvedBy,并 resolve promise。

RESOLVED_ENTRY_GRACE_MS = 15000 已决条目保留 15 秒。理由:两阶段调用里,request 先收到 accepted, 再调 waitDecision 时 resolve 可能已经发生,grace 窗口保证此时仍能取到 decision。

四、网关方法层时序(server-methods/exec-approval.ts)

exec.approval.request

  1. 校验参数
  2. manager.create(...)
  3. manager.register(...)必须先注册再响应,避免响应后客户端来 waitDecision 时 entry 还未注册
  4. 广播 exec.approval.requested(通知 UI 弹审批框)
  5. two-phase 模式:先回 accepted,然后等最终 decision 再推送

exec.approval.waitDecision

  1. awaitDecision(id) 拿 promise
  2. entry 不存在(过期或未注册)→ 返回 { error: "expired or not found" }
  3. 等待 resolve,返回 decision(可能是 null = 超时)

exec.approval.resolve

  1. 校验 decision 只能是 allow-once/allow-always/deny
  2. manager.resolve(...)
  3. 广播 exec.approval.resolved

五、最小复刻骨架(含完整 5 步管线)

ts
// === 工具策略管线 ===
function buildAllowedTools(all: Tool[], ctx: PolicyCtx): Tool[] {
  // Step 1: owner-only 裁剪
  const owned = applyOwnerOnlyToolPolicy(all, ctx.senderIsOwner);
  // Step 2: 多层 pipeline(顺序固定,stripPluginOnlyAllowlist 按步骤开启)
  const filtered = applyToolPolicyPipeline(owned, [
    { policy: ctx.profilePolicy,  label: `tools.profile (${ctx.profile})`,  stripPluginOnlyAllowlist: true },
    { policy: ctx.globalPolicy,   label: "tools.global"                                                    },
    { policy: ctx.agentPolicy,    label: `tools.agent (${ctx.agentId})`                                    },
    { policy: ctx.groupPolicy,    label: "group tools.allow",               stripPluginOnlyAllowlist: true },
    { policy: ctx.sandboxPolicy,  label: "sandbox tools.allow"                                             },
    { policy: ctx.subagentPolicy, label: "subagent tools.allow"                                            },
  ]);
  // Step 3: schema 规范化(必须在 Hook 之前)
  const normalized = filtered.map(normalizeToolParameters);
  // Step 4: Hook 注入
  return normalized.map((t) => wrapWithBeforeHook(t, ctx));
}

// === 审批状态机 ===
type Decision = "allow-once" | "allow-always" | "deny" | null;
type Entry = { resolve: (d: Decision) => void; promise: Promise<Decision>; timer: NodeJS.Timeout; resolvedAt?: number };

const pending = new Map<string, Entry>();
const GRACE_MS = 15_000;

function register(id: string, timeoutMs: number): Promise<Decision> {
  const exist = pending.get(id);
  if (exist && !exist.resolvedAt) return exist.promise;          // 幂等
  if (exist?.resolvedAt)          throw new Error("already resolved");

  let done!: (d: Decision) => void;
  const promise = new Promise<Decision>((r) => (done = r));
  const e: Entry = {
    resolve: done, promise,
    timer: setTimeout(() => {
      e.resolvedAt = Date.now();
      done(null);                                               // 超时 → null
      setTimeout(() => pending.delete(id), GRACE_MS);
    }, timeoutMs),
  };
  pending.set(id, e);
  return promise;
}

function resolveDecision(id: string, d: Exclude<Decision, null>): boolean {
  const e = pending.get(id);
  if (!e || e.resolvedAt) return false;                         // 幂等拒绝
  clearTimeout(e.timer);
  e.resolvedAt = Date.now();
  e.resolve(d);
  setTimeout(() => pending.delete(id), GRACE_MS);
  return true;
}

六、验收清单

  1. 同一审批 id 不会注册为两个不同 promise。
  2. 超时后结果是 null(不是 reject,不是永远挂起)。
  3. resolve 后二次 resolve 返回 false(幂等)。
  4. waitDecision 在 grace 窗口内(15s)仍可拿到 decision。
  5. pipeline 步骤顺序固定,stripPluginOnlyAllowlist 仅在 profile/group 步骤开启。
  6. schema 规范化在 Hook 注入之前执行。
  7. before_tool_call 改写的参数通过 adjustedParamsByToolCallId 传递,不直接覆盖原始参数。
  8. after_tool_call 在错误分支仍然触发(fire-and-forget)。
  9. apply_patch 对非 OpenAI provider 默认关闭。

七、常见误区

  1. 先响应 accepted 再 register → race condition(客户端来 waitDecision 时 entry 还未存在)。
  2. 把审批逻辑塞进工具函数内部 → 耦合爆炸,无法独立测试。
  3. 只有 allowlist 没有 owner-only 裁剪 → 高风险工具仍可被非 owner 调到。
  4. 状态机无超时回收 → pending map 内存持续增长。
  5. schema 规范化步骤放在 Hook 注入之后 → wrapToolWithBeforeToolCallHook 拿到的 execute 是旧的,normalize 无效。
  6. adjustedParamsByToolCallId 无上限 → 高并发下内存泄漏。

用工程视角拆解 AI 智能体框架