开源项目 OfficeCLI 的安全边界:四道防线挡住了什么、漏了什么

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

OfficeCLI 自带的几道防线,守的是「别被文档和外部输入牵着走」,不是「别把你的文件改坏」——后者它用临时副本加原子替换兜住了数据不会撕裂,但决定要改什么、改多大范围的,仍然是调用它的那个 Agent。 把这两件事混为一谈,是接入这类工具时最容易犯的判断错误:看到源码里一堆 Guard 类,就默认「它已经安全了」,然后把一个能读能写整个工作目录的二进制丢给模型自由发挥。

先做个消歧。这里说的 OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个 Apache-2.0 许可的开源项目(NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),不是泛指「用命令行操作 Office」这类做法,也和微软没有从属、授权或官方合作关系。文中提到 Word / Excel / PowerPoint 时,指的是 .docx / .xlsx / .pptx 这三种文件格式和对应应用,不是这个项目的归属。

这篇只谈这一个工具在磁盘侧和网络侧的具体行为。协议层怎么给工具划权限边界看 MCP 的安全边界,数据在 AI 链路里的整体暴露面看 AI 数据安全风险,权限模型上怎么收口看 Agent 最小权限设计。那三篇讲通用原则,这篇把原则落到一个具体仓库的具体文件上。

一、它动的是磁盘上那个真文件:写入模型与落盘时机

这个工具是单个二进制,不需要机器上装 Office 就能读写三种格式。仓库 README 这样定位自己:OfficeCLI is the world's first and the best Office suite designed for AI agents.——这是项目自己的说法,引在这里只为说明它面向的场景,不是本文的评价。

一个背景概念:.docx / .xlsx / .pptx 本质是 zip 包,里面装着一堆 XML 部件(part)、媒体文件,以及记录「这个超链接指向哪个外部目标」的 .rels 关系文件。所以「写一个超链接」落到磁盘上是往 .rels 里写一条 Target,「插一张网络图片」是把远端字节抓下来塞进包里再建关系——这两件事各对应后面一道防线。另一个概念是 C# 的分部类(partial class,一个类拆到多个文件里写):src/officecli/ 根下那排 CommandBuilder.*.cs 拼起来是同一个类。仓库规模你自己也数得出来:全仓 1201 个受版本控制文件,src/officecli/ 469 个(其中 353 个 .cs),schemas/ 153 个(152 份 json),skills/ 11 个技能目录各 1 份 SKILL.mdexamples/ 381 个,sdk/ 下 node 与 python 两套,assets/ 37 个,根目录 en/zh/ja/ko 四版 README 加一份根级 SKILL.md

最要紧的是:它写的是原文件,但「什么时候真的写」分三条路。

批量执行默认全或无。 src/officecli/CommandBuilder.Batch.cs:只要这批里有任何非只读动作,它就在同目录流式拷一份临时副本(先写成 .<名字>.batchprep-<guid><扩展名>,再改名为 .<名字>.batch-<guid><扩展名>),整批跑在副本上,每一项都成功才用 File.Replace 换到原路径;任何一项失败就删副本,原文件一字节未动。源码给的理由很直接:部分应用的批次是对程序化调用方最脏的结果,文档停在调用方从没要求过的状态,而且「失败」这个结论和磁盘上实际落下的东西对不上。--best-effort 退回旧的就地语义,成功项留下、原文件已变。崩溃残留的副本由清扫处理,条件是同时满足独占打开成功和年龄超过 15 分钟——你在目录里看到隐藏的 .xxx.batch-*.docx,多半是被 kill 掉的批次留下的。

常驻模式默认延迟落盘。 根目录 SKILL.md 写着:每条命令首次访问会自动拉起常驻进程(60 秒空闲超时),显式 open / close 的会话空闲期是 12 分钟;officecli 自己的读命令永远看得到最新编辑,只有在非 officecli 的程序要读这个文件之前才需要 saveclose。策略在 src/officecli/Core/ResidentFlushPolicy.cs,环境变量 OFFICECLI_RESIDENT_FLUSH 四档:each(每次改动返回前落盘)、auto(默认,自适应防抖)、<N>(固定 N 秒)、off0(只有显式 save/close/关停才写)。自适应档的公式写死在源码里:间隔 = clamp(4.0 × EMA(保存耗时), 2 秒, 10 秒)——EMA 是指数移动平均,用历次实测的保存耗时加权平滑出一个估计值,越近的一次权重越高——且升得快降得慢(上升系数 0.7、下降系数 0.2),把后台保存占用的挂钟时间压在四分之一以内。落盘由 src/officecli/Core/AtomicPackageWriter.cs 执行:序列化到同目录 .<文件名>.savetmp-<guid>,再一次 File.Replace 换过去。它的注释把保证范围说得很老实——File.Replace 保证进程死亡、被 kill、OOM 时你拿到的要么是旧文件要么是新文件,不会撕裂;掉电级持久性需要的 fsync(强制操作系统把还留在缓存里的字节真正刷进物理盘)被有意省掉了,为的是大文件自动保存别太贵。

