跳到主要内容

数据截至 (上游 commit 8d6cbee1b527)

工具、技能与沙箱:让 agent 动手且不越界

30 秒导读: 前面几章讲的是"消息怎么进来、模型怎么转"。这一章讲 agent 的手脚——它能调哪些工具,以及围在手脚外面的栏杆——谁批准、能碰哪些文件、跑在宿主机还是容器里。核心结论一句话:OpenClaw 的工具表是每次运行现搭出来的,装配、过滤、规范化、包壳四步之后才交给模型。

本章位置:01 网关 讲进程形态,02 插件化内核 讲工具怎么被插件注册,03 入站与会话 讲会话从哪来,04 回复流水线 讲结果怎么投递,05 智能体运行时 讲工具结果怎么回流进上下文。本章只讲工具本身和它的围栏


1. 这一章讲什么(零基础也能懂)

一句话定义: 工具(tool)是模型能调用的函数。模型说"我要调 exec,参数是 ls -la",运行时真的去执行,再把结果喂回去。

OpenClaw 的特别之处在于工具的"射程"。 普通编码 agent 的工具止步于本地文件和 shell;OpenClaw 的 agent 还能往聊天频道发消息、读自己的配置、调你手机的摄像头、给自己排定时任务。

射程一大,"能不能干"就必须和"会不会干"分开管。所以本章有两条主线:

主线是什么代表模块
手脚工具族、schema 适配、技能、MCPsrc/agents/tools/src/skills/
栏杆按会话生效的 allow/deny、执行前审批钩子、沙箱src/agents/tool-policy*.tssrc/agents/sandbox/

用起来什么样。 运维侧的手感基本就是一份配置——选一个工具档位、划一条沙箱线:

// 示意,非源码;对应 src/agents/tool-catalog.ts 与 src/agents/sandbox/config.ts
{
"tools": { "profile": "coding" }, // 只放行编码族工具
"agents": {
"defaults": {
"sandbox": {
"mode": "non-main", // main 会话留在宿主机,其余进容器
"workspaceAccess": "ro", // 容器里只读挂载工作区
"tools": { "deny": ["gateway", "nodes"] } // 沙箱内额外禁用两个高权工具
}
}
}
}

一句话心智模型: 别把工具表想成"启动时注册好的全局清单"。它更像每次开会前现发的工牌——这次会议(这个会话、这个模型、这个发送者、这次触发)决定了你手上有哪几张卡。


2. 顶层全景:一次运行怎么拿到自己的工具表

怎么读这张图: 从上到下是一次 agent 运行开始时的装配顺序;从左到右是一次具体工具调用的执行顺序。装配只做一遍,调用可以做很多遍。

[装配阶段] createOpenClawCodingTools() src/agents/agent-tools.ts:1018

├─ ① 按族造工具 base 编码族 / shell 族 / 频道族 / OpenClaw 族 / 插件族 / ToolSearch
│ └─ 有沙箱时:read/write/edit 换成走 fs-bridge 的沙箱版
├─ ② 过策略流水线 profile → 全局 → agent → 群组 → 发送者 → 沙箱 → 子代理 → 继承
├─ ③ 规范化 schema 按模型厂商修 JSON Schema 方言
├─ ④ 套钩子壳 before_tool_call + 诊断事件 + abort 信号
└─→ 得到 AnyAgentTool[]

[调用阶段] 模型说要调 X


┌──────────┐ ┌───────────┐ ┌────────────┐ ┌────────────┐
│ 参数归一 │→ │ 循环检测 │→ │ 审批/否决 │→ │ 真正执行 │
│(容错解析)│ │(防死循环) │ │(插件策略) │ │宿主机/容器 │
└──────────┘ └───────────┘ └────────────┘ └────────────┘


after_tool_call(只观测)

各部件一句话职责:

部件干什么在哪个文件
createOpenClawCodingTools装配一次运行的完整工具表src/agents/agent-tools.ts:1022
applyToolPolicyPipeline逐层跑 allow/deny,并审计每层删掉了谁src/agents/tool-policy-pipeline.ts:136
normalizeToolParameters把工具参数 schema 改成当前模型厂商吃得下的形状src/agents/agent-tools.schema.ts:65
wrapToolWithBeforeToolCallHook给每个工具套上审批 + 观测的壳src/agents/agent-tools.before-tool-call.wrapper.ts:287
toToolDefinitions把运行时工具对象翻译成会话层的 ToolDefinitionsrc/agents/agent-tool-definition-adapter.ts:397
resolveSandboxContext准备容器/远端句柄、工作区挂载、文件桥src/agents/sandbox/context.ts:348
SandboxFsPathGuard证明每次文件操作都没跑出挂载点src/agents/sandbox/fs-bridge-path-safety.ts:73
resolveReusableWorkspaceSkillSnapshot把技能目录收成一份可复用的 prompt 快照src/skills/runtime/session-snapshot.ts:52

