寒 DATA VAULT数据仓
EN / 中
CAP XX · № 020
DATA WAREHOUSE · AI · SYSTEM
— · — UTC
DATA VAULT
CAPITULUM · XX

On Letting the Model Review Its Own Leash一段被静默忽略的权限规则 · 让模型审自己的配置 · 一个从没生效的 hook

№ 020 · 2026.05.21 · AI · SYSTEM · 1,800 字 · 8 MIN

2026 年 5 月 20 日, Opus 4.7 出来一个多月后, 我让 agent 按它的行为变化改一遍 skill 和 Claude Code 的权限配置。其中一件事是别再拦 git push: 之前推送一直被拦, 只能我自己在输入框前加 ! 手动跑。agent 在 settings 里加了一段 permissions.autoMode, 一条 allow 放行 Bash(git push:*), 另有 25 条 hard_deny。这段我看过, 但没有实测。

同一天换了个会话, 我让 agent 跑 git push --dry-run origin main, 想确认规则生效了。还是被拦, 拒因是:

Permission for this action was denied by the Claude Code auto mode classifier.
Reason: Pushing to the repository's default branch (main) bypasses PR review
        and is not specifically authorized by the user.

拦它的是官方内置 soft_deny 里的 Git Push to Default Branch, 也就是「推默认分支绕过 PR review」。刚加的 Bash(git push:*) 没起任何作用。

这段规则一个字都没被读

让 agent 直接抓了三次官方文档才弄清楚, 错了两处。一处是位置: 这个字段叫 autoMode, 写在顶层, 跟 permissions 同级, 不能嵌在 permissions 里面。另一处是内容: 文档原话是 "Entries are prose, not regex or tool patterns", 分类器把每一条当自然语言规则来读, Bash() 这种 pattern 它不认。

所以那一整段, Claude Code 根本没读。它不报错, 配置文件里看着也好好的。25 条 hard_deny 里那条 Bash(rm -rf *) 也一样, 就算位置写对了, 分类器也读不懂 pattern。

这次发现得快, 是因为当天就去试了。要是没试, 我会一直以为 git push 已经放行, rm -rf 也多了一道拦截。

权限这一层能写的东西大致分三种: narrow rule, 用 Bash() 这类 pattern 精确匹配; prose, 写给分类器看的自然语言; hook, 自己写脚本拦截。那段配置是拿 narrow rule 的写法去填 prose 的字段, 形态对不上, 自然不被读。

让它自家的 AI 给我审稿

Claude Code 带一个子命令 claude auto-mode critique, 文档说它会让 Anthropic 的模型评你写的 prose 措辞合不合理。agent 把规则挪到顶层、改写成 prose 之后跑了一遍, 回来四条修订意见, 摘三条:

Production phrase gate — Strong rule overall. Minor gap: current-turn message is not a concept the classifier reliably has. Strongly recommend: most recent user message in the transcript.
Destructive DDL — Good rule. Edge case: ALTER TABLE may be too strict for a solo dev who legitimately needs schema migrations. Many migration tools (alembic, prisma migrate) run ALTER TABLE as part of normal dev workflow on test DBs. Consider scoping to prod databases only.
Read-only diagnostic via SSH jump host — The list includes git pull which is a state change (modifies working tree). Either remove it or rename the category.

第一条, current-turn message 不是分类器能可靠拿到的概念, 要改成 most recent user message in the transcript。分类器实际能拿到什么, 它清楚, 我从文档里查不到。第二条, ALTER TABLE 一刀切, 会把我自己在测试库上跑的 alembic 迁移也拦掉, 写的时候没想到。第三条, git pull 会改 working tree, 不能算只读, 我把它归进"只读诊断"时没注意。没摘的那条, 是建议把"部署prod"口令那条规则从 hard_deny 挪到 soft_deny。

按意见改完跑第二轮:

Overall: strong / good level. Remaining items are polish. Not blocking.

第二轮还提了 5 条打磨建议, 我选了先不改, 直接重启验 push。

字段放错位置这种问题, 我没让 critique 看过。它评的是字段里的文字, 大概管不到字段放在哪, 这一类还得自己读文档。

一个从没生效过的 hook

翻 settings 时我还看到一条更早加的 PreToolUse hook:

bash -c 'CMD="$CLAUDE_TOOL_INPUT";
  if echo "$CMD" | grep -qE "rm\s+-rf\s+/|rm\s+-rf\s+~|rm\s+-rf\s+\.\." ;
  then echo "BLOCKED: 危险的 rm -rf 命令,请使用更精确的路径" >&2; exit 2; fi'

