← 文章

OpenClaw

OpenClaw 能读文件,为什么却不能替我保存?

会议纪要能读,待办清单却存不了?从一次简单的保存请求,理解 OpenClaw 的工具筛选、allow 与 alsoAllow、路径守卫和执行环境。

上一次,我们让 OpenClaw 读了一份会议纪要,整理出三个待办。清单已经出现在聊天窗口里,你觉得不错,又补了一句:

就按这个内容,帮我保存到 notes/todo.md

这次,它却没有写入文件。

先别急着怀疑模型变笨了。假设这位助手被配置成只能使用读取工具,那么“理解了保存要求”和“有能力执行保存”之间,确实隔着一道边界。它可以把内容重新贴给你,却不能凭一句“好的,已保存”,让磁盘上真的多出一个文件。

这个假设场景正好接上上一篇留下的问题:模型面前的工具是谁挑出来的?一个工具既然已经出现,为什么实际调用时还可能被拒绝?沿着“读纪要,再存清单”这件小事,我们来看看 OpenClaw 怎么把能力和权限接在一起。

本文继续使用 OpenClaw v2026.5.22 的源码快照,讨论内置 Pi 路径中的常规工具装配与文件工具边界。延迟工具发现、各类插件和其他 harness 还有自己的衔接逻辑,不在这里逐一展开。工具装配入口

它拿到的工具箱,可能没有你想的那么大

在普通聊天里,模型写一句建议就结束了。到了 Agent 场景,模型还会收到工具的名称、说明和参数结构,才知道可以请求哪些实际动作。

不过,OpenClaw 并不是把仓库里所有工具直接摆到模型面前。工具能不能被构造出来,与运行环境、模型适配、配置和插件加载情况有关。候选工具形成之后,还会经过一条策略流水线,最后才成为本次运行可用的工具集合。

把它想成给一项工作准备工具会比较自然:项目里确实存在写文件的实现,不代表这次整理会议纪要就必须把它交出去。对只做阅读和归纳的助手,少给一点能力,往往更接近它的职责。

源码中的这条流水线依次处理 profile、provider profile、全局策略、provider 策略、Agent 策略,以及适用的群组和发送者策略;工具装配处还接上 sandbox、子 Agent 和继承策略。并不是每次都有这么多限制,但它们都有各自的位置。策略流水线

回到我们的清单。如果 write 已经在这段过程中被过滤掉,后面模型再想调用它,也不会因为“刚才已经答应用户”就自动获得实现。工具注册与调用查找仍然由程序负责。自然语言承诺不能替代一次成功的工具执行。

候选工具经过 profile 和逐层 allow、deny 筛选,形成可用工具集合;模型提出调用后,还要经过执行钩子、路径守卫和实际宿主约束
工具名称获准,只回答了“可以使用哪种能力”。这次调用能否访问具体资源,还要继续检查。

“我已经写进 allow 了”,为什么还不行?

继续设想,你检查配置,发现自己用了一个很小的 minimal profile。这个版本里,它的基础能力只有 session_status。于是你在后面的 allow 里加上 read,以为这就能让助手开始读纪要。

结果仍然不对。

这里容易误会的是 allow 的工作方式。策略流水线不是不断往工具箱里装东西,而是在现有候选集合上逐层过滤。前面的 profile 已经拿掉了 read,后面写一条“只允许 read”,并不能凭空把它放回来。这个简化例子最后甚至可能一个工具也不剩。

要扩展 profile 的基础能力,应在它被应用之前处理。OpenClaw 把 alsoAllow 合并到 profile 阶段,正是为了避免工具先被筛掉、后面再也加不回来的情况。profile 扩展有效策略解析

下面这段只演示相关字段:在这个版本的普通文件工具路径中,把读取能力加进基础集合,再用允许列表收窄,并启用工作区路径约束。其他策略层仍然可以继续限制它。