3. 工具族全景:agent 手上到底有什么

装配阶段按"族"分批造工具(toolConstructionPlan 选项控制哪几族参与,src/agents/agent-tools.ts:347:531-538)。

代表工具一句话作用源码锚点
编码基础read write edit读写改文件src/agents/sessions/tools/index.ts:203 createCodingTools
Shellexec process apply_patch跑命令、管后台进程、打补丁src/agents/lazy-exec-tool.ts:26 createLazyExecTool
消息message往任意通道发/编辑/表态/读消息src/agents/tools/message-tool.ts
网关(只读)gateway读自身配置与 schemasrc/agents/tools/gateway-tool.ts:108
节点nodes调手机/电脑节点的相机、通知、位置、截屏src/agents/tools/nodes-tool.ts:169 createNodesTool
定时cron自建/改/删定时任务,或叫醒自己src/agents/tools/cron-tool.ts:221 createCronTool
会话编排sessions_spawn sessions_send sessions_yield subagents拉起并操控子会话src/agents/tools/sessions-*.ts
媒体生成image video_generate music_generate tts生成图/视频/音乐/语音src/agents/openclaw-tools.ts:144
外部能力<server>__<tool>每个 agent 自己的 MCP 服务器工具src/agents/agent-bundle-mcp-materialize.ts:429

三个值得单独说的:

message 是"射程最远"的那个。 它的动作表是一份刻意封闭、核心持有的词表(CHANNEL_MESSAGE_ACTION_NAMES,src/channels/plugins/message-action-names.ts:4;文件头注释 :1-3 明写:插件要加动作名只能给核心提 PR,运行期注册被有意封死),从 send/reactrenameGroup/removeParticipant/thread-create,具体哪些能用取决于当前通道插件声明的能力。

gateway 已经变成只读工具。 现在它只认两个动作——config.getconfig.schema.lookup,描述里直说"Writes/restart: use openclaw tool"(gateway-tool.ts:108-113)。写配置、重启这类"改自己"的动作整体挪进了 system-agent 工具(src/agents/tools/system-agent-tool.ts,文件头 :1-6 写明"ring-zero、永不对普通 agent 暴露"),每个动作走类型化操作联合 + 审批断言 + 审计日志,而且模型传来的 approved 参数永远不能单独授权——必须由宿主判定用户这条消息是真批准(approvalArmed,:24-28)。模型不是可信主体这条前提,从"白名单"升级成了"根本不给你写"。

nodesinvoke 是"通用逃生口",所以被特意堵住。 system.run 系列直接拒绝(BLOCKED_INVOKE_COMMANDS,src/agents/tools/nodes-tool-commands.ts:22,判定 :181),让你走 exec host=node;会返回媒体字节的命令必须走各自的专用动作,否则 base64 会撑爆上下文(:186-200);带路径白名单的文件传输命令永远改道到专用工具(POLICY_REDIRECT_INVOKE_COMMANDS,:19 引入)——因为只有专用工具会跑路径策略和操作员审批。

agent_step 不是模型可见的工具,而是工具的内部零件。 sessions_send 做 agent-to-agent 对话时,用它把一条带来源标注的消息推进目标会话、等对方跑完、把回复读回来(runAgentStep,src/agents/tools/agent-step.ts:60;调用方 src/agents/tools/sessions-send-tool.a2a.ts:148:228)。


4. 工具定义怎么变成模型看得见的 schema

要解决的小问题: 同一个工具,给 Anthropic、OpenAI、Gemini 看的 JSON Schema 不能长一样;而且模型回传的参数经常不是干净的对象。

4.1 装配尾声的 schema 规范化

工具表过完策略后,统一跑一遍 normalizeToolParameters(src/agents/agent-tools.schema.ts:65),它委托给 @openclaw/ai/internal/openainormalizeToolParameterSchema 做厂商兼容清理(:1-3),并顺手做一件小事:根层是 object 且没有任何必填参数的 schema,会被补一个 prepareArguments,把 null/undefined 实参变成 {}(addEmptyObjectArgumentPreparation,:48-61)——这类模型口误不该变成运行时异常。

4.2 适配器:执行一次工具的完整包裹

