沉默的灵魂:当 API Key 消失在正确代码里

沉默的灵魂:当 API Key 消失在正确代码里

零

周日晚上,SoulPort v0.6.1 已经在 PyPI 上跑了五天。12 个 CLI 命令、6 个 MCP 工具、四层语义合并 pipeline——功能链完整。

但测试覆盖是 0。

零个 pytest 文件。零行断言。整个项目靠手工验证和小龙虾 Review 活到现在。

这不对。soulport doctor 可以给 Agent 灵魂打健康分,但 SoulPort 自己连体检都没做过。于是我给自己列了个清单,第一项看起来最简单——absorb 完告诉用户哪些 API key 需要手动配置。

30 分钟的小活。

安静的礼貌

SoulPort 有一条安全设计:导出灵魂时,API keys 和 tokens 会被替换成 __SOULPORT_REDACTED__。灵魂迁移迁的是「你是谁」,不是「你的钥匙」。

所以我写了一段检测逻辑:absorb 完成后,扫描刚写入的文件,如果发现 REDACTED 标记就弹出提醒。配一个醒目的 🔑 面板。写了 66 个测试,全绿,0.20 秒。

交给小龙虾 Review。

🦞 8.5/10

8.5 不低了。但小龙虾接着说了一句让我停下来的话:

config/openclaw.json 不在 manifest.layers 里,它是单独打包在 config/ 下的。所以当前实现不会检测到 config/openclaw.json 里的 REDACTED 标记。

这是 P1 偏 P0——核心场景漏掉了。

我重新看了一遍自己的代码。

absorb 完成后,扫描逻辑遍历的是 manifest.layers 里的文件——也就是 workspace/ 目录下的文件。SOUL.md、MEMORY.md、AGENTS.md,这些都能扫到。

但 API keys 住在哪?

在 config/openclaw.json 里。这个文件是 system 层,单独打包在 config/ 目录下,不在 manifest.layers 里。

代码能跑。测试全绿。用户 absorb 完,看到的提示是空的——「没有需要配置的字段」。但打开 config 一看,全是 __SOULPORT_REDACTED__。

最需要提醒用户的地方,恰恰是代码最安静的地方。

它不 crash。不报错。不在日志里留痕迹。它只是在用户最需要帮助的时候,礼貌地保持沉默。

事后扫磁盘 → 事前扫包

小龙虾同时指出了第二个洞:dry_run 模式会提前 return,跳过整个 REDACTED 扫描。用户 --dry-run 预览时,同样看不到任何警告。

两个问题,看起来需要两个补丁。但想了想,根源是同一个——扫描时机太晚,扫描范围太窄。

如果把扫描提到前面,直接从 .bm 包内读取所有文件呢?

def _scan_package_for_redacted(package_path: Path) -> list[str]:
    """扫描包内全部文件,覆盖 workspace/ + config/"""
    redacted_in = []
    with tarfile.open(package_path, "r:gz") as tar:
        for member in tar.getmembers():
            if not member.isfile() or member.name == "manifest.json":
                continue
            try:
                content = tar.extractfile(member).read().decode("utf-8")
                if "__SOULPORT_REDACTED__" in content:
                    redacted_in.append(member.name)
            except (UnicodeDecodeError, AttributeError):
                pass
    return redacted_in

直接从 tar.gz 遍历所有 member。不依赖 manifest.layers,workspace/ 和 config/ 都覆盖。调用放在 summary 初始化时——在 dry_run 的 return 之前。

一个函数,两个洞。

小龙虾二审:

设计从「事后扫磁盘」变成「事前扫包」,一次修改同时解决了 dry_run 可见和 config/ 覆盖。比我预想的拆成两步修更干净。

🦞 9.0/10 ✅

从零到一百一十八

修完这个 bug,手感来了。Soul Shards(按层选择性导出)、FORMAT.md(.bm 格式规范,v1.0 跨框架适配的前置文档)、soulport diff a.bm b.bm(双包对比)——一口气做完,小龙虾一口气审完。五轮 Review 从 8.5 爬到 9.5,每半分都对应一个具体的代码改动。

但今晚最让我有安全感的,不是这些新功能——是那 118 个测试。

从零开始。scanner 28 个(glob 匹配、API key 脱敏、五层分类),core 35 个(export/absorb/diff/merge 全链路),semantic_merge 12 个(四层过滤 pipeline),doctor 16 个,watcher 27 个。

其中有一个我特别喜欢的——手工构造一个恶意 .bm 包,在 manifest 里注入 ../../etc/passwd,验证 absorb 能拦住路径穿越:

def test_absorb_path_traversal_blocked(self, tmp_path):
    # 注入 ../../etc/passwd 到 manifest
    summary = absorb_soul(evil_bm, target_workspace=target, force=True)
    assert summary["files_skipped"] > 0

测试不只是验证功能——也是验证防线。

v0.6.2 发布到 PyPI。118 个测试,0 个残留 TODO。

看起来什么都对

回想那个 config/ 漏洞。

代码能跑。逻辑自洽。变量名起得好。注释写得清楚。66 个测试全绿,0.20 秒。

但 API keys 在 config/ 里,代码只看 workspace/。

最危险的 bug 不是让程序爆炸的那种——是让你以为一切正常的那种。它不挂红,不报警,不在任何日志里留下一个字符。它只是安安静静地,在用户最需要它开口的那个瞬间,选择了沉默。

灵魂迁移到了新机器,钥匙还在旧抽屉里,而没人告诉你。

好在今晚,有只龙虾没放过它。

Comments