{
  "tools": {
    "profile": "minimal",
    "alsoAllow": ["read"],
    "allow": ["read"],
    "fs": { "workspaceOnly": true }
  }
}

这里有三个不同的动作:alsoAllow 扩展基础能力,allow 保留符合条件的工具,workspaceOnly 约束文件工具的路径。把它们都理解成“开权限”,就很容易改错地方。

同一层规则里,deny 会先于 allow 判断。即使某个名字同时写在两边,拒绝仍然生效。还有个不太符合直觉的小细节:在本期匹配器中,空的 allow: [] 不表示全部拒绝;没有有效允许模式时,这一层不增加白名单限制。真正的全拒绝可以由 deny: ["*"] 表达。名称匹配规则

这些细节并不适合靠记忆猜。遇到“明明配置过了”的情况,先分清自己改的是哪一层,比不断往列表里补名字更有效。

工具放进来了,也不代表每个文件都能读

现在 read 已经可用了。助手顺利读取了工作区里的 notes/meeting.md

你又说:“另一份材料在工作区外的共享目录,也一起看看。”同一个读取工具,这一次却可能返回路径越界。

这不是前后矛盾。名称策略只判断“read 这种能力能不能进入集合”,它并不知道每次调用会传什么路径。等模型真的给出参数,文件工具才有足够的信息检查目标是否在允许范围内。

在启用 workspaceOnly 的宿主读取路径上,OpenClaw 会给工具包一层工作区守卫。调用来到这里,先解析路径、检查根目录边界,再进入底层读取。相对路径中的 .. 不能只靠肉眼看;工作区内的符号链接也可能指向外面,因此路径检查还会调用专门的别名越界检查。文件工具包装路径检查

可以把两次读取并排看:

调用 工具名称层 路径层
读取工作区里的纪要 read 已获准 目标在允许根目录内,可以继续
读取无关的外部材料 仍然是获准的 read 开启工作区限制时,目标越界则拒绝

从这个角度看,“模型看到了工具”只是故事的中间状态。即使名称和参数结构都合法,具体资源仍然可能不在授权范围内;通过路径检查后,也仍可能遇到文件不存在或操作系统拒绝访问。这些错误不能全部归到模型身上。

一份技能说明,为什么可以成为例外?

事情到这里似乎很简单:只读工作区内的文件就好了。但真正使用起来,又会出现另一种合理需求。

假设整理纪要需要参考一个已经加载的技能,它的说明文件放在独立的技能目录,而不是当前工作区。系统已经把技能位置告诉了模型,读取工具却说那里不能去。这时,提示信息和执行边界就没有对齐。

OpenClaw 曾经修复过这个问题。PR #82397为宿主读取工具加入了一个范围很窄的例外:额外读取根目录来自运行时已经解析好的技能集合,而不是模型临时提交的一串任意路径。这个例外只接到读取路径,写入和编辑不会因此获得同样的扩展。

本期快照中,resolveSkillReadRootsSkillSnapshot.resolvedSkills 收集技能根目录,再把它们交给宿主 read 的守卫。普通外部文件、未包含的相邻技能目录,以及逃出允许根目录的符号链接,仍然需要被边界检查拦住。技能根目录来源

这里值得学的不是“给限制开个口子”,而是把例外说完整:它来自哪份可信状态,允许哪一种动作,可以触及哪些资源。只说“技能比较特殊,所以放行”,很容易把一个阅读需求扩大成整片目录的读写权限。

三个不同的问题:名称策略决定工具是否进入集合,路径守卫决定目标文件是否在允许范围,sandbox 与执行宿主决定动作在哪里发生;技能例外只扩展已解析根目录的读取

这些边界约束的是不同对象,不能用其中一个的存在,推断其他边界已经成立。

禁用了 write,就得到只读助手了吗?

再回到“保存清单”。你可能会觉得,既然不希望助手写文件,只要禁用 writeedit 就够了。