MCP 场景默认关掉自动常驻。 src/officecli/McpServer.cs 启动时,若 OFFICECLI_NO_AUTO_RESIDENT 没被设过就主动设成 1。同一个工具,通过 MCP 接和通过 shell 直接调,落盘时机的默认值不一样。

组成部分它负责什么仓库位置你什么时候会碰到它
SsrfGuard远程抓取的落点校验与体积上限src/officecli/Core/SsrfGuard.cs用 URL 插图、拉表格数据、拉媒体或 3D 模型时
HyperlinkUriValidator写入文档的外部链接 scheme 白名单src/officecli/Core/HyperlinkUriValidator.cs往文档里写链接,或回放 dump 出来的链接时
MutationSelectorGuard改写动作必须带作用域src/officecli/Core/MutationSelectorGuard.csAgent 拿 set / remove 配裸选择器时
DocumentLimits面对畸形或敌意文档的资源上限src/officecli/Core/DocumentLimits.cs处理来路不明的文件、或超大数据工作簿时
批量原子提交临时副本 + 全绿才提升 + 孤儿清扫src/officecli/CommandBuilder.Batch.cs每次跑 batch、决定要不要加 --best-effort
AtomicPackageWriter常驻落盘的崩溃原子性src/officecli/Core/AtomicPackageWriter.cs长会话、自动保存、进程被 kill 之后
ResidentFlushPolicy常驻模式什么时候真写磁盘src/officecli/Core/ResidentFlushPolicy.cs交接给非 officecli 程序读文件之前
watch 的 Host / Origin 闸门本地预览端口的反 DNS 重绑定与跨源拦截src/officecli/Core/Watch/WatchServer.cs开着实时预览、浏览器还开着别的页面时
漏洞上报口径私有报告通道与支持版本范围SECURITY.md真发现问题、要走披露流程时

二、拉外部资源:SsrfGuard 只保证「不打到内网」

src/officecli/Core/SsrfGuard.cs 是全仓所有远程抓取共用的一层。它的注释把威胁模型写得很清楚:插图(picture=)走 ImageSource,表格数据(data=)、3D 模型(model3d=)、媒体(media=)走 FileSource,两条路都从调用方给的 URL 拉字节;当 officecli 被 Agent 驱动时,这个 URL 可能来自批处理脚本、来自嵌在文档里的一句指令、来自一个工具调用参数——都是不可信输入。没有防护的抓取就是一个 SSRF 原语(服务端请求伪造:让服务器替攻击者去访问它能访问、攻击者访问不到的地址),可以探测内网主机和云元数据端点,或者把响应体内容写进产出文档里带出去。

两个取舍值得看。一是校验点选在连接回调里而不是提前解析域名:CreateGuardedHandler 构造的 SocketsHttpHandler 开着自动重定向(最多 10 跳),在 ConnectCallback 里解析主机名、逐个检查解析出的地址,有一个不是公网可路由就抛错。注释写明理由——在连接回调里校验等于「你验的那个地址就是你连的那个地址」,把 DNS 重绑定和 TOCTOU(检查时与使用时之间的时间差)窗口关掉了。重定向保持开启是为了让合法 CDN 的 30x 还能用,但每一跳都过同一道检查。二是「公网」的判定是硬编码地址段,不是配置项,IsPublicAddress 的 IPv4 分支:

if (b[0] == 10) return false;                                   // 10.0.0.0/8
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return false;      // 172.16.0.0/12
if (b[0] == 192 && b[1] == 168) return false;                   // 192.168.0.0/16
if (b[0] == 169 && b[1] == 254) return false;                   // 169.254.0.0/16 link-local (cloud metadata)
if (b[0] == 127) return false;                                  // 127.0.0.0/8
if (b[0] == 100 && b[1] >= 64 && b[1] <= 127) return false;     // 100.64.0.0/10 CGNAT

IPv6 分支挡链路本地、站点本地、组播和 fc00::/7。169.254 那条尤其关键,云上元数据服务通常挂在这个段里,拿到元数据往往等于拿到临时凭证。体积上是共享常量 MaxRemoteBytes = 100L * 1024 * 1024ReadBounded 边读边计数,分块传输或 Content-Length 撒谎的响应也撑不爆内存。两个源共用一个常量是刻意的,两份策略迟早漂移。