toToolDefinitions(src/agents/agent-tool-definition-adapter.ts:397)把运行时工具翻译成会话层定义,顺手接管四件脏活:

  1. 参数容错。 Gemini 会把工具参数按字符串增量流式吐出,到 execute 时可能还是 JSON 字符串;coerceParamsRecord(:567)先试 JSON.parse,失败才退成空对象。
  2. 执行签名兼容。 splitToolExecuteArgs(:310)同时认新旧两种 execute 参数顺序。
  3. 结果规格化。 工具没返回标准 content[] 时,normalizeToolExecutionResult(:237)把它包成文本结果而不是让运行时炸掉。
  4. 失败日志脱敏。 这是最值得抄的一条,见 §11。

5. 钩子:执行前的闸门,执行后的观测

5.1 before_tool_call 做三件事,顺序固定

runBeforeToolCallHook(src/agents/agent-tools.before-tool-call.policy.ts:90)是所有工具调用的必经之路(实现已按职责拆成 agent-tools.before-tool-call.* 一族,对外门面是 agent-tools.before-tool-call.ts):

参数进来

├─ ① 循环检测 detectToolCallLoop(src/agents/tool-loop-detection.ts:510)
│ ├─ critical → 直接否决(deniedReason="tool-loop")
│ └─ warning → 打日志,放行(带去重,防刷屏)

├─ ② 可信工具策略 runTrustedToolPolicies(src/plugins/trusted-tool-policy.ts:212)
│ └─ 可 block / 改参 / 要求审批

├─ ③ 插件 hook hookRunner.runBeforeToolCall
│ └─ 同样可 block / 改参 / 要求审批

└─→ 出口只有四种:放行 / 放行但参数被改 / 需审批 / 被否决

没有钩子时它会尽早短路:核心策略、可信策略、插件 hook 都没有,就原样放行——热路径不为空配置买单(短路逻辑见 agent-tools.before-tool-call.policy.ts:90 起的编排)。

否决分两种,后果不同。 veto 会变成一条结构化的"被拦截"结果喂回模型,让它换个做法;failure 直接抛错(结果类型见 agent-tools.before-tool-call.types.ts:111-113)。而钩子自己崩了算否决——这是典型的 fail-closed:审批系统故障时宁可不执行。

多插件的合并规则也是保守的(src/plugins/hooks.ts:1335-1373 runBeforeToolCall):任何一个插件 block=true 就粘住不再翻转(stickyTrue);已有插件要求审批后,别的插件不许再改参数——注释写明原因:"Approval covers one detached snapshot. Later hooks may still block, but they cannot change what the operator reviewed"。另外每个插件拿到的是隔离的事件副本(isolateEventPerHandler),A 的本地改写不会污染 B 看到的事件。

5.2 包装器:同一层壳里塞进了观测

wrapToolWithBeforeToolCallHook(src/agents/agent-tools.before-tool-call.wrapper.ts:287)包出来的 execute 除了跑上面的闸门,还负责:

时机发出的东西行号
被拦截tool.execution.blocked + 安全事件:366:389
开始执行tool.execution.started:523
失败tool.execution.error(带错误分类,不带输出):346:608
任意结局recordLoopOutcome 回填循环检测状态:409:550:619

5.3 after_tool_call 只看不改

runAfterToolCall 走 fire-and-forget 的 void 钩子——并行、没有返回值。触发点在工具流结束之后,带上耗时和脱敏后的结果(src/agents/embedded-agent-subscribe.handlers.tools.completion.ts:682;harness 侧另有 src/agents/harness/hook-helpers.ts:40)。

设计取向很清楚:执行前那道闸可以改结果,执行后那道只能看。 后置钩子失败不该影响一次已经完成的工具调用。


6. 权限层:一条会话到底能用哪些工具

6.1 层数比你想的多

buildDefaultToolPolicyPipelineSteps(src/agents/tool-policy-pipeline.ts:58)加追加步骤串起这些层,每层都只能收窄,不能放宽:

顺序配置位置
1工具档位 profiletools.profile
2按模型厂商的 profiletools.byProvider.profile
3全局 allow/denytools.allow / tools.deny
4全局按厂商tools.byProvider.allow
5agent 级agents.list[].tools.allow
6agent 按厂商agents.list[].tools.byProvider.allow
7群组级群/频道/空间维度策略
8发送者级按 senderId/用户名等
9沙箱级tools.sandbox.tools
10子代理级子会话能力信封
11继承级父会话实际生效的工具表

档位不是魔法字符串,而是从工具目录派生的:CORE_TOOL_DEFINITIONS 每条声明自己属于哪几个 profile(src/agents/tool-catalog.ts:70),CORE_TOOL_PROFILES(:500)按这个反查生成 allow 列表,full 就是 ["*"]

6.2 匹配语义:三条规则记住就够