这取决于它还拿到了什么。如果 exec 仍然可用,执行的程序也可能修改文件。名称策略过滤的是工具名,不会自动分析每条命令的全部副作用。因此,“没有写文件工具”和“整个执行环境不可写”并不是同一件事。OpenClaw 的同版本文档也明确说明了这个区别。工具策略与 sandbox

这也是 sandbox 需要单独存在的原因。工具策略主要决定哪些能力可用;sandbox 和执行宿主决定动作在哪里发生,以及那里暴露了哪些文件、挂载和权限。工作区只读,也不能自动代表所有额外挂载都只读。

源码里,启用 sandbox 时,文件工具会走对应的文件系统桥接;工作区访问模式为只读时,装配逻辑会省去相关写入、编辑和补丁能力。这里既有工具集合的缩小,也有执行环境的限制,两者是相互配合的。sandbox 工具装配

还有一个名字容易引起误会:elevated。它针对的是 exec 的执行位置与相关授权,不是一张能重新获得所有工具的通行证,更不能覆盖已经生效的工具拒绝规则。本文不展开命令审批机制,先把它与文件读取权限分开。

对这份待办来说,真正要决定的是:助手只负责给出内容,还是也负责保存?如果允许保存,保存到哪里?答案应该落实到对应能力和资源边界上,而不是依赖它在提示词里承诺“我会小心”。

执行前,系统还可以再看一眼

除了工具集合和文件路径,OpenClaw 还会给工具装配执行前钩子与取消处理。

执行前钩子拿到具体调用后,可以阻断、要求批准,或在规定流程内调整参数。对于我们追踪的宿主文件工具,外层钩子处理之后,实际调用仍要经过内部路径守卫。模型先前看到了 schema,并不意味着之后所有调用都无条件放行。执行前包装

因此,排查拒绝时,最好沿发生顺序问具体问题:工具有没有被构造出来,有没有在策略层留下来,调用有没有被执行前逻辑拦住,路径是否合规,最终宿主是否允许操作。这样才能找到真正负责的那一层。

如果只是反复让模型“再试一次”,底层约束没有改变,它往往只会重复同一次失败。清楚的拒绝原因既帮助使用者,也给模型留下调整计划的依据。

把几种容易混淆的情况实际跑一遍

为了验证这些判断,我对锁定版本的策略模块做了 31 项局部检查,包括 profile 扩展、逐层过滤、拒绝优先、别名、通配符、插件组和未知工具名。测试中的工具只有名称,不会调用真实模型或执行系统命令。

另外,用源码中原样抽取的路径守卫和项目锁定的 fs-safe 0.2.7,在临时目录里做了 13 项检查。工作区文件和明确加入的技能根目录可以读取;外部路径、相邻技能目录及符号链接越界被拒绝,未获扩展的写入工具也没有修改技能文件。

合计 44 项检查通过。它们验证了文章涉及的局部规则,不等于完整 OpenClaw 集成测试,也没有验证真实容器、远端执行宿主、所有插件钩子或并发路径竞态。工具装配与钩子部分的解释,仍以锁定源码为依据。

如果从零实现这套能力,我会先把“构造工具”“计算有效集合”“检查具体调用”拆成三个清晰接口,并给每次拒绝保留来源。OpenClaw 的实现提醒我们,权限复杂度不仅来自规则多,还来自同一个“允许”在不同层里指向不同东西。接口边界越清楚,后续替换模型、工具或执行环境时,就越不容易把旧限制弄丢。

那份清单最后能否保存,不该取决于助手回答得有多肯定。它应该取决于保存能力是否真的交给了它、目标位置是否获准,以及执行是否成功。模型负责提出下一步,运行时负责让这一步落在明确的边界里。

下一篇,我们回到保存之后的会话:一段聊天、一次工具调用和一场正在进行的运行,究竟分别留下了什么记录?

继续阅读