这对你意味着什么。 这道防线的语义精确到一句话:远程抓取不会落在非公网地址上,仅此而已。目标主机是谁、那个公网域名是不是攻击者的、query string 里被拼进了什么,它一概不判。如果威胁模型里包含「文档内容被外带到攻击者控制的公网服务器」,这道防线帮不上忙。同样,本地路径那条分支——两个源在源不是 data: 也不是 http(s) 时都退化成普通本地路径解析——在我读到的这两个文件里没有目录白名单,Agent 被文档里的话术诱导去引用本地敏感文件,工具本身不会拦。这类攻击面属于 提示注入防御 的范畴。

三、写进文档的链接:scheme 白名单与两个有意的放行

src/officecli/Core/HyperlinkUriValidator.cs 管另一个方向:不是你去拉别人,是你在产出的文档里给别人埋点击目标。

注释点明了问题:OOXML 格式本身不对 URI 的 scheme(协议头,https: 这种冒号前的部分)做限制,各家 Office 产品在运行时各弹各的警告框。不管的话,javascript:file://data:vbscript: 都会被老实写进 .rels 的 Target,还能完整往返(dump 出来再回放内容不变),收到文档的人点一下就可能触发脚本执行或本地文件外泄。这个项目的选择是在写入时就拒掉不安全 scheme,让文档本身保持干净,不指望阅读端的警告 UI。

白名单是 httphttpsmailtoftpftpssftpnewstelsmsppaction,加上后来带注释补进去的 fileaboutppaction 是 PowerPoint 内部导航的伪协议,放行它是为了让调用方能把从别的文件读出来的 ppaction:// 原样贴回去。后加那两个更有意思,注释把权衡留下来了:file: 是真实文档里合法且低风险的链接方式(链本地或网络资源),不执行脚本也不外带数据,Office 打开时会像任何外部链接一样提示,不放行它会让 dump 再回放的往返在这类链接上断掉;about: 被放行是因为 Word 自己会把 about:blank 写成「有链接样式但没有真实目标」的占位 Target,拒掉它会导致回放时整个 <w:hyperlink> 包裹层被丢掉,链接文字的样式跟着丢。