makeToolPolicyMatcher(src/agents/tool-policy-match.ts:10)的行为:

  • deny 永远优先,命中即拒。
  • allow 为空 = 放行一切未被 deny 的(不是"什么都不许")。
  • 支持 glob;group:fs 这类分组会先展开(CORE_TOOL_GROUPS,src/agents/tool-catalog.ts:549;TOOL_GROUPS,src/agents/tool-policy-shared.ts:32)。

6.3 收窄之后要"传下去"

子会话不该比父会话权限更大。createOpenClawCodingTools 在流水线跑完后,把实际存活的工具名回填进一个按引用传给 sessions_spawn 的数组(replaceWithEffectiveToolAllowlist 调用点:src/agents/agent-tools.ts:963)。

cron 更麻烦:定时任务将来在全新会话里跑,原会话的策略那时已经不存在。所以装配时会把有效工具名冻进 cron 创建者上限(cronCreatorToolAllowlistRef,agent-tools.ts:308:675),建任务时由 capCronJobToolsAllowOnCreate 用它封顶(src/agents/tools/cron-tool.ts:400 调用;实现在 cron-tool-creator-cap.ts);涉及配置型 MCP 的变更还要过创建者权限校验(cron-tool.ts:381-389)。agent 建的定时任务,权限不可能超过建它的那次运行。

6.4 两个按"触发来源"临时缩权的例子

触发缩到什么程度源码
记忆压缩运行(trigger: "memory")写入被限制为只能追加指定文件(memoryFlushWritePath),工作区其余部分只读src/agents/agent-tools.ts:383-387:519:544
cron 触发的运行cron 工具只能查自己、删自己那一个 job(assertCronSelfRemoveScope)src/agents/tools/cron-tool.ts:93:232

7. 沙箱:把手脚关进笼子

7.1 那条默认安全线

shouldSandboxSession(src/agents/sandbox/runtime-status.ts:23)只有几行,却是整套隔离策略的支点:

// 真实源码 src/agents/sandbox/runtime-status.ts:23
function shouldSandboxSession(cfg: SandboxConfig, sessionKey: string, mainSessionKey: string) {
if (cfg.mode === "off") return false;
if (cfg.mode === "all") return true;
return sessionKey.trim() !== mainSessionKey.trim();
}

三档的含义与陷阱:

mode行为注意
off全在宿主机跑代码里的默认值(src/agents/sandbox/config.ts:256)
non-mainmain 会话在宿主机,其余进沙箱比的是 session.mainKey,不是 agent id
all全进沙箱

non-main 的陷阱值得记住:群聊/频道会话都有自己的 key,所以它们一律算 non-main、一律被沙箱。比较前会先把 main 会话的各种别名归一(resolveComparableSessionKeyForSandbox,src/agents/sandbox/runtime-status.ts:46)。

沙箱内还有独立的一份工具策略默认值(src/agents/sandbox/constants.ts:25 DEFAULT_TOOL_ALLOW:44 DEFAULT_TOOL_DENY):默认放行 exec/文件/会话族,默认禁掉 browser canvas nodes cron gateway 以及全部通道 id——即"容器里的 agent 不许直接对外发消息、不许读宿主配置"。

这份策略的合并有两个反直觉之处(src/agents/sandbox/tool-policy.ts):allow: [] 表示放行全部(保留历史语义);而只要你显式 allow 了某个工具,它就会从默认 deny 里被摘掉(:119-121)。分类判定在 classifyToolAgainstSandboxToolPolicy(:204)。

7.2 后端是可注册的

沙箱后端不是写死的 Docker,而是一张进程级注册表(registerSandboxBackend,src/agents/sandbox/backend.ts:67;requireSandboxBackendFactory,:102)。

后端由谁注册运行形态
docker核心自注册,backend.ts:115-116本机容器,默认
ssh核心自注册,backend.ts:128远端主机上的工作区副本
openshell插件注册,extensions/openshell/插件自带的远端运行时

取不到后端时的报错专门写成了给运维看的形状——告诉你"要么装那个插件,要么把 backend 设回 docker"(backend.ts:102 起的 requireSandboxBackendFactory)。

后端句柄只需实现几个方法:buildExecSpec(怎么拼一条执行命令)、runShellCommand(怎么跑一段脚本)、可选的 createFsBridge。Docker 后端见 src/agents/sandbox/docker-backend.ts:77 createDockerSandboxBackend,SSH 后端则要多做"把工作区上传到远端"这一步(src/agents/sandbox/ssh-backend.ts:155)。

7.3 挂载:写在哪、读在哪

appendWorkspaceMountArgs(src/agents/sandbox/workspace-mounts.ts:184)按 workspaceAccess 决定挂载的读写位:

workspaceAccess项目目录agent 工作区技能目录
rw可写挂 /workspace可写挂 /agent额外只读挂进去
ro只读只读 /agent随沙箱工作区拷贝
none只读的沙箱副本不挂随副本