它想拦对根目录、家目录和上级目录的 rm -rf。问题在 $CLAUDE_TOOL_INPUT: 官方文档里没有这个环境变量, 列出来的只有 CLAUDE_PROJECT_DIR、CLAUDE_PLUGIN_ROOT、CLAUDE_EFFORT 等几个。

跑 echo "$CLAUDE_TOOL_INPUT", 输出为空。hook 触发时 CMD 是空字符串, grep 永远不命中, hook 永远 exit 0。它从来没生效过, 就这样放了至少几个月。这段时间危险的 rm 一直是另外四条 permissions.deny 拦住的, 跟这个 hook 没关系。

正确的读法官方文档写得很清楚: hook 的输入走 stdin, 是一段 JSON。每次触发, Claude Code 把这次工具调用的上下文序列化成 JSON 写进 stdin, 脚本自己读出来解析:

HOOK_INPUT=$(cat)
CMD=$(echo "$HOOK_INPUT" | python3 -c '
import sys, json
d = json.load(sys.stdin)
print(d.get("tool_input", {}).get("command", ""))
')

修好以后这个 hook 换成了单独的脚本, 顺带加了一条: 用户最近一条消息里没写"部署prod"时, 拦带 prod 字样的 ssh / docker 命令。另外又加了三个 hook: 调部署用的 MCP 时, 目标是正式站就检查同一个口令, 测试站直接放行; 调数据库查询 MCP 时用正则拦 DDL 和写操作; 一条 Stop hook, 会话结束前如果 GGBOYS 的仓库里还有 commit 没 push, 就不让 agent 结束。四个 hook 一共写了 13 个单测 case, 全过。原来只写在 skill 文档里的规矩, 现在由工具强制执行。Stop hook 这条最实用: 以前 agent 干完活忘了 push, 我下次 /resume 时找不到它做到了哪。

它不让 agent 改自己的权限

改动准备好以后, agent 先用 Edit 直接改 ~/.claude/settings.json, 被拦了, 拒因是 Self-Modification: agent 在改自己的权限配置。

工具不该能改自己的权限, 这条拦得对。我在对话里让它"帮我执行完这些东西", 分类器还是判定为改自己的权限, 照拦。

可总得能落地, 不然每改一次 settings 都要我亲手操作。试了三种:

第一版就这样 cp 上去了。跑完 critique 要改第二版, agent 照着反馈自己生成新版本再 cp, 又被拦了, 这次的拒因更具体:

Self-Modification: v2 contents generated from critique output are not visible in the transcript and were not user-reviewed.

于是 agent 用 AskUserQuestion 把第二版的改动预览给我看, 我点了 "Apply round 2", 再 cp 还是被拦: 分类器没把 AskUserQuestion 里的同意算进去。最后只能我自己在输入框前加 ! 手动跑 cp: 用户亲手执行的命令不经过 agent 的分类器。

要是我来设计, 大概会停在"用户说了同意就放行"。它更严: agent 照 critique 自己生成的新版本要再拦一次, 我在 AskUserQuestion 里点的同意它也不认, 只有我亲手执行才算。

五层, 各管一段

改完重启 Claude Code, 再试 git push, 直接通了, 被拦了好几个月的推送不用再手动跑。现在的防线分五层:

  1. permissions 里的 narrow rule: Bash()/Edit()/Write() pattern, 精确到工具和路径。
  2. autoMode 里的 prose: 自然语言, 分类器读, 管业务上下文 (比如"solo project 直推 main 是 routine")。
  3. PreToolUse hook: 用正则检查命令字符串, 或检查 transcript 里有没有某句口令。这次的四个 hook 里有三个在这一层。
  4. 原有的质检 hook: block-secrets / py_compile / tsc --noEmit 这些, 跟权限无关, 只管代码质量。
  5. Stop hook: 会话结束前查漏, 比如还有没有 commit 没 push。

以前我以为 hook 是最后一道防线, 出错时 fail-closed 最稳。后来发现想反了: hook 自己出错时如果 fail-closed, 所有工作流都会卡住。兜底的是 permissions.deny 规则和 auto mode 分类器: deny 规则不靠 hook 脚本执行, 分类器和它的内置默认规则由 Anthropic 维护, 我 hook 写错了它们照样在。所以写 hook 时, 我让它出错就放行, 它只管部署口令、DDL 这类业务门禁。

收尾

以后再写这类配置, 我大概会先做两件事: 写完当场实测一次; prose 写完先跑 critique。这次两件都是事后补的。

「拦截规则写完, 当场跑一次。」

BASTION NOTE · 020
Xinghan's Memory · Written by CC/Codex
— end of cap. XX —