Contents

OpenClaw 核心流程第三部分:工具、记忆与 Hook 管道

第一部分 追踪了 gateway。第二部分 涵盖了路由与 agent 循环。现在我们完成最后一块拼图:工具如何在 Docker 沙箱中执行、记忆检索的工作原理,以及消息路径中所有 13 个插件 hook 的精确执行顺序。


工具策略:谁能使用什么

文件: src/agents/tool-policy.ts

工具通过带有分组展开的分层策略进行管控:

const TOOL_GROUPS = {
  "group:memory": ["memory_search", "memory_get"],
  "group:web":    ["web_search", "web_fetch"],
  "group:fs":     ["read", "write", "edit", "apply_patch"],
  "group:runtime": ["exec", "process"],
};

四种预设配置控制访问权限:

配置 访问范围
minimal session_status
coding 文件系统、运行时、会话、记忆、图像
messaging 消息 + 会话工具
full 无限制

策略编译为 glob 匹配器,采用拒绝优先语义。解析层级:agent 专属 → 全局 → agent 级别 → 全局工具策略。子 agent 受到额外限制 — gatewaycronmemory_searchsessions_send 始终被拒绝。


沙箱:Docker 工具执行

文件: src/agents/sandbox/

容器创建

buildSandboxCreateArgs() 以加固的默认配置构建 Docker 容器:

  • --security-opt=no-new-privileges
  • 用于系统调用过滤的 Seccomp 配置文件
  • AppArmor 强制访问控制
  • 所有 Linux capabilities 已丢弃
  • 只读根文件系统
  • 内存 + CPU + PID 限制

两阶段符号链接检测

文件: src/agents/sandbox/validate-sandbox-security.ts

安全验证同时捕获明显的和隐蔽的逃逸尝试:

第一阶段 — 字符串验证:

  • parseBindSourcePath()source:target[:mode] 中提取宿主机路径
  • normalizeHostPath() 解析 ...,折叠 //
  • getBlockedBindReason() 检查根挂载、被封锁路径、相对路径

第二阶段 — 符号链接解析:

  • tryRealpathAbsolute() 解析到真实文件系统位置
  • 对解析后的路径重新进行被封锁路径校验
  • 捕获:/innocent/path → 符号链接 → /etc/shadow

被封锁的宿主机路径(永不暴露):

/etc  /proc  /sys  /dev  /root  /boot  /run  /var/run  Docker socket

Shell 执行:三种宿主

exec 工具将命令路由到三种执行宿主之一:

宿主 使用时机 安全性
Sandbox(默认) 容器化执行 受限权限
Gateway 本地机器 白名单 + 两阶段审批
Node 远程设备 Gateway 代理调用

两阶段审批机制:首先对照 shell 白名单模式分析命令,然后以"仅允许一次"或"始终允许"选项请求用户审批。审批 ID 有有效期。


SSRF 防护:Web Fetch

文件: src/infra/net/ssrf.ts

fetchWithSsrfGuard() 实现多层防御:

  1. 协议白名单 — 仅允许 HTTP/HTTPS
  2. IPv4 封锁10.0.0.0/8127.x.x.x169.254.x.x172.16-31.x.x192.168.x.x100.64-127.x.x
  3. IPv6 封锁::::1fe80::/10fc00::/7
  4. 内嵌 IPv4 检测 — 捕获 IPv4 映射 IPv6、NAT64、6to4、Teredo、ISATAP 隧道
  5. 主机名黑名单localhostmetadata.google.internal.local.internal
  6. DNS 固定createPinnedLookup() 一次性解析并固定,防止 DNS 重绑定
  7. 重定向验证 — 最多 3 次重定向,循环检测,每个目标重新验证

限制:默认响应 50KB,最大 2MB,绝对上限 10MB。超时 30 秒。


记忆:混合向量 + 全文检索

Embedding 提供商链

文件: src/memory/embeddings.ts

自动模式解析:

