KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 工具体系与执行安全:四层策略只能收紧不能放宽 — keel 龙骨

OpenClaw 的工具层有一条贯穿始终的设计律:每一层策略只能让结果更严格,不能更宽松。Profile 限定可见工具集合,allow / deny 在其上做增减,会话权限模式把文件与 shell 的边界进一步压紧,exec 审批再压一层。四层叠加的结果永远是各层「最严」的子集。

OpenClaw 的工具层有一条贯穿始终的设计律:每一层策略只能让结果更严格,不能更宽松。Profile 限定可见工具集合,allow / deny 在其上做增减,会话权限模式把文件与 shell 的边界进一步压紧,exec 审批再压一层。四层叠加的结果永远是各层「最严」的子集。

这一章沿着一次工具调用要走完的路径展开:从目录声明 → 策略求解 → 参数校验 → 并发/截断 → exec 审批 → 路径围栏。要点:

  1. 为什么工具是「一工具一文件 + 一份声明式 catalog」,而不是运行时自注册;
  2. Profile 与 ToolPolicyLike 如何逐层收紧;
  3. 工具结果的截断为什么是两级(固定上限 + 上下文预算);
  4. ExecSecurity / ExecAsk / safe-bin / 两阶段审批四者如何组合;
  5. 路径围栏在 wrapToolWorkspaceRootGuard 与 workspace.ts 两处各拦什么。

一、工具目录:一工具一文件,一份 catalog 做声明

src/agents/tools/ 下有两百多个 .ts 文件,约定是一个工具一个文件加同名 .test.ts(例如 sessions-spawn-tool.ts / sessions-spawn-tool.test.ts、web-search.ts / web-search.test.ts)。但「有哪些工具」不靠扫描目录决定,而是靠 src/agents/tool-catalog.ts 的 CORE_TOOL_DEFINITIONS(第 69 行)这份显式清单。

每条定义的结构是 CoreToolDefinition(第 46 行):id / label / description / sectionId / profiles / includeInOpenClawGroup?。sectionId 取自 CORE_TOOL_SECTION_ORDER(第 55 行)的 11 个分组:fs、runtime、web、memory、sessions、ui、messaging、automation、nodes、agents、media。

声明式清单换来了三件别处很难做的事:

运行时把「清单」变成「实际注册」的过滤器是 src/agents/openclaw-tools.registration.ts:collectPresentOpenClawTools()(第 41 行)——名字里的 present 是关键:声明存在 ≠ 运行时存在,插件没启用、渠道没配、能力不可用时工具不会出现。

还有一条按需检索通道:src/agents/tool-search-catalog.ts。resolveCatalog()(第 338 行)与 visibleCatalogEntries()(第 346 行)给搜索类工具提供可见目录,restrictToolSearchCatalog()(第 315 行)与 registerHeadlessToolSearchCatalog()(第 240 行)分别用于收窄与无头注册。

二、四层策略:逐层求交

第一层是 Profile。 ToolProfileId(src/agents/tool-catalog.ts:28)是四个字面量:"minimal" | "coding" | "messaging" | "full"。它是「你打算干什么」的粗粒度声明。

第二层是 allow / deny。 形状是 ToolPolicyLike(src/agents/tool-policy.ts:28):

export type ToolPolicyLike = {
  allow?: string[];
  deny?: string[];
  [IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW]?: true;
};

这一层有几个不显眼但重要的求值函数:toolPolicyRestrictsTools()(第 98 行)判断策略是否真的收窄了;replaceWithEffectiveToolAllowlist()(第 115 行)做「交集式替换」;collectExplicitAllowlist() / collectExplicitDenylist()(第 132 / 160 行)把多份策略的显式清单合并;expandPolicyWithPluginGroups()(第 239 行)把插件工具组展开成具体工具名。最终落到运行时的是 src/agents/embedded-agent-runner/effective-tool-policy.ts:applyFinalEffectiveToolPolicy()(第 44 行)——注意这份文件在 embedded-agent-runner 下,说明「有效策略」是运行时阶段的产物,不是配置解析期的。

第三层是会话权限模式。 通道协议侧的闭集定义在 packages/gateway-protocol/src/schema/sessions-row.ts 的 SessionPermissionModeSchema(第 8 到 13 行):read-only / guarded / workspace / full,用 TypeBox 的 Type.Union([Type.Literal(...)]) 表达。它向执行层的映射在 src/agents/session-permission-exec-mode.ts:

const EXEC_MODE_BY_PERMISSION_MODE = {
  "read-only": "deny",
  guarded: "ask",
  workspace: "auto",
  full: "full",
} as const satisfies Record<PreparedSessionPermissionPolicy["mode"], ExecMode>;

