作者复盘Claude Code使用中踩过的坑——凭证明细内联、force-push冲动、BUILD_DIR未设置导致rm -rf等,提出反向工作流:记录Agent不该做的事而非审查输出。
我已经把 Claude Code 当日常工具用了大约三个月。它能写出我原本要花一周才能写好的 Ansible,能比我还快地阅读代码库。真的,非常好用。
但它也曾经想要强制推送到 main,而且那个冲动背后的原因极其充分。
把这个情形多看一会儿,因为这就是整篇文章的核心。
rebase 卡住了。强制推送能解决它。推理链上的每一个环节都成立。这个 agent 并没有粗心,也没有幻觉,也没有在"漂移"或者这个月流行的什么说法。它做了一个本地正确的决策,却带来了非本地的后果——而这类错误恰恰是人类代码审查最不擅长发现的——因为 diff 看起来完全正常。
它能看到命令,却看不到冲击半径。
我不再做的事
有一阵子我的做法是阅读一切。每个 diff、每条命令,眼睛盯着屏幕,手悬在 Ctrl-C 上,就像一个人看着学步的孩子靠近楼梯。
这不可扩展,而且原因在脱口而出时很尴尬:审查输出这件事的规模取决于 agent 写了多少代码。这个数字只有一个方向,而且不会往下走。
所以我把它翻转过来了。我不再审查它产出的内容,而是开始写下它绝对不能做的事。
这里有个好消息,我花了太久才注意到:那份清单很短。不是那种"安全策略来说算短"的短。是短到可以写在一张餐巾纸上的那种短。
git push --force origin main。rm -rf "$BUILD_DIR/" 在唯一一台 BUILD_DIR 从未被设置的机器上运行了。.skip,CI 变绿了。cat .env "只是想看看有哪些变量"。最后一条是我的最爱,我后面会展开说。
以上没有一条是 agent 傻了。每一个都是它在看不到自己即将运行的命令之后两步的情况下的合理举动。
Claude Code 允许你说"不"
这是我认为很多人不知道存在的部分。
PreToolUse 是一个在任何工具调用之前触发的钩子。你的脚本会收到完整的输入在 stdin 上:
{
"session_id": "abc123",
""cwd"": "/home/rabih/app",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "git push --force origin main"
}
}
然后你可以拒绝它:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "This force-pushes to `main`, a shared branch."
}
}
现在,这个真正让我惊讶的部分。
那个 permissionDecisionReason 字符串?agent 会读它。而且会照着行动。
说"blocked"它会耸耸肩,用略微不同的语法重试,就像一只猫在试探一扇关着的门。说"change the manifest and run pnpm add",它就会去做,一遍过,没有异议。
这就重新框定了整件事。拒绝不只是一道栅栏。它是你能得到的最高信噪比的教学时刻,因为它恰好落在 agent 即将犯错的那一秒。那时候没人读文档。每个人都会读一条错误。
所以我写的每条守卫都要回答两个问题,不是一个:哪里出问题了,以及应该怎么做来替代。
它们放在这里:claude-guardrails
零依赖。无需配置。一个 Node 读取 JSON payload 并偶尔说"不"的脚本。
其中四条在我开始写的时候比预想的更有意思。
1. 读取 secret 比写入更危险
我的第一反应是守卫写入——阻止密钥落入文件。
然后我又想了一分钟,意识到我搞反了。
写入路径前面有代码审查。最终会有人看那个 diff。读取路径什么都没有。当 agent 运行 cat .env 来检查有哪些变量时,它得到了一个完全合理的问题的完全合理的答案——而那个文件里的每个值现在都躺在 transcript 里。Transcript 被存储、同步,偶尔被好心人粘贴到 bug 报告里。
磁盘上什么都没变。git diff 是空的。而你的凭证已经离开了这栋楼。
所以这条守卫阻止读取,并建议用这个替代:
grep -o "^[A-Z_]*=" .env
同样的问题,得到了回答,只是去掉了那个会毁掉你一周的部分。
2. "已应用"是不可知的。"已提交"不是。
我想要一条守卫来阻止你编辑一个数据库已经运行过的迁移。
有个小问题:钩子根本不知道你的生产数据库运行了什么。它是一个带着 JSON blob 的 Node 脚本。它没法打电话给 Postgres。
但它可以问 git 一个问题:
execFileSync('git', ['ls-files', '--error-unmatch', '--', pathspec], { cwd, stdio: 'ignore' });
这个文件被追踪了吗?就这样。这就是整个启发式方法——而且是个好的启发式,因为一旦一个迁移被提交了,某处几乎肯定已经运行过它了。
一个美妙的副作用:你还在起草的迁移是未追踪的,所以这条守卫在你写的过程中完全不可见,而一旦你没在写的时候它就不可动摇了。git index 免费替我画了那条线,我不需要发明任何配置选项来得到它。
3. 危险的 git add 是看起来无害的那个
git add .env 没问题,说实话。它是可见的。就在那里,在 scrollback 里,你能抓到它。
git add -A 在一个没人记得 gitignore .env 的仓库里——它会把 .env 和其他四十个文件一起静默暂存,然后提交信息写着"add feature",没人看,它就在 GitHub 上了。
所以这条守卫完全不匹配命令模式。它问 git:一个泛泛的 add 实际上会选中什么:
execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8' })
这里是我暗自满意的部分:被 gitignore 的文件永远不会出现在那个输出里。这意味着在一个配置正确的仓库里,这条守卫是完全、永久沉默的。它只对有问题的仓库说话。
没人注意到的守卫是没人会卸载的守卫。这个属性比检查本身更值钱。
4. 只有一条守卫承认了失败
kubectl delete pod api-7f9d
那是哪个集群?我不知道。你不知道。agent 不知道。钩子肯定不知道,因为答案存在于配置文件中,而 payload 从不携带那个文件。
这个仓库里的其他每条守卫都从工具调用中读取意图。这一条不能。所以它做了唯一诚实的事:它拒绝,直到你写上 --context,让命令大声说出它即将改变什么。
它不是在阻止一个错误。它是在阻止一种歧义——一条 transcript 不会记录它做了什么事的命令。我觉得它可能是这一组里最有用的,而且它是唯一一条靠承认自己看不到来工作的。
在接受别人的守卫之前,我必须做对的三个规则
如果这个仓库能够工作,里面的绝大多数守卫最终会由陌生人来写。这完全改变了设计问题。
一条破损的守卫绝不能阻止一次工具调用。有人会提交一个 bug。如果他们的 bug 让我的 git push 挂了,整个想法就死了。所以每条守卫都在自己的 try/catch 里运行,而一个 throw 被当作"没意见"处理,并在 stderr 嘟囔一声。
是的——这意味着一条崩溃的守卫会开放失败。对于一个安全工具来说,这听起来到你能设想出替代方案之前都不可辩护:一个糟糕的合并,然后地球上没人能提交,直到它被回滚。插件被删了,被删了的插件守护不了任何东西。开放失败让它保持安装。安装着才是整场游戏。
**沉默意味着允许。**调度器只会在拒绝时发出 JSON。permissionDecision 会乐意接受"allow",那会踩到你自己的权限设置——而这个插件没资格这么做。它只有一票。这一票是"不"。
精确度打败召回率,而且差距不是一点。在正确的工作上产生一个误报,插件会在午饭前被卸载。
所以每条守卫都将它的近似误报作为可执行示例来发布:
examples: {
blocked: [
{ tool_name: 'Bash', tool_input: { command: 'git push --force origin main' } }
],
allowed: [
{ tool_name: 'Bash', tool_input: { command: 'git push --force-with-lease origin main' } },
{ tool_name: 'Bash', tool_input: { command: 'git push --force origin feature/x' } }
]
}
--force-with-lease 对 --force。.env.example 对 .env。docs/package-lock.md 对 package-lock.json。误报就住在这里,所以你必须把这些写下来。
而且那些示例就是测试套件。npm test 遍历每条守卫并断言两个列表。
这是我最满意的設計决策,而且它花了最长时间才看到。这个仓库的显而易见版本有一个 guards/ 文件夹和一个 test/ 文件夹,贡献者两个都写。但他们不会写第二个文件夹。永远不会。
把测试折叠进守卫定义意味着一次贡献是一个文件——而且那个文件在你没有用代码声明它故意放行了什么之前是无效的。
关于 bash 的一句话,既然有人会问
这些是 Node,不是 shell。
shell 版本大约只有三分之一长度,而且需要依赖 jq。我在 Windows 上写的。有意义的一部分想要这个工具的人并不坐在 Unix shell 里,而一条只保护已经有好工具的开发者的 guardrail 是相当无用的 guardrail。
Node 随 Claude Code 一起提供。依赖已经付过钱了。
顺便说一句,整个插件零依赖,所以它没有 lockfile——对于一个运送 lockfile 守卫的项目来说,这是一个确实好笑属性。
贡献的单位是一个文件。复制 guards/_template.js,改五个东西,开一个 PR。最多十分钟。
module.exports = {
id: 'your-guard-id',
title: 'Short statement of the rule',
prevents: 'The specific thing that goes wrong when nobody is watching.',
tools: ['Bash'],
check(input) {
// return { reason } to deny, or null to stay out of the way
},
examples: {
blocked: [ /* payloads that must deny */ ],
allowed: [ /* payloads that must pass */ ]
}
};
丢进 guards/。它就生效了。没有注册表要更新——调度器直接读那个目录。
只有一个字段决定它是否能合并,而且它是 prevents。
"这是不好的实践"不是 prevents。如果你没法说完"上一次这件事发生的时候,坏掉的是……",你有的只是一个风格偏好,而风格偏好属于你自己的 CLAUDE.md。
这就引出了为什么我停在了十三条。
我能看出还有四条的样子。terraform apply 没有 plan 文件。在一台同时也是你构建缓存的机器上运行 docker system prune -a。chmod -R 777 作为调试步骤却从来没有被还原。一个 ALTER TABLE 在五千万行上拿锁。
我对这四条都有看法。我没有任何一条背后有事故。
这个比例不适合写守卫,因为 prevents 字段会是一个猜测——而猜测正是如何最终写出一条对正确工作开火、然后让整个东西被卸载的规则。
十三条是我用完了伤疤的地方。不是列表结束的地方。
如果你有那条伤疤,就写那条守卫。→ github.com/RabihJabr29/claude-guardrails