1. 本地(GemmaEmbed 300M,通过 node-llama-cpp)→ 免费、私有
2. OpenAI → 远程 API
3. Gemini → 远程 API
4. Voyage → 远程 API
5. 仅 FTS 降级 → 无向量,仍可用

所有向量均经过 L2 归一化:

function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
  const sanitized = vec.map(v => Number.isFinite(v) ? v : 0);
  const magnitude = Math.sqrt(sanitized.reduce((sum, v) => sum + v * v, 0));
  if (magnitude < 1e-10) return sanitized;
  return sanitized.map(v => v / magnitude);
}

混合检索合并

文件: src/memory/hybrid.ts

async function mergeHybridResults(params: {
  vector: HybridVectorResult[];
  keyword: HybridKeywordResult[];
  vectorWeight: number;
  textWeight: number;
  mmr?: Partial<MMRConfig>;
  temporalDecay?: Partial<TemporalDecayConfig>;
})

算法流程:

  1. 联合合并 — 按 ID 合并向量和关键词结果
  2. 加权评分vectorWeight × vectorScore + textWeight × textScore
  3. 时间衰减 — 指数衰减,可配置半衰期(默认 30 天)
  4. MMR 重排序 — 最大边际相关性(Maximal Marginal Relevance)提升结果多样性

BM25 归一化

关键词检索使用 BM25 排名,归一化至 0-1:

function bm25RankToScore(rank: number): number {
  return 1 / (1 + Math.max(0, rank));
}

MMR:最大边际相关性

文件: src/memory/mmr.ts

Carbonell & Goldstein(1998)实现:

MMR = λ × relevance − (1 − λ) × max_similarity_to_selected

相似度使用小写 token 集合上的 Jaccard 计算。默认 λ = 0.7(偏重相关性)。算法从剩余候选项中迭代选择 MMR 最高的条目,避免返回冗余片段。

时间衰减

文件: src/memory/temporal-decay.ts

function calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays }) {
  const lambda = Math.LN2 / halfLifeDays;
  return Math.exp(-lambda * Math.max(0, ageInDays));
}

带日期的记忆文件(memory/2026-02-15.md)根据年龄进行衰减。“常青"文件(无日期)保持完整评分。

三表 SQLite 设计

  • chunks — 单个数据块,包含路径、来源、哈希、内容
  • chunks_vec — 向量 embedding 的虚拟表(sqlite-vec)
  • chunks_fts — 全文检索索引

差量索引跳过未更改的文件(哈希比较)。通过临时数据库 + 文件重命名的原子交换实现安全重建索引。


完整 Hook 管道

文件: src/plugins/hooks.ts

三种执行模式:

模式 使用时机 行为
Void 观察 并行,触发即忘
Modifying 转换 按优先级顺序执行,结果合并
Synchronous 热路径 不允许异步

13 步 Hook 执行顺序

每条消息按以下精确顺序通过这些 hook:

# Hook 模式 时机
1 message_received void(并行) 入站归一化后
2 before_model_resolve modifying(顺序) 模型选择期间
3 before_prompt_build modifying(顺序) 系统提示词组装前
4 before_agent_start modifying(顺序) LLM 调用前
5 llm_input modifying(顺序) 每次 LLM API 调用前
6 before_tool_call modifying(顺序) 每次工具执行前
7 tool_result_persist sync 工具结果返回后、持久化前
8 after_tool_call modifying(顺序) 每次工具执行后
9 llm_output modifying(顺序) 每次 LLM 响应后
10 agent_end void(并行) Agent 轮次完成后
11 message_sending modifying(顺序) 出站投递前
12 before_message_write sync 转录持久化前
13 message_sent void(并行) 成功投递后

Hook 6-9 在一个轮次内针对每次工具调用重复执行。before_tool_call hook 可以修改工具参数完全阻止调用message_sending hook 可以在消息到达 channel 之前修改出站消息

Hook Runner 实现