rw 那一行的设计意图:项目要可写,但技能源文件必须只读——agent 应该能看见自己的指令,不该能改自己的指令(resolveReadOnlyWorkspaceSkillMounts,workspace-mounts.ts:69;调用点 :221)。

7.4 fs-bridge:文件操作怎么证明自己没越界

沙箱里的 read/write/edit 不直接碰宿主路径,而是经过文件桥(createSandboxFsBridge,src/agents/sandbox/fs-bridge.ts:44)。每次操作至少过四道:

容器路径 /workspace/a/../../etc/passwd

① 词法定位挂载点 最长前缀优先;不在任何挂载里 → 直接拒

② 打开时钉住根目录 openRootFile(root=挂载宿主根)

③ 在容器里跑 readlink -f 求真实路径,再重查一次挂载
│ └─ 符号链接指到挂载外 → 拒 (resolveCanonicalContainerPath,fs-bridge-path-safety.ts:274)

④ 只读挂载 + 要求可写 → 拒

第 ③ 步是防符号链接逃逸的关键,它同时处理"目标还不存在"的创建场景——先向上找到最深的已存在祖先做 readlink -f,再把缺失的后缀拼回去(fs-bridge-path-safety.ts:212 的父路径解析)。

还有一层专门对付 TOCTOU(检查完到执行之间目录被换掉):会创建或替换父目录的变更类操作,在发命令前把检查再跑一遍(fs-bridge.ts:364-366,recheckBeforeCommand)。

7.5 配置变了,容器要不要重建

容器复用靠一个 hash。computeSandboxConfigHash(src/agents/sandbox/config-hash.ts:73)把 docker 配置、workspaceAccess、两个工作区路径、挂载格式版本、只读技能挂载清单拍成一个 sha256,写进容器 label(调用点 src/agents/sandbox/docker.ts:618)。

hash 的输入先做归一化:递归排序对象键、丢掉 undefined——这样"语义相同但写法不同"的配置不会白白触发重建。

比对逻辑有个体贴的例外(ensureSandboxContainer,src/agents/sandbox/docker.ts:555):hash 对不上时,如果容器正在运行且最近刚被用过,不强删,只打一行日志告诉你"配置变了,想生效请手动重建";否则才 docker rm -f 重来。不为了配置洁癖打断用户手上正在跑的活。

7.6 创建参数的安全校验

建容器前先跑 validateSandboxSecurity(调用点 src/agents/sandbox/docker.ts:334,实现 src/agents/sandbox/validate-sandbox-security.ts:420)。它的威胁模型写在文件头:配置本身是本地可信的,防的是手滑和配置注入

四类拦截:

类别拦什么锚点
bind 源路径/etc /proc /dev 及 docker.sock;~/.ssh ~/.aws 等家目录敏感子目录;挂 /getBlockedBindReason,validate-sandbox-security.ts:119
bind 目标路径覆盖 /workspace/agent 这类保留挂载点同文件
网络模式hostcontainer:*(后者要显式 dangerous 开关)同文件
安全 profileseccomp=unconfinedapparmor=unconfined同文件

两个细节:父目录也算数——挂一个包含 ~/.ssh 的上级目录同样被拒;而且同一条 bind 会校验两遍,第二遍用 realpath 穿过符号链接,防止用软链绕过字符串检查。


8. 技能:让 agent 按需读说明书

8.1 SKILL.md 的 frontmatter 契约

一个技能就是一个目录 + 一个 SKILL.md。frontmatter 里 namedescription 是硬门槛——缺一个这个目录就不算技能(parseSkillFrontmatter,src/skills/loading/frontmatter.ts:25)。

另外两组字段:

字段作用解析处
user-invocable / disable-model-invocation人能不能用斜杠命令调、模型能不能自己调resolveSkillInvocationPolicy,frontmatter.ts:215
openclaw: 块(always/requires/install/os)依赖声明与安装方式resolveOpenClawManifestRequires,frontmatter.ts:200

install 的解析是把校验当成过滤器而不是报错器:brew formula、npm spec、go module、uv 包、下载 URL 各有正则和前缀检查,不合规就整条丢掉。因为这些字符串最终会进命令行,- 开头的值可能被当成参数注入。

8.2 来源与优先级

技能从六处来源加载,同名者按固定顺序覆盖(loadSkillEntries 所在模块 src/skills/loading/workspace-skill-loader.ts:337;合并循环在 :399-427):

extra < bundled < managed < agents-personal < agents-project < workspace
(配置/插件的额外目录) (随程序自带) (~/.openclaw/skills) (~/.agents/skills) (项目 .agents/skills) (工作区 skills/)

