KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 工具体系与执行安全:四层策略只能收紧不能放宽 — keel 龙骨
OpenClaw 的工具层有一条贯穿始终的设计律:每一层策略只能让结果更严格,不能更宽松。Profile 限定可见工具集合,allow / deny 在其上做增减,会话权限模式把文件与 shell 的边界进一步压紧,exec 审批再压一层。四层叠加的结果永远是各层「最严」的子集。
OpenClaw 的工具层有一条贯穿始终的设计律:每一层策略只能让结果更严格,不能更宽松。Profile 限定可见工具集合,allow / deny 在其上做增减,会话权限模式把文件与 shell 的边界进一步压紧,exec 审批再压一层。四层叠加的结果永远是各层「最严」的子集。
这一章沿着一次工具调用要走完的路径展开:从目录声明 → 策略求解 → 参数校验 → 并发/截断 → exec 审批 → 路径围栏。要点:
- 为什么工具是「一工具一文件 + 一份声明式 catalog」,而不是运行时自注册;
- Profile 与
ToolPolicyLike如何逐层收紧; - 工具结果的截断为什么是两级(固定上限 + 上下文预算);
ExecSecurity/ExecAsk/safe-bin/ 两阶段审批四者如何组合;- 路径围栏在
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。
声明式清单换来了三件别处很难做的事:
listCoreToolSections()(第 562 行)能直接按分组渲染工具说明,isKnownCoreToolId()(第 589 行)能在不做任何加载的情况下判断工具名是否合法;resolveCoreToolProfiles(toolId)(第 580 行)能反查某工具属于哪些 profile;CORE_TOOL_PROFILES(第 500 行)与resolveCoreToolProfilePolicy()(第 544 行)能把 profile 直接翻译成 allow/deny 策略。
运行时把「清单」变成「实际注册」的过滤器是 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/ 根目录):
tool-result-truncation.ts:truncateToolResultText()(第 360 行)、truncateToolResultMessage()(第 477 行)、resolveLiveToolResultAggregateMaxChars()(第 437 行)给实时聚合结果另算预算、truncateOversizedToolResultsInMessages()(第 633 行)做整段历史清理,还有pruneExpiredCacheTtlToolResults()(第 170 行)按缓存 TTL 过期清理。tool-result-context-guard.ts:truncateToolResultToChars()(第 153 行)按字符预算裁、enforceToolResultLimit()(第 263 行)执行上限,installToolResultContextGuard()(第 456 行)与installContextEngineLoopHook()(第 311 行)把它们挂进循环钩子。
两级的意义不同:第一级保证单条结果不会撑爆线格式;第二级保证多轮累积后的历史仍能塞进上下文预算,且允许不同工具类型(如实时 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 存在的原因。
自测题
executeToolCalls用「任一工具executionMode === "sequential"就整批串行」的判据。请举一个「只有一个工具需要串行、其余本可并行」的现实批,并说明整批串行丢失了什么,以及为什么作者仍选这个判据。normalizeExecSecurity返回| null而不是回退到某个默认值。这个选择在「配置文件写错一个字符」时分别会带来什么后果(对比回退到full和回退到deny)?- 第一级截断(8000 字符)与第二级截断(上下文预算)都能让「单条结果塞得进去」。请说明在什么情况下只有第二级能救场,并指出
resolveLiveToolResultAggregateMaxChars这类「按工具类型另算预算」的必要性。 SENSITIVE_STRUCTURED_HEADER_FIELDS是静态集合。它挡不住什么样的敏感泄漏?请结合sanitizeToolResult的清理粒度说明你会怎么补。- 路径围栏有两层:
wrapToolWorkspaceRootGuard拦参数、workspace.ts:isPathInside拦遍历。请各构造一个只被其中一层拦住的例子,并说明如果只保留一层会漏掉什么。