function createHookRunner(hooks, options) {
  function getHooksForName(name) {
    return hooks
      .filter(h => h.name === name)
      .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); // 高优先级优先
  }

  // Void:全部并行执行
  async function runVoidHooks(name, data) {
    await Promise.all(matched.map(h => h.handler(data)));
  }

  // Modifying:顺序执行,合并结果
  async function runModifyingHooks(name, initial, merge) {
    let result = initial;
    for (const hook of matched) {
      const hookResult = await hook.handler(result);
      if (hookResult !== undefined) result = merge(result, hookResult);
    }
    return result;
  }

  // Sync:不允许异步,若 handler 返回 Promise 则警告
  function runSyncHooks(name, data) {
    for (const hook of matched) {
      const result = hook.handler(data);
      if (result instanceof Promise) logger?.warn?.("Async in sync hook");
    }
  }
}

端到端:WhatsApp 消息 → 响应

以一条 WhatsApp 私信为例,串联所有流程:

WhatsApp webhook 触发
  → Channel adapter 构建 MsgContext { Channel:"whatsapp", From: JID, Body: text }
  → finalizeInboundContext() 归一化字段
  → [Hook 1: message_received]

resolveAgentRoute({ channel:"whatsapp", peer:{ kind:"direct", id:JID }})
  → Tier 6 匹配(binding.account)→ 默认 agent
  → sessionKey: "agent:main:direct:5511999999999"

recordInboundSession() → 持久化元数据

dispatchReplyFromConfig()
  → [Hook 2: before_model_resolve] → 选定模型
  → [Hook 3: before_prompt_build] → 组装系统提示词
  → runReplyAgent()
    → [Hook 4: before_agent_start]
    → createAgentSession() → Agent + AgentSession
    → session.prompt(text)
      → Agent._runLoop()
        → agentLoop() → 双重循环:
          → [Hook 5: llm_input]
          → streamAssistantResponse() → LLM API 调用
          → [Hook 9: llm_output]
          → 若有工具调用:
            → [Hook 6: before_tool_call]
            → tool.execute()(在 Docker 沙箱中)
            → [Hook 7: tool_result_persist](sync)
            → [Hook 8: after_tool_call]
            → 检查 steering → 循环或退出
          → agent_end 事件
    → [Hook 10: agent_end]

回复投递:
  → [Hook 11: message_sending] → 可能修改出站消息
  → [Hook 12: before_message_write](sync)
  → WhatsApp 出站 adapter → 文本切分至 4000 字符
  → [Hook 13: message_sent]

Gateway WebSocket:
  → agent 事件 → 150ms 节流 → 广播(dropIfSlow)
  → 最终消息 → 广播(保证投递)

关键架构洞察

  1. 双重循环设计优雅。 内层循环 = 工具执行 + steering。外层循环 = 后续跟进。中断语义与延续语义分离清晰。

  2. 工具调用间的 steering 是杀手级特性。 用户可在执行过程中重定向 agent。剩余工具调用被跳过,新指令注入。

  3. 13 个 hook 点赋予插件完全控制能力。插件可修改模型选择、工具参数、出站消息,甚至完全阻止工具调用 — 无需触碰核心代码。

  4. 记忆优雅降级。 向量 → FTS → 无。系统在 embedding 失败时不会崩溃。

  5. SSRF 防护相当彻底。 DNS 固定防止重绑定。内嵌 IPv4 检测捕获大多数实现都会遗漏的隧道攻击手段。

  6. 两阶段符号链接检测在业界实属罕见。大多数沙箱只做字符串检查。realpath 阶段捕获真正有威胁的攻击。


本文完成了我们对 OpenClaw 核心流程的深度分析。架构概览请参见原始系列的 第一部分第二部分第三部分

分析基于 OpenClaw v2026.2.18 和 pi-mono v0.53.0。来源:openclaw/openclawbadlogic/pi-mono。研究来自 cryptocj.org