作者通过一次真实的 publish 脚本事故,详解 fail-close vs fail-open 的选择逻辑:误 block 比漏过更糟糕的场景判断并非哲学问题,而是具体风险权衡。
Deny-by-default(默认拒绝)是安全护栏的标准姿态:先关闭,明确放行,有人投诉再添加例外。三周前我就此写过关于 agent 工具访问的文章:fail-close 是在注册第一个工具之前就选择的默认状态,而不是事后拧上的护栏;相反的直觉是本末倒置的,那些在上生产前没有修正它的团队终将为此付出代价。
然后我写了另一个护栏,并且故意让它 fail open。它的头部文件用一行字说明了这一点:在这里,false block 比 miss 更糟糕。
两者都是正确的,而决定你需要哪一种的不是哲学问题。是一个 hook 在它所守护的脚本上拦截了 cat,让我不得不把真正的原因写下来。
2026-07-29,一个 agent 在一个仓库的主 checkout 上运行了发布仪式,而那个 checkout 当时就在默认分支上。发布脚本会修改跟踪的文件作为副作用——一个 metrics CSV 和一个索引——所以运行它是对仓库的一次写入,而不是读取。那个仓库要求用特性分支和 PR 来执行写入。
那个仓库已经有一个分支护栏了。它在这里什么都没做,因为它只守护 git checkout|switch,不守护会修改仓库的脚本。规则是"不要往主 checkout 写东西"。护栏实现的是"不要在那里切换分支"。这不是同一条规则,它们之间的缝隙刚好够一个脚本大步走过。
所以:要一个新的 hook。当发布引擎从主 checkout 在默认分支上运行时拦截它。写一个下午足够简单。
第一个实现匹配命令行中任何位置的脚本名。如果出现 publish.mjs,就 exit 2。
这是写它的显而易见的方式,而且它在,直到你使用它之前都是看不见的。有四个命令开始返回 exit 2:
cat content/scripts/publish.mjs
grep -n frontmatter content/scripts/publish.mjs
git diff content/scripts/publish.mjs
node --check content/scripts/publish.mjs
这些命令都没有运行任何东西。它们只是读取了一个文件。但因为文件名在命令行里,护栏就触发了。提交记录写得很清楚:它本会拦截生成这个 hook 的那次调查。
为了理解这个事件,我不得不去读导致它的那个脚本,而我写这个护栏来防止的那个事件,恰恰让我无法读取那个脚本了。一次 miss 会让我付出两个被弄脏的跟踪文件(metrics CSV 和索引)的代价,都可以用 git restore 恢复。一次 block 则让我失去了调查所针对的那个文件的可读能力。
修复方法是停止问"这个字符串是否出现",开始问"它出现在哪里"。命令行被按 ;、& 和 | 分割成顶级段,引用感知——所以引号内的分隔符不会分割它——然后每个段被 tokenize 成 argv。然后只计数两种形状:
if (GUARDED_SCRIPTS.includes(firstBase)) return true;
脚本是被运行的命令。或者它是 node 的入口点参数,排除了解析专用的标志,因为 node --check x.mjs 只解析然后退出。前导的 NAME=value 赋值被跳过,所以 FOO=bar node publish.mjs 仍然能解析到正确的第一个 token。
其他一切都会通过。cat、grep、git diff、wc、bat、在文本中提到路径的 echo:文件名是读取器的参数,从来不是被执行的东西,所以永远不会计入。
重写大约是 87 行 tokenizer。洞察在于:一个基于文本操作的护栏完全不知道文本的含义,而命令行有一种语法,位置承载了全部意义。
非对称成本不是新想法。任何定价过风险的人都遇到过它。容易忽略的是,它适用于你正在写的这个护栏,而且它给出的答案可能与你上个月给出的相反。
Deny-by-default 不是 universal virtue。它是对一个特定问题的正确答案:哪种方向的错误是我无法恢复的?对于持有数据库连接的 agent,无法恢复的方向是 miss。一次本该被拒绝的 DELETE 不会 un-run。拦截一次合法调用损失一次往返和一个明确的授权,而且你可以重试。Miss 是致命的,block 是廉价的,所以默认拒绝。
把成本颠倒,答案也随之颠倒。对于工作流护栏,一次 miss 会留下一个被修改的文件在 checkout 里,git 很乐意展示给我并 revert。一次 false block 则剥夺了我检查系统的能力,而且它是在打印一条自信的消息、描述一个从未发生的违规的同时这么做的。消息里没有任何内容告诉你护栏才是出错的那个。Miss 是廉价的,block 是昂贵的,所以默认放行。那个 hook 里每个模糊的检测步骤都 exit 0:git 不可用、不是仓库、detached HEAD,全部放行。
同样的原则,相反的配置。在它们之间传递的不是默认值,而是问题。
我想命名的失败模式是凭感觉(vibe)选择默认值。安全相关的工作有向严格倾斜的重力,而严格感觉上是负责任的,所以护栏被写成 closed,却没有人给 block 定价。我的 hook 的第一个版本是严格的。它也是无用的,而且花了拦截 git diff 它所守护的脚本才让我看清。
完成了的护栏并没有捕获所有东西。通过 bash -c 包装的執行、藏在 for 循环里的、或者通过 npx tsx 启动的,都是直接走过。这些被写入文件头部作为确认的 pass-through,带有一个"不要试图关闭这些"的指令,因为关闭它们意味着解析嵌套的 shell 语法,而那会把 false block 买回来。
这是一个真实的权衡,而且它在下一个读者遇到它的地方陈述了。头部文件在同一位置固定了作用域:
这个护栏是针对 ACCIDENTAL 情况(agent 在主 checkout 里输入明显的命令)的 guardrail,而不是针对故意混淆调用的determined caller的安全边界。
承认自己漏洞的 guardrail 是诚实的。暗示自己没有漏洞的 guardrail 比没有护栏更糟糕,因为人们会停止检查。
那四个坏掉的命令现在是测试套件的工作,作为命名的 allow case 固定下来:一个断言 node --check 只解析不运行,另一个断言对受保护脚本的 git diff 原封不动地通过。总共 17 个 case,提交记录了另外 25 个 exit-code 形状是手工跑的,覆盖了每种 block 形状、dry run、链接的 worktree、repo 外部,以及 bypass。
在你设定默认值之前,用真正重要的单位为两种错误定价,并注意昂贵的那个是否是 block。我的 miss 很便宜,因为爆炸半径是两个在我控制范围内的 checkout 里的文件。如果你的写入一张生产表,算术就会反过来,你就应该关闭护栏。重点是做算术而不是继承答案。
如果你说不出 false block 的代价,你就还没有设计这个护栏。你只是表达了一种关于风险的态度,然后让它编译了。
在我的情况里,线索是一个护栏,其自己的维护路径正好穿过它所拦截的东西。你总是第一个遇到你自己的护栏破坏了什么的人,而且没有人会为那个第一个 case 写测试,直到它已经发生在他们身上。