同文件的 resolveSessionPermissionCoreToolPolicy()(第 11 行)把非 full 的模式统一标成 workspaceOnly,并在 read-only 时置 readOnly。

第四层是 exec 审批。 见第五节。

之所以说「只能收紧」,是因为每一层的语义都是求交:Profile 给集合,allow/deny 做增删,权限模式给上限,审批给单次放行。没有任何一层能突破上层的约束。

三、参数 schema:TypeBox,一份定义两端消费

工具参数用 TypeBox(typebox@1.3.6)声明,而不是 zod。这一点在 package.json 的插件依赖里可以直接看到:extensions/telegram/package.json 同时带 "typebox": "1.3.6" 与 "zod": "4.4.3"——zod 用于配置校验,TypeBox 用于协议与工具 schema。

选 TypeBox 的收益是同一份 schema 可以产出 JSON Schema,从而同步生成 Swift 侧模型(apps/shared/OpenClawKit 是 Swift Package,apps/macos / apps/ios / apps/macos-mlx-tts / apps/swabble 都是 Package.swift 工程)。这就是协议层 gateway-protocol 里能用 Type.Union / Type.Literal 描述 SessionPermissionMode 的原因:它不只是 TS 类型,还是可导出的线格式契约。

代价是多一套 schema 方言:配置用 zod、协议与工具用 TypeBox,跨边界时要做转换,而 Type.Union / Type.Literal 的用法必须保持「可导出」的子集。

四、并发与截断

并发由 executeToolCalls()(packages/agent-core/src/agent-loop.ts:633)决定。它先解析每个 toolCall,若任一工具声明 executionMode === "sequential",或配置层直接把 toolExecution 设成 "sequential",就走 executeToolCallsSequential()(第 764 行);否则走 executeToolCallsParallel()(第 945 行)。判断「任一」而不是「全部」是保守选择:顺序工具的存在会污染整批的副作用顺序,所以整批降级为串行。批量前置钩子是 config.beforeToolBatch(第 645 行调用、第 672 行落点),它可以返回 intervention 直接接管整批。

截断分两级,先固定上限、再按上下文预算。

第一级在 src/agents/embedded-agent-tool-results.ts:TOOL_RESULT_MAX_CHARS = 8000(第 22 行)、TOOL_ERROR_MAX_CHARS = 400(第 23 行),另有 LIVE_EXEC_OUTPUT_MAX_CHARS = 8000(第 24 行)。truncateToolText()(第 36 行)截断后追加 …(truncated)…。同一文件还负责清理:OPAQUE_STRUCTURED_RESULT_FIELDS(第 26 行)包含 encrypted_content / encrypted_stdout 这类不可读字段,SENSITIVE_STRUCTURED_HEADER_FIELDS(第 27 到 34 行)明确列出 authorization、proxy-authorization、cookie、set-cookie、x-api-key、x-auth-token,sanitizeToolResult()(第 249 行)是清理入口。

第二级在 src/agents/embedded-agent-runner/ 下的两个文件(注意它们不在 src/agents/ 根目录):

两级的意义不同:第一级保证单条结果不会撑爆线格式;第二级保证多轮累积后的历史仍能塞进上下文预算,且允许不同工具类型(如实时 exec 输出)有不同预算。

五、exec 三级策略与 safe-bin

exec 的策略闭集在 src/infra/exec-approvals-core.ts:

export type ExecSecurity = "deny" | "allowlist" | "full";     // 第 9 行
export type ExecAsk = "off" | "on-miss" | "always";           // 第 10 行
export type ExecMode = "deny" | "allowlist" | "ask" | "auto" | "full";  // 第 11 行
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"; // 第 12 行

ExecSecurity 决定「允不允许跑」,ExecAsk 决定「什么时候问人」,两者正交。归一化入口是 normalizeExecSecurity()(第 57 行)与 normalizeExecAsk()(第 65 行)——注意它们返回 | null,即非法值不会被「修正」成一个宽松默认,而是被识别为非法。

allowlist 模式下真正判定的是 safe-bin 机制,实现在 src/infra/exec-approvals-allowlist.ts:从 exec-approvals 侧引入 DEFAULT_SAFE_BINS(第 44 行)、SAFE_BIN_PROFILES(第 45 行)、validateSafeBinArgv(第 47 行)。第 126 行用 normalizeSafeBins(DEFAULT_SAFE_BINS) 兜住缺省,第 171 到 176 行先取 params.safeBinProfiles ?? SAFE_BIN_PROFILES,再调 validateSafeBinArgv(argv, profile, { binName: execName })。关键点是 validate 的是 argv 而不只是 bin 名——git 在白名单里不代表 git -c core.pager=… 安全。

六、两阶段审批