越靠近具体项目的,优先级越高。 覆盖用的是 Map.set 依次刷(mergeRecord,:402-409),同名冲突会打一条 warnSkillPrecedenceCollision。来源标签分别是 openclaw-extra / openclaw-bundled / openclaw-managed / agents-skills-personal / agents-skills-project / openclaw-workspace(:366-394)。

路径遏制策略随来源而变:自己组织的目录(managed、个人)允许符号链接指到外面;workspace、extra、项目级则必须留在配置的根内——bundled 目录里出现软链逃逸甚至有专属 reason(bundled-symlink-escape,src/skills/loading/skill-root-discovery.ts:241-243),因为那基本意味着本地 checkout 被改过。

8.3 按需注入:prompt 里只放目录,不放正文

formatSkillsForPrompt(src/skills/loading/session.ts:278)渲染出的是一张索引:每条只有 namedescriptionlocationversion。正文要模型自己用 read 工具去读。

版本号的用法很聪明:提示词里明写"如果某个技能的 <version> 和上一轮不同,用前请重读它的 SKILL.md"。这样技能改了不必重建整个上下文。

预算不够时还有降级:先尝试完整格式,超了就退到紧凑格式(formatSkillsCompactForPrompt,src/skills/loading/skill-prompt-limits.ts:4 引入),再不行才开始丢。

8.4 刷新:文件变了怎么知道

ensureSkillsWatcher(src/skills/runtime/refresh.ts:678)按目录建 chokidar 监听,多个 agent 工作区共享同一个技能根时共用一个 watcher,句柄数按目录数增长而不是按 agent 数增长。

变化通过一个版本号广播;下次组装快照时 shouldRefreshSnapshotForVersion 比对版本、prompt 格式版本、技能过滤器三者,任一变了才重建(src/skills/runtime/refresh-state.ts:77)。不比对文件内容,只比对版本——热路径不做 stat/重读。

8.5 workspace 技能的安全扫描

