18 工具策略与审批状态机实现实战(tool policy + exec approval)
目标:把"工具安全"做成真正可控的代码系统,而不是提示词约束。
对应源码入口
src/agents/pi-tools.tssrc/agents/tool-policy.tssrc/agents/pi-tools.policy.tssrc/agents/tool-policy-pipeline.tssrc/agents/pi-tools.before-tool-call.tssrc/agents/pi-tool-definition-adapter.tssrc/infra/exec-approvals.tssrc/gateway/exec-approval-manager.tssrc/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) : withHooksStep 1 — owner-only 裁剪
// 默认 false:安全 opt-in,不是 opt-out
const senderIsOwner = options?.senderIsOwner === true;
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner);Step 2 — pipeline 的精确步骤标签(源码字面量)
buildDefaultToolPolicyPipelineSteps(...) 生成的步骤:
[
// 以下三步 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 拒绝)
const normalized = subagentFiltered.map(normalizeToolParameters);OpenAI 会拒绝根级 union schema(如 anyOf 出现在 parameters 根节点)。 没有这一步,有些工具在 OpenAI provider 下无声地被服务端拒绝。 这一步必须在 Hook 注入之前,因为 wrapToolWithBeforeToolCallHook 会包裹整个 execute。
Step 4 — before_tool_call Hook 注入
wrapToolWithBeforeToolCallHook 的精确实现模式:
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):
// after_tool_call 需要的是"hook 改写后的参数",不是原始参数
const afterParams = beforeHookWrapped
? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams)
: executeParams;多插件 before_tool_call 合并规则:
// 顺序执行,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 的双路径触发(成功 + 失败)
// 成功路径(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(...) 的核心规则:
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
- 校验参数
manager.create(...)manager.register(...)— 必须先注册再响应,避免响应后客户端来 waitDecision 时 entry 还未注册- 广播
exec.approval.requested(通知 UI 弹审批框) - two-phase 模式:先回
accepted,然后等最终 decision 再推送
exec.approval.waitDecision
awaitDecision(id)拿 promise- entry 不存在(过期或未注册)→ 返回
{ error: "expired or not found" } - 等待 resolve,返回 decision(可能是
null= 超时)
exec.approval.resolve
- 校验 decision 只能是
allow-once/allow-always/deny manager.resolve(...)- 广播
exec.approval.resolved
五、最小复刻骨架(含完整 5 步管线)
// === 工具策略管线 ===
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;
}六、验收清单
- 同一审批 id 不会注册为两个不同 promise。
- 超时后结果是
null(不是 reject,不是永远挂起)。 resolve后二次resolve返回 false(幂等)。waitDecision在 grace 窗口内(15s)仍可拿到 decision。- pipeline 步骤顺序固定,
stripPluginOnlyAllowlist仅在 profile/group 步骤开启。 - schema 规范化在 Hook 注入之前执行。
- before_tool_call 改写的参数通过
adjustedParamsByToolCallId传递,不直接覆盖原始参数。 - after_tool_call 在错误分支仍然触发(fire-and-forget)。
- apply_patch 对非 OpenAI provider 默认关闭。
七、常见误区
- 先响应 accepted 再 register → race condition(客户端来 waitDecision 时 entry 还未存在)。
- 把审批逻辑塞进工具函数内部 → 耦合爆炸,无法独立测试。
- 只有 allowlist 没有 owner-only 裁剪 → 高风险工具仍可被非 owner 调到。
- 状态机无超时回收 → pending map 内存持续增长。
- schema 规范化步骤放在 Hook 注入之后 → wrapToolWithBeforeToolCallHook 拿到的 execute 是旧的,normalize 无效。
adjustedParamsByToolCallId无上限 → 高并发下内存泄漏。