审批不是「弹个框点确定」,而是两阶段:先注册、再解析。

src/agents/bash-tools.exec-approval-request.ts 定义 RequestExecApprovalDecisionParams(第 40 行)与配套上下文构造器 buildExecApprovalRequesterContext()(第 241 行)、buildExecApprovalTurnSourceContext()(第 259 行)。发起侧走 registerExecApprovalRequestForHostOrThrow()(第 355 行)把请求登记进 Host;等待侧走 resolveRegisteredExecApprovalDecision()(第 175 行)拿回结论。中途 run 被取消时有专门错误类型,判定函数是 isExecApprovalRunAbortedError()(第 148 行)。

两阶段的收益是审批请求有了持久身份:请求可以先出现在渠道里(一条带按钮的消息),人过一会儿再点,而发起这次 exec 的 run 依然能正确关联。代价是必须处理「请求还在但 run 已经没了」的状态,所以第 148 行那个 aborted 判定是必需的,不是可选的健壮性糖。

七、路径围栏:两层拦不同的问题

文件类工具受两层围栏保护。

第一层是工具包装器:src/agents/agent-tools.read.ts 第 515 行注释写着 "Wrap a file tool so path params stay inside the workspace root.",实现是 wrapToolWorkspaceRootGuard()(第 516 行),另有带选项的 wrapToolWorkspaceRootGuardWithOptions()(第 817 行)和沙箱化构造器 createSandboxedReadTool() / createSandboxedWriteTool() / createSandboxedEditTool()(第 913 / 928 / 940 行)。它拦的是模型给的路径参数——不管工具内部怎么用这个路径,先确认它落在 workspace 根内。

第二层是 workspace 侧的遍历与模式校验:src/agents/workspace.ts 从 ../infra/path-guards.js 引入 isPathInside(第 20 行),在目录遍历时第 1322 行用 isPathInside(workspaceDir, currentDir) 防越界,第 1395 行同样用它确认 walkRoot,第 1422 行给出 "pattern resolves outside the workspace" 的拒绝理由。

两层不是冗余:第一层挡「参数直接越界」,第二层挡「参数看起来在界内、但语义允许逃逸」的情况——例如符号链接、.. 组合出的 glob 模式,以及遍历过程中通过相对关系跳出根目录。

代码地图

机制 位置 要点
工具清单 src/agents/tool-catalog.ts: CORE_TOOL_DEFINITIONS 第 69 行;一条工具一条定义
工具定义形状 src/agents/tool-catalog.ts: CoreToolDefinition 第 46 行;含 sectionId 与 profiles
分组顺序 src/agents/tool-catalog.ts: CORE_TOOL_SECTION_ORDER 第 55 行;11 个分组
Profile 闭集 src/agents/tool-catalog.ts: ToolProfileId 第 28 行;minimal/coding/messaging/full
Profile 转策略 src/agents/tool-catalog.ts: resolveCoreToolProfilePolicy 第 544 行;CORE_TOOL_PROFILES 第 500 行
运行时过滤 src/agents/openclaw-tools.registration.ts: collectPresentOpenClawTools 第 41 行;声明存在不等于运行时存在
工具检索目录 src/agents/tool-search-catalog.ts: resolveCatalog 第 338 行;visibleCatalogEntries 第 346 行
策略形状 src/agents/tool-policy.ts: ToolPolicyLike 第 28 行;allow / deny
策略求值 src/agents/tool-policy.ts: replaceWithEffectiveToolAllowlist 第 115 行;交集式替换
最终生效策略 src/agents/embedded-agent-runner/effective-tool-policy.ts: applyFinalEffectiveToolPolicy 第 44 行;运行时阶段产物
会话权限模式 packages/gateway-protocol/src/schema/sessions-row.ts: SessionPermissionModeSchema 第 8-13 行;read-only/guarded/workspace/full
权限模式转 exec src/agents/session-permission-exec-mode.ts: EXEC_MODE_BY_PERMISSION_MODE 第 4-9 行;非 full 一律 workspaceOnly
工具批执行分发 packages/agent-core/src/agent-loop.ts: executeToolCalls 第 633 行;任一 sequential 则整批串行
串行 / 并行实现 packages/agent-core/src/agent-loop.ts: executeToolCallsSequential / executeToolCallsParallel 第 764 / 945 行
批量前置钩子 packages/agent-core/src/agent-loop.ts: beforeToolBatch 第 645 行调用、第 672 行落点
单条结果上限 src/agents/embedded-agent-tool-results.ts: TOOL_RESULT_MAX_CHARS 第 22 行 8000;错误 400 在第 23 行
敏感字段清理 src/agents/embedded-agent-tool-results.ts: SENSITIVE_STRUCTURED_HEADER_FIELDS 第 27-34 行;authorization / cookie / x-api-key 等
结果清理入口 src/agents/embedded-agent-tool-results.ts: sanitizeToolResult 第 249 行
按预算截断 src/agents/embedded-agent-runner/tool-result-truncation.ts: truncateToolResultText 第 360 行;truncateToolResultMessage 第 477 行
上下文守卫 src/agents/embedded-agent-runner/tool-result-context-guard.ts: truncateToolResultToChars 第 153 行;installToolResultContextGuard 第 456 行
exec 策略闭集 src/infra/exec-approvals-core.ts: ExecSecurity 第 9 行;ExecAsk 第 10、ExecApprovalDecision 第 12 行
safe-bin 判定 src/infra/exec-approvals-allowlist.ts: validateSafeBinArgv 第 47 行引入、第 176 行调用;校验的是 argv
审批请求参数 src/agents/bash-tools.exec-approval-request.ts: RequestExecApprovalDecisionParams 第 40 行;注册在第 355 行
审批结论解析 src/agents/bash-tools.exec-approval-request.ts: resolveRegisteredExecApprovalDecision 第 175 行
路径参数围栏 src/agents/agent-tools.read.ts: wrapToolWorkspaceRootGuard 第 516 行;注释在第 515 行
遍历边界校验 src/agents/workspace.ts: isPathInside 调用点 第 1322 / 1395 行;拒绝理由第 1422 行