工作区技能是"用户/agent 自己写的代码",所以要过扫描器(src/skills/security/scanner.ts)。规则分两类:

  • 文本规则(scanSkillContent,:699):找提示词层面的坏味道,比如"绕过工具审批"的措辞、curl | sh、把 process.env 往外发、rm -rf /chmod 777
  • 源码规则(scanSource,:558):扫 .js/.ts 等可执行文件;为了减少误报,先剥掉注释再跑启发式(stripCommentsForHeuristics,:451),还会判断 .exec( 到底是正则的 exec 还是子进程的 exec(isBenignMemberExecMatch,:396)。

真正的闸门在技能工坊:提案连同附带文件一起扫,只要有一条 critical 就整包判失败(src/skills/workshop/proposal-scan.ts:28-34)。

8.6 技能命令派发也走同一套策略

技能可以声明 command-dispatch: tool,让一条斜杠命令直接落到某个工具上。这条路径没有另起炉灶,而是复刻了完整的策略栈:profile、全局、agent、群组、发送者、沙箱、子代理、继承一层不少(resolveSkillDispatchTools,src/skills/runtime/tool-dispatch.ts:58;内部直接复用 buildDefaultToolPolicyPipelineStepsapplyToolPolicyPipeline,:196-201)。

文件头注释点名了原因——这是 GHSA-mhm4-93fw-4qr2 的修复面,必须和普通工具面保持一致。任何"绕过主路径的便捷入口",都是权限模型最容易破的地方。


9. 外部能力:每个 agent 自己的 MCP

配置的 MCP 服务器不是全局工具,而是按会话物化成一份工具表(materializeBundleMcpToolsForRun,src/agents/agent-bundle-mcp-materialize.ts:429)。

命名是这里最实际的一块工程(buildSafeToolName,src/agents/agent-bundle-mcp-names.ts:67):

约束处理
只能有 A-Za-z0-9_-其余字符换成 -
必须字母开头否则前面补 fallback 前缀
服务器名 ≤ 30 字符,总长 ≤ 64先截工具名,保住服务器名这个命名空间
不能和已有工具重名-2-3 后缀,且后缀也要挤进长度预算

分隔符是 __(TOOL_NAME_SEPARATOR,agent-bundle-mcp-names.ts:10),所以模型看到的是 github__create_issue 这种形状。发生改名时会打一条 warn 说明(agent-bundle-mcp-materialize.ts:292)。

所有 MCP 工具的 pluginId 统一是 bundle-mcp(agent-bundle-mcp-materialize.ts:85),于是它们能被当成一个插件组,在策略里整体 allow/deny。

目录投影是确定性排序的(:246),这对 prompt 缓存命中率很重要。

服务器若声明 supportsParallelToolCalls,对应工具的 executionMode 才是 parallel,否则串行(:267-268)。


10. 时间触发:没人说话时也能跑起来

10.1 cron 才是那个"闹钟"

CronService 维护一个 setTimeout 链(armTimer,src/cron/service/timer-scheduler.ts:50;到点处理 onTimer,:123)。三个防御性细节都留了注释:

  • 最小重触发间隔:下次时间已过期(delay=0)时强制一个下限(MIN_REFIRE_GAP_MS),防止 job 卡在 running 标记上时把事件循环打成热循环(timer-scheduler.ts:83-92 含注释)。
  • 最大延迟封顶:至少每分钟醒一次(MAX_CRON_TIMER_DELAY_MS),防止时钟跳变或进程被挂起后错过调度(:93)。
  • 定时器回调不写成 async:否则 Vitest 的假定时器会去 await 它,阻塞模拟长任务的测试(:94-95 的注释)。

到点后走隔离 agent 运行,在一个新会话里发起一次完整 agent 回合,trigger: "cron"jobId 一路带下去(src/cron/isolated-agent/run-executor.ts:591-592:656),于是 §6.4 的那两条缩权规则自动生效。

10.2 tasks 是台账,不是闹钟

src/tasks/ 管的是已经在跑的分离式任务——子代理、ACP 运行这类脱离当前回合的执行。它负责登记、状态流转、进度回填、取消与保留期(createTaskRecord 使用点 src/tasks/task-executor.ts:99;finalizeTaskRunByRunId,src/tasks/detached-task-runtime.ts:97)。

说白了:cron 负责"什么时候开始",tasks 负责"开始之后怎么被追踪"。 两者都能让一次 agent 运行在没有人类消息的情况下发生,但职责不同。


11. 巧妙之处(可以直接借鉴)

① 失败日志按工具名脱敏。 exec 失败时,command 不进日志,只留类型、长度和哈希前缀;env 的键保留(排序后)、值一律换成占位符(sanitizeExecFailureParamsForLog,src/agents/agent-tool-definition-adapter.ts:185,分流点 :218)。既能对比"是不是同一条命令又失败了",又不会把凭据写进日志。

② "unknown tool" 报错会被翻译成人话。 工具被策略过滤掉后,模型调它会收到"未知工具"。运行时捕获这个报错,反查沙箱策略,重写成一条带具体配置键和修复建议的说明(src/agents/embedded-agent-helpers/error-text.ts:82,格式化在 formatSandboxToolPolicyBlockedMessage,src/agents/sandbox/runtime-status.ts:141)。

③ exec 工具懒加载。 createLazyExecTool 先返回一个只有描述和 schema 的壳,真正 import("./bash-tools.js") 推迟到第一次调用(src/agents/lazy-exec-tool.ts:26)。装配一次工具表要造几十个工具,能不加载的模块就别加载。

④ 沙箱容器用工作区目录的属主跑。 没显式配 user 时,stat 一下工作区目录,用它的 uid:gid 启动容器(resolveSandboxDockerUser,src/agents/sandbox/docker-user.ts:4;调用点 context.ts:245)。这样容器里写出来的文件在宿主上属主正常,不会留一地 root 文件。

⑤ 装配阶段处处埋了计时点。 recordToolPrepStage("tool-policy"/"workspace-policy"/"openclaw-tools"/…) 把装配拆成十来段(src/agents/agent-tools.ts:495:566:861 等),回归时能直接定位是哪一段变慢了。


12. 边界与不覆盖

本章不讲的: 插件如何注册工具见 02 插件化内核;工具结果如何回流进上下文、如何被压缩见 05 智能体运行时

代码里能看到的边界:

  • 沙箱默认关。 mode 默认 "off"(src/agents/sandbox/config.ts:256),隔离需要显式开启。
  • non-main 按 session key 判,不按 agent 判。 想让某个 agent 永不进沙箱,得设 agents.list[].sandbox.mode: "off"
  • 沙箱威胁模型是"防手滑",不是"防恶意运维"。 validate-sandbox-security.ts 文件头明写配置本身可信;而且提供了 dangerouslyAllow* 系列逃生开关。
  • 不是所有后端都支持全部能力。 浏览器沙箱要求后端声明 capabilities.browser,否则直接抛错(src/agents/sandbox/context.ts:303)。
  • 技能扫描是启发式的正则规则,给的是"值得看一眼"的信号,不是完备的静态分析。
  • 模型不是可信主体。 gateway 工具只读化、system-agent 的审批断言、nodes.invoke 的黑名单都建立在这个前提上;放宽它们等于扩大模型的权限面。

13. 代码地图(导航索引)

主题文件路径关键符号
装配一次运行的工具表src/agents/agent-tools.tscreateOpenClawCodingTools
exec 懒加载src/agents/lazy-exec-tool.tscreateLazyExecTool
沙箱版读写工具src/agents/agent-tools.read.tscreateSandboxedReadTool
工具→会话定义适配src/agents/agent-tool-definition-adapter.tstoToolDefinitionscoerceParamsRecordsanitizeExecFailureParamsForLog
执行前闸门src/agents/agent-tools.before-tool-call.policy.tsagent-tools.before-tool-call.ts(门面)runBeforeToolCallHook
执行包装与观测src/agents/agent-tools.before-tool-call.wrapper.tswrapToolWithBeforeToolCallHook
循环检测src/agents/tool-loop-detection.tsdetectToolCallLoop
插件钩子合并规则src/plugins/hooks.tsrunBeforeToolCall
策略流水线src/agents/tool-policy-pipeline.tsbuildDefaultToolPolicyPipelineStepsapplyToolPolicyPipeline
策略匹配语义src/agents/tool-policy-match.tsmakeToolPolicyMatcherisToolAllowedByPolicies
工具目录与档位src/agents/tool-catalog.tsCORE_TOOL_DEFINITIONSCORE_TOOL_PROFILESCORE_TOOL_GROUPS
沙箱是否生效src/agents/sandbox/runtime-status.tsshouldSandboxSessionformatSandboxToolPolicyBlockedMessage
沙箱工具策略src/agents/sandbox/tool-policy.tsresolveSandboxToolPolicyForAgentclassifyToolAgainstSandboxToolPolicy
沙箱默认值src/agents/sandbox/constants.tsDEFAULT_TOOL_ALLOWDEFAULT_TOOL_DENY
后端注册表src/agents/sandbox/backend.tsregisterSandboxBackendrequireSandboxBackendFactory
Docker / SSH 后端src/agents/sandbox/docker-backend.tsssh-backend.tscreateDockerSandboxBackendcreateSshSandboxBackend
沙箱上下文准备src/agents/sandbox/context.tsresolveSandboxContext
容器属主src/agents/sandbox/docker-user.tsresolveSandboxDockerUser
挂载参数src/agents/sandbox/workspace-mounts.tsappendWorkspaceMountArgsresolveReadOnlyWorkspaceSkillMounts
文件桥src/agents/sandbox/fs-bridge.tscreateSandboxFsBridge
路径逃逸防护src/agents/sandbox/fs-bridge-path-safety.tsSandboxFsPathGuardresolveCanonicalContainerPath
容器复用判定src/agents/sandbox/config-hash.tsdocker.tscomputeSandboxConfigHashensureSandboxContainer
创建参数安全校验src/agents/sandbox/validate-sandbox-security.tsvalidateSandboxSecuritygetBlockedBindReason
技能 frontmattersrc/skills/loading/frontmatter.tsparseSkillFrontmatterresolveSkillInvocationPolicy
技能加载与优先级src/skills/loading/workspace-skill-loader.tsloadSkillEntriesloadMergedWorkspaceSkills
技能路径遏制src/skills/loading/skill-root-discovery.tssymlink-targets.tsbundled-symlink-escape
技能 prompt 渲染src/skills/loading/session.tsskill-prompt-limits.tsformatSkillsForPrompt
技能刷新src/skills/runtime/refresh.tsrefresh-state.tssession-snapshot.tsensureSkillsWatchershouldRefreshSnapshotForVersionresolveReusableWorkspaceSkillSnapshot
技能安全扫描src/skills/security/scanner.tsscanSkillContentscanSource
技能工坊闸门src/skills/workshop/proposal-scan.tscritical → failed
技能命令派发策略src/skills/runtime/tool-dispatch.tsresolveSkillDispatchTools
MCP 工具物化src/agents/agent-bundle-mcp-materialize.tsagent-bundle-mcp-names.tsmaterializeBundleMcpToolsForRunbuildSafeToolName
定时调度src/cron/service/timer-scheduler.tssrc/cron/isolated-agent/run-executor.tsarmTimeronTimer
cron 工具与权限封顶src/agents/tools/cron-tool.tscron-tool-creator-cap.tscreateCronToolcapCronJobToolsAllowOnCreateassertCronSelfRemoveScope
分离任务台账src/tasks/task-executor.tsdetached-task-runtime.tscreateTaskRecordfinalizeTaskRunByRunId
高权工具的护栏src/agents/tools/gateway-tool.tssystem-agent-tool.tsnodes-tool-commands.tscreateGatewayTool(只读)、BLOCKED_INVOKE_COMMANDS
工具错误类型src/agents/tools/common.tsToolInputErrorcreateActionGate