
这周有个热门帖子,讲的是有人给 AI 代理开放了发送邮件的权限,评论区第一条问题显然是"谁来阻止它发错东西"。我自己仓库里也有个类似的问题躺了好几周没深究——我搭了同样的东西:一个有公开身份写入权限的 MCP(Model Context Protocol)工具,外加一个完全独立的无人值守脚本,两者都能写入同一个 API(应用程序接口)——我本以为它们有相同的安全行为。实际上没有。
我的 developer-presence MCP 服务有个 create_article 工具:
@mcp.tool()
def create_article(title: str, body_markdown: str, tags: list[str] = None, published: bool = False) -> dict: """Create a new DEV.to article. Returns id and url.""" payload = {"article": {"title": title, "body_markdown": body_markdown, "published": published}} if tags: payload["article"]["tags"] = tags result = _dev("/articles", method="POST", data=payload) return {"id": result["id"], "url": result.get("url"), "published": result.get("published")}
published: bool = False。如果 AI 代理调用这个工具但没有明确指定,文章就会以私密草稿的形式出现在 DEV.to 上。这个默认值不是随便写的——我特意这么设计,是因为这个工具可以从交互式 Claude 会话中调用,比如我可能随口说"帮我草拟一篇关于 X 的文章",然后忘了加一句"不要发布",这时候半成品想法就不该直接上线。
而另一边,这个定时发布流程——每天自动跑两次、无人在旁边盯着的那个——根本不调用 create_article。它直接把 publish_devto.py 作为子进程调用:
payload = {"article": {"title": title, "published": published, "body_markdown": body, "tags": tags}}
req = urllib.request.Request("https://dev.to/api/articles", data=json.dumps(payload).encode(), method="POST")
这里 published 不是关键字默认值——它直接读取自草稿文件 frontmatter 里的字段:
published = meta.get("published", "false").lower() in ("true", "1", "yes")
而驱动这个流程的任务指令,每次运行都会告诉 AI 代理:把 frontmatter 写成 published: true。所以我给 MCP 工具加的那个安全默认值——那个在"草稿"和"以我的身份上线"之间唯一的防护——根本不在这个流程的实际代码路径里。它没有被禁用,没有被覆盖,只是完全不同的函数,在完全不同的文件里,自动化流程从来不会碰它。
我之前已经写过这个仓库里相反的发现:git_commit.py 和 generate_commit_message MCP 工具跑了完全相同的 claude -p 调用,经过同一个 _STRIP attribution 过滤器,只是结构不同——行为一样,接口不同。我本来预期这次会找到同样的故事,确认两条发布路径是等价的。结果不是。create_article 和 publish_devto.py 用相同的 payload 结构访问同一个端点,但其中一个在函数签名里埋了安全网,另一个完全没有——它唯一的关卡就是 markdown 文件 frontmatter 里恰好写的值,而这个流程里 frontmatter 永远 是 true,这是设计好的,每次都是。
这跟"同样的代码,不同的接口"是有本质区别的发现。这是"不同的代码,而且区别恰好是我会称为安全功能的那个东西"。
当我停止假设 MCP 默认值在这里有任何作用之后,我去列了一下这个无人值守路径真正有的防护措施,而不是我之前误以为它有的那个:
/me/published API(不是本地计数器,所以不会跟实际情况失去同步。2026-07-14 有一次因为出口流量被拒绝导致整个运行被阻止,正确记录了配额消耗为 0,而不是靠猜)。这些是真实存在的,而且确实在起作用。但注意它们的共同点:每一条都在管哪篇文章会被发布,而不是管是否可以安全地执行发布调用。没有 dry-run 模式,没有"待审核"暂存步骤,没有人类可以在 POST 发出之前手动拉下的总开关。MCP 工具的 published=False 默认值看起来像是在填补那个最后的缺口,但其实没有,因为它根本不在这个循环里。
这次不打算发修复——诚实地讲,今天发现的东西是那个缺口本身,不是补丁。与其匆忙加上一个没想清楚的人工审核步骤,我更愿意把这个缺口精确记录下来。但真正的修复形状从缺失的部分里看得很清楚:publish_devto.py 应该跟 create_article 一样默认 published=False,然后需要一个显式的 --live 参数(或者一个 frontmatter 字段,不能只是"任务指令这次恰好写成这样")才能真正把文章改成公开。现在,每天跑两次、无人在旁的那个路径里,把"草稿"变成"上线"的唯一东西,就是一行 YAML,而任务指令每次都会把它设成一样的值。这不叫安全默认值。这叫披着安全默认值外衣的常量。