关键取舍

工具清单是声明式的,代价是新增工具要改两处。
加一个工具要在 src/agents/tools/ 写实现,还要在 CORE_TOOL_DEFINITIONS 补一条带 sectionId 与 profiles 的定义,漏一处就「实现存在但模型看不见」。收益是工具说明、profile 策略、合法工具名判定、文档渲染全都从同一份数据推导,不会出现「代码里有但帮助里没有」的漂移。

四层策略求交而不是一层可覆盖,代价是排查「为什么工具不可用」要逐层看。
用户写 allow: ["bash"] 却发现用不了,原因可能是 profile 不含它、会话权限是 read-only(exec 被映射成 deny)、或 ExecAsk = "always" 而审批没人答。收益是任何一层单独被误配都不会导致「意外放宽」——这在给他人开权限的场景下是硬要求。

结果截断分两级且第二级在 embedded-agent-runner,代价是阈值分散在两处。
固定上限(8000 / 400 字符)在 src/agents/embedded-agent-tool-results.ts,上下文预算在 src/agents/embedded-agent-runner/tool-result-truncation.ts。收益是第一级可以被任何调用方复用而不依赖上下文引擎,第二级只在真正跑 agent 时才生效;代价是修改截断行为时必须判断该改哪一级,改错层级会导致「单测通过但长会话仍超限」。

safe-bin 校验 argv 而不只是 bin 名,代价是白名单维护成本高。
validateSafeBinArgv(argv, profile, { binName }) 意味着每个安全二进制要配一份参数 profile(SAFE_BIN_PROFILES)。收益是堵住「白名单命令 + 危险参数」这条最常见的绕过路径;代价是白名单是「参数级」资产,任何工具版本升级都可能让既有 profile 失效。

审批做成两阶段,代价是要维护「孤儿请求」状态。
先注册(registerExecApprovalRequestForHostOrThrow)再解析(resolveRegisteredExecApprovalDecision),中间可以隔着渠道往返与人工延迟。收益是审批可以异步、可以跨设备答复;代价是必须处理 run 已取消但请求仍在的情况,这就是 isExecApprovalRunAbortedError 存在的原因。

自测题

  1. executeToolCalls 用「任一工具 executionMode === "sequential" 就整批串行」的判据。请举一个「只有一个工具需要串行、其余本可并行」的现实批,并说明整批串行丢失了什么,以及为什么作者仍选这个判据。
  2. normalizeExecSecurity 返回 | null 而不是回退到某个默认值。这个选择在「配置文件写错一个字符」时分别会带来什么后果(对比回退到 full 和回退到 deny)?
  3. 第一级截断(8000 字符)与第二级截断(上下文预算)都能让「单条结果塞得进去」。请说明在什么情况下只有第二级能救场,并指出 resolveLiveToolResultAggregateMaxChars 这类「按工具类型另算预算」的必要性。
  4. SENSITIVE_STRUCTURED_HEADER_FIELDS 是静态集合。它挡不住什么样的敏感泄漏?请结合 sanitizeToolResult 的清理粒度说明你会怎么补。
  5. 路径围栏有两层:wrapToolWorkspaceRootGuard 拦参数、workspace.ts:isPathInside 拦遍历。请各构造一个只被其中一层拦住的例子,并说明如果只保留一层会漏掉什么。

进入 keel 阅读