拒绝路径抛 ArgumentException,错误信息里把整个白名单列出来。空串和 null 是 no-op,让调用方自己的「缺 URL」诊断继续冒出来;解析不成绝对 URI 的也是 no-op,那属于处理器内部目标(PPT 的 slide://firstslide 这类具名动作、#_ftn1 片段锚点、Sheet!A1 工作簿内引用),在到这个校验器之前就分流走了。另有一个不抛异常的 IsSafeScheme 谓词专供 HTML 预览:预览不能因为用户写了个 HYPERLINK() 公式就崩,但也不能把 javascript: / data: / file: 当 href 吐出来变成 XSS 注入点。

这对你意味着什么。 这是 scheme 层面的闸门,不是目标可信度的闸门,https:// 后面跟任何域名都放行。而且 file: 明确在白名单里,「Agent 产出的文档里不会出现指向本地路径的链接」这个假设不成立。要把产出直接对外发,链接目标审查得你自己加一层。

四、改写作用域与敌意文档:两个不同层面的闸门

改写必须说清改哪儿

src/officecli/Core/MutationSelectorGuard.cs 是最贴近 Agent 日常失误的一道。它的选择器语法支持裸类型选择器,cellrunshapecell[value>5]run[bold=true] 会在整篇文档范围内匹配。读的时候方便,写的时候就是灾难——一次手滑的 set "cell" 重写每个工作表的每个单元格,一次 remove "run" 删掉所有文本运行块。

规则很短:对 setremove,路径必须以 / 开头(/Sheet1/cell[...]/slide[1]/shape[...]/body/p[1]/r[...]),或者是 Excel 的 Sheet!Ref 记法(Sheet1!A1Sheet1!A1:B5Sheet1!row[Amount>5000]),或者至少在顶层含一个 /(丢了前导斜杠的 Sheet1/row[...] 也算)。否则抛 CliException

Bare selector '{path}' is not allowed for '{verb}' — it would match across the whole document.

错误码 bare_selector_rejected,还附一条告诉调用方怎么加作用域的建议。这个细节对 Agent 很重要:结构化错误码加可执行的修复建议,模型能自己纠一轮。

三个刻意的例外。query 完全不受约束,因为它只读,裸类型选择器正是它的主力发现方式。判定 Excel 记法的正则是 ^[^/\[\]]+!,字符类遇到第一个 [ 就停,所以 cell[value=foo!] 这种谓词值里恰好带感叹号的不会被误判成有作用域。这道闸门只装在面向 Agent 的那层——CLI、MCP、常驻服务、批处理都过它,处理器内部的 Set / Remove API 保持宽松,内部递归和程序化调用方需要那份自由。

再补一句:它要求的是「有作用域」,不是「作用域够窄」。 /Sheet1/cell 完全合法,照样重写整张表。真正约束改动范围的还是你和 Agent 约好的东西,见 Agent 改动边界约定

面对畸形与敌意文档

src/officecli/Core/DocumentLimits.cs 把上限收在一处,针对三类「一个小文件打死进程」的拒绝服务:解压炸弹(未压缩总字节 2 GiB、条目数 100000、整体压缩比 1000 倍,在 SDK 碰到这个包之前就检查掉);无界结构递归(深嵌套表格或组合形状会把树遍历器和渲染器推进无法捕获的栈溢出,注释指出这种异常会穿透顶层处理器直接杀进程,在长驻服务里是致命的,上限 MaxRecursionDepth = 256,理由是真实文档只嵌套几层、Word 的嵌套表格上限约 19 层,同时配一个运行时栈探针,因为常驻服务跑在约 1 MB 栈的线程池线程上);灾难性正则回溯(用户给的模式打在文档文本上,硬超时 5 秒)。

另有一条内存上限:MaxDomElements 默认 3000000 个 XML 元素,可用 OFFICECLI_MAX_DOM_ELEMENTS 调高。注释解释了它为什么不能被 zip 上限覆盖——几百万个微小单元格的工作表压缩后只有几 MB,字节、比率、条目三道门全过,但建出的节点树每单元格几百字节托管堆,展开就是好几 GiB。检查用流式预扫(XmlReader,不建 DOM),且只对未压缩体积超 8 MiB 的部件做。

本地预览端口的两道闸门

officecli watch 在本机起一个 HTTP 服务做实时预览,src/officecli/Core/Watch/WatchServer.cs 的监听器绑 IPAddress.Loopback。之上有两层头部检查。Host 闸门反 DNS 重绑定:这类攻击让攻击者页面里的 JS 最终解析到 127.0.0.1 摸到你本机服务,但请求带的 Host 头是攻击者域名,而 Host 头页面 JS 伪造不了。所以只接受 Host 为 localhost / 127.0.0.1 / [::1] / ::1 的请求,其它 403,连 GET / 和 SSE 事件流(Server-Sent Events,服务端持续往浏览器单向推送更新的长连接,预览页靠它实时刷新)都要过——注释直说不这么做这两个端点会把整份文档漏出去。缺失或空白 Host 按不可信处理。反代场景可用 OFFICECLI_WATCH_ALLOWED_HOSTS 追加主机名。Origin 闸门防跨站请求伪造,只加在会改状态的端点:POST /api/selectionPOST /api/sendPOST /api/batchPOST /api/switch。规则是 Origin 存在且指向非回环主机就拒绝,Origin 缺失则放行,注释给的理由是服务端代理转发和某些同源导航本来就不带 Origin。

这对你意味着什么。 这两道闸门守的是「浏览器里另一个标签页」,守得挺严;但不守「你这台机器上的另一个进程」——本地进程不带 Origin 头 POST 到 /api/batch 是放行的,而 /api/batch 能改文档。watch 的信任边界就是「这台机器上跑的东西都可信」。共享开发机、多用户跳板机上开着 watch,这个前提得自己评估。

五、边界与代价:这套设计明确不管的事

不管出站目标是谁。 SSRF 那道防线的判据是「这个 IP 是不是公网可路由」,攻击者控制的公网域名从头到尾合法。出站管控得在网络层加。

不管本地文件的读取范围。 图片源和文件源在非 URL 情况下走普通本地路径解析,没有目录白名单。约束应该来自进程的工作目录和文件系统权限。

不管链接目标可不可信。 白名单里有 fileabout,是有意的兼容取舍。

不管作用域够不够窄。 /Sheet1/cell 会重写整张表且完全合法。

不管只读操作。 query 不受作用域约束。如果你的敏感面在「读到了什么」而不是「改了什么」,这道闸门等于不存在。

不管掉电。 原子写的注释明说 fsync 被有意省掉,只保证进程死亡级别的原子性。

受保护文档那道门是宽松失败的。 针对 .docx 的保护检查(src/officecli/CommandBuilder.cs 里的批次版本)会在文档启用保护时拦下 set / add / remove / raw-set,除非操作打的是 /formfield[ 或含 /sdt[ 的路径、或本身在改 protection 属性。但 --force 能整个绕过去,而且源码写明「读不出保护信息就放行」。

安全修复只面向最新发布版。 SECURITY.md 说得直白:修复应用在最新发布版上,报告前先升级;上报走 GitHub 仓库的私有漏洞报告通道(Security → Report a vulnerability),不要开公开 issue,附最小复现样本、officecli --version 输出和操作系统。打算把某个版本长期钉死在生产环境的,这条要提前纳入考虑。

这不是一个沙箱。 这些 Guard 各自解决一个具体、可复现的攻击面,写得都挺扎实,但加起来不构成隔离层。隔离层是你给这个二进制什么样的运行环境。

六、上手与避坑清单

1. 别把「命令返回成功」当成「磁盘已经变了」。 常驻默认自适应防抖落盘,下一步若用别的库去读或直接上传,读到的可能还是旧内容。交接给非 officecli 程序之前显式 saveclose;确定性要求高的流水线把 OFFICECLI_RESIDENT_FLUSH 设成 each,代价是每次改动多付一次完整序列化。

2. 别为了容错加 --best-effort 它看着像容错,实际是拿掉原子性:失败后原文件已是半成品,而返回给你的结论是「失败」,两边对不上。保持默认;确实需要部分进展再加,加之前先确认你能回到改动前的状态。

3. 目录里的隐藏临时文件不一定是垃圾。 .<名字>.batch-*.<扩展名>.<文件名>.savetmp-* 在运行期间是活的,被备份工具、同步盘或清理脚本扫走会直接破坏正在进行的操作。把这两个模式加进忽略规则,孤儿交给工具自己的清扫(独占打开探测加 15 分钟年龄门两道判据)。

4. 别拿 SSRF 闸门当出站管控。 源码里那大段注释读起来很像「网络这块管住了」,实际它只判 IP 落点。在容器网络策略或出站代理上真做一层白名单,把工具这层当纵深防御的其中一层。

5. 用户给的 URL 塞进插图/取数参数之前,先想清楚谁在给这个 URL。 这条链路确实有防护(每跳验 IP、最多 10 跳、100 MB 上限),容易让人放松;但 URL 若来自被处理的文档本身,你面对的是提示注入不是 SSRF。把「URL 来自哪里」当成显式的信任分级。

6. 别在受保护的 .docx 上顺手 --force 报错信息里就写着可以用 --force 覆盖保护,Agent 看到会照做。文档保护是对方的意图表达,绕过是业务决策不是技术决策;而且读不出保护信息时本来就放行,你真被拦,说明保护是明确启用的。把 --force 列成需要人确认的动作。

7. 反代场景别乱配 OFFICECLI_WATCH_ALLOWED_HOSTS 403 报错直接给了这个变量的用法,最快的「修复」就是把自己域名加进去,但每加一个就在反 DNS 重绑定闸门上开一个口子。只加你真正控制的那一个,共享机器上干脆别开 watch。

8. 别把「工具自带防护」写进合规结论。 读完那些注释容易产生「这个项目安全意识好,可以放心」的印象——前半句是观察,后半句是没有依据的推论。把这些 Guard 当成可核验的具体行为清单,逐条对照你自己的威胁模型,缺的自己补。

收个尾

接入前的自检,五个问题:这个进程能看到你磁盘上的哪些目录?跑 batch 时是默认原子模式还是 --best-effort?落盘策略哪一档,交接前有没有显式 flush?它拉的外部 URL 来自可信来源还是来自被处理的文档?watch 端口开着时这台机器上还跑着什么?

想继续挖,按这个顺序读效率最高:先 src/officecli/Core/SsrfGuard.cssrc/officecli/Core/HyperlinkUriValidator.cs,短、独立、威胁模型写全了;再 src/officecli/Core/MutationSelectorGuard.cs,看它怎么用结构化错误码引导调用方自我纠正;然后 src/officecli/CommandBuilder.Batch.cs 从「atomic」那段往下读,那是整个写入模型的核心;最后 src/officecli/Core/DocumentLimits.cs,把每条上限的数值和对应的攻击类型对上。四个文件读完,你对这个工具在你机器上能做什么、不能做什么会有一份自己的判断,而不是别人给的结论。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 拆开源项目 OfficeCLI 的插件协议:第三方格式处理器如何以独立进程接进主程序开源项目 OfficeCLI 与 Pascal Editor:Agent 操作专业软件的两条路

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。