一个 Hook 堵住 Claude Code、Codex 的大部分权限"漏洞"
Claude Code 的权限规则是按命令前缀匹配的。
比如说:你配一条 Bash(git push:*),想让 AI 每次 push 前问你一声。但只要命令不是以 git push 开头,这条规则就不生效。
比如 cd /other/repo && git push 就能绕过去,因为它开头是 cd。
我自己遇到好多次,每次他都 cd /path/xx && git ... 或者 git -C xxx ...,把我想亲自验收的命令直接替我跑了。社区里也有人提 Issue-59498:被 subagent 这样连推了几个远程仓库。
说到底,前缀匹配只认命令的第一个词。危险的东西挪到第二句,或者在命令名前面垫个 cd、git -C、一个环境变量,它就看不见了。Codex 一样。OpenCode 的权限做得细一些,可它不吃 Claude Code 这套 Hook,搬不过来。
好在 Claude Code 和 Codex 都有 hook 这种东西。
hook 就是工具在一些关键节点(比如"马上要执行一条命令了")留的一个口子:到点它先运行你指定的程序,把当前上下文(这里就是那条完整命令)丢给你,再看你这个程序返回什么,决定下一步怎么走。
对权限来说,返回值就三种:放行 (allow)、拦死 (deny)、弹个窗问你一声(ask)。
cd /other && git push 走到我的脚本,我一看里面有 git push,返回一个 ask,它就停下来等我点确认------前缀规则匹配不到的,我用代码兜住了。
最终效果

三家的权限系统,都不完美
| 能力 | Claude Code | Codex | OpenCode |
|---|---|---|---|
| 按命令模式 ask/deny | ✅ | ❌ | ✅ |
| Claude Code 风格 Hook | ✅ | ✅ 但 deny 只对 Bash 可靠(#27833) | 无 hook,只有 TS 写插件,其实意思也是类似的 |
| Hook 弹确认(ask) | ✅ | ❌ schema 有、runtime 不认 | ⚠️ 权限能 ask,plugin 那条有 issue |
| 复合命令拆开看 | ❌ | ❌ | ✅ tree-sitter 逐条解析 |
git -C 跨仓库 |
❌ | ❌ | ⚠️ 看得到,但规则还得自己补 |
读 .env 之类 |
✅ 仅前缀 | ❌ | ✅ 默认 deny |
Claude Code 和 Codex 卡在命令解析和 ask 接入口;OpenCode 做得全,但不认 Claude Code 这套 Hook,plugin 那条路也还有 open issue。
Claude 对于 rm 的隐式特殊处理
ClaudeCode 给 rm 留了后手 ------ echo x && rm -rf /important 会被拦,因为 Claude Code 单独给 rm 做了复合命令检测;
同样的检测却没给 git push。为什么 rm 有、git push 没有,没文档,也没开关。
相关 issue 有几个:#59498、#16561、#46868、#33340。
这个蠢问题让我排查半天,真烦人!!
git -C 同理
git -C /other/repo push 里,-C 插在 git 和子命令中间,前缀不再是 git push,规则扑空,--work-tree、--git-dir 也一样。这些都不难,Claude Code 和 Codex 就是没管。
解决方案:一套规则,两家通吃
Claude Code 和 Codex 都有 PreToolUse hook:命令执行前跑我的脚本,拿到完整命令,解析后再回一段 JSON 告诉它放行、拦死、还是弹确认。
json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "git 写操作,需要确认"
}
}
我要的是一份规则,同时管多个工具(Claude Code、Codex;OpenCode 不吃这套 hook)。
如果两边各写一套,迟早写岔。现在实现分成两个入口,共用同一套路径、Git、决策和运行时模块:
deny-compound-bypass.ts:正则模式入口,零运行时依赖,当前实际注册使用这一版deny-compound-bypass-ast.ts:AST 模式入口,用 tree-sitter-bash 理解 Shell 结构;解析失败或依赖不可用时自动退回正则版
!TIP Tree-Sitter 就是代码高亮解析的,所以他能识别语法
脚本每条命令前都要跑,所以默认入口追求快和零依赖;如果需要更准确地处理转义这些 Shell 边界时,再切到 AST 入口。
麻烦点在于,ClaudeCode、Codex 看似是同一套协议,跑起来不是一回事。Codex 的 schema 里也有 ask,但只是接收而已,运行时并不认:
测试环境:codex-cli 0.144.5,2026-07-18 复核(0.141.0 时就这样,一直没变)。
源码里 schema.rs 的枚举确实收 ask,可 output_parser.rs 到运行时直接回一句
PreToolUse hook returned unsupported permissionDecision:ask------文档说能弹窗,代码不认。
功能支持区别对比:
| Claude Code | Codex 0.144.5 | |
|---|---|---|
deny(Bash 命令) |
✅ | ✅ |
deny(文件写入 apply_patch) |
✅ | ❌ 不生效(#27833) |
ask 弹窗确认 |
✅ | ❌ unsupported |
Read matcher |
✅ | ❌ |
PostToolUse 写后处理 |
Write/Edit/MultiEdit | apply_patch |
识别 Codex 就看一个字段------turn_id,它独有。分出来之后区别对待:
- Claude Code :该弹窗口的弹
ask,让我决定是否运行 - Codex :它弹不出确认(我本机
approval_policy=never+danger-full-access),硬deny只会挡路。所以真危险的(关机、磁盘、pipe 到 shell)升级成deny拦死;git写这种"本来就想让它自动跑"的,直接放行。
选择你的运行时:为什么用 Bun + TypeScript
官方例子清一色 Python 和 Bash。这两个我都没用,为什么呢?
Bash 不适合写逻辑。
-
解析 JSON 得拖个
jq(又多一个依赖),匹配靠grep -qE,几个条件判断写出来就是一坨if [ ... ]; then ... fi嵌套。维护一周,你自己都不想再看。 -
它还有茴香豆的 N 种写法------同一件事好几种姿势,每种都埋着雷。赋值必须写死
x=1,你敢加个空格写成x = 1,它就把x当命令去执行了; -
判断字符串空不空,
[ ]、[[ ]]、test随你挑,但是[ $x = foo ]里变量忘了加引号、值一为空就直接语法错。 -
引号更是重灾区:单引号里什么都不解析、连转义一个单引号自己都做不到,双引号里
$、反引号、\又各有各的脾气,再加上$'...'、$"..."这种你八成没见过的写法。
Python?可读性是个笑话。 一边喊 "Readability counts",一边把 [x.strip() for x in lines if x and not x.startswith('#')] 硬挤一行,真有小众哥写代码喜欢压行吗?密密麻麻的你有什么可读性?
而且启动慢,光 CPython 初始化加 import json 实测就 50--100ms 起步。
最后选了 Bun + TypeScript。 冷启动约 14ms(benchmark:原生跑 TS,Bun 14ms、Node+tsx 280ms),Bun.stdin.text() 直接读 stdin、JSON.parse() 直接解析。正则入口一个依赖都不用装;AST 入口则额外依赖 web-tree-sitter 和 tree-sitter-wasms。
类型标注、正则字面量这些也是现成的。hook 每条命令都要跑一遍,冷启动这点差距是实打实的体验------所以运行时值得挑一下。
打磨脚本
先讲默认的正则入口。它要解决的不是"有没有出现危险单词",而是三个更具体的问题:
- 这个词真的是命令,还是引号里的普通文本?
- 这个命令前面套了环境变量、
sudo或复合命令后,还能不能识别? - 拦截之后,能不能直接告诉我究竟是哪一段有问题?
最初的规则很直白:扫描整条命令,只要出现 mkfs、| bash、git push 之类的模式就拦。
bash
磁盘命令 /\b(fdisk|mkfs|wipefs)\b/
管道到 shell /\|\s*(sh|bash|zsh)\b/
git 写操作 /\bgit\s+(add|commit|push|reset|...)\b/
这能抓到 cd /x && git push,但也会把数据当命令、把参数当命令。真正麻烦的地方,从这里才开始。
一:不要把引号里的文字当命令
例如我只是想在日志里搜索两个单词:
bash
grep -E "reboot|halt" /var/log/syslog
这里的 reboot、halt 都是搜索文本,| 是 grep 正则里的"或",没有任何命令会被执行。直接全文扫描却会把它们当成关机命令;类似地,grep "| bash" file 还会被误判成"管道到 Shell"。
解决办法是先生成一份"扫描副本":保留引号和 Shell 结构,但把引号里的正文替换成等长空格。
bash
grep -E "reboot|halt" /var/log/syslog
↓
grep -E " " /var/log/syslog
这样,引号里的 reboot|halt 消失了;真正的 curl x | bash 中,管道符在引号外,仍然会被检测到。
但不能无脑清空所有引号。bash -c "mkfs /dev/sda" 的引号里不是数据,而是交给 Bash 执行的命令体:
bash
bash -c "mkfs /dev/sda"
所以处理顺序是:先找出 sh -c、bash -c 这类命令体,把它们暴露给扫描器;再清空其余引号内容。前者照拦,后者不再误杀。
二:只在"命令位置"匹配
只找 mkfs 这个单词仍然不够:grep mkfs log.txt 里的 mkfs 是参数,不是要执行的程序。
真正要找的是"命令位置":整条输入开头,或者 ;、&&、||、|、换行之后。找到起点后,还要跳过命令前允许出现的包装:
bash
X=1 mkfs /dev/sda # 环境变量赋值
sudo -i fdisk /dev/sda # sudo 无参数选项
sudo -u root reboot # -u 还会消费下一项 root
sudo --user=root reboot # 长选项内联参数
doas -u root rm /etc/passwd # doas 同理
这里最容易漏的是 sudo -u root reboot:-u 需要一个参数,root 是目标用户,真正的命令仍是后面的 reboot。因此不能简单跳过所有 -xxx,必须区分哪些选项会消费下一项,并处理长选项、内联参数和 -- 终止符。
最后得到的效果是:上面这些危险命令都能识别,而下面这些只是在参数里出现同名单词的命令可以正常放行:
bash
grep mkfs log.txt
jq '.format' package.json
三:告诉我具体拦了哪一段
最终要实现图中效果,能知道是什么触发了拦截

复合命令可能很长。如果结果只说一句"危险删除",还得靠人重新找是哪一段触发:
比如图中命令这么长,你没法快速定位,此时优势就体现出来了

前面清空引号时使用"等长空格",就是为了让扫描副本和原命令保持相同下标。扫描器在副本的第几个字符命中,就能回到原命令的同一位置,再截取到下一个 Shell 分隔符。
bash
危险文件删除|命中:rm -rf .git
git 写操作|命中:git push
脚本也不会在第一个命中处停止,而是收集所有结果,按原命令中的位置排序、去重后一次列完。如果其中任何一项是 deny,整条命令最终就是 deny:
bash
rm -rf .git && reboot && mkfs /dev/sda && git push
危险文件删除|命中:rm -rf .git
系统关机/重启命令|命中:reboot
磁盘格式化/分区命令|命中:mkfs /dev/sda
git 写操作|命中:git push
规则不是按命令名一刀切
到这里还有一个关键选择:检测到某个命令,不等于它所有用法都危险。
以 rm 为例,全拦会让删除 node_modules 也反复弹窗;全放又会漏掉家目录和 Git 历史。因此规则检查的是删除目标 :先展开 ~、$HOME,再按当前工作目录归一化路径,最后判断它是否属于危险范围:
- 根目录
/、当前目录、上级目录 - 家目录
/etc、/usr这些系统目录树- 路径里带
.git的(早年一条递归删除,把含.git的项目连历史一起删光过) - 命令替换
...、$(...):算不出最终路径,一律按危险处理
bash
rm -rf node_modules dist → 放行
rm -rf ~ → 拦(家目录)
rm -rf ./x/.git → 拦(.git)
$TARGET 这种运行时才知道值的变量算不了,按普通目标放------故意留的口子,要堵死得真跑 shell,不值当。
其他规则也按实际意图区分:
- 敏感文件看路径:
cat .env、xxd ~/.ssh/id_rsa会触发,普通文件不会 - 环境变量看用法:裸
env、printenv会输出全部变量;env X=1 cmd只是给子进程赋值 - Git 和 systemctl 看子命令:
git log、systemctl status放行,git push、systemctl restart才进入决策
所以最终原则不是"看到危险命令名就拦",而是:先确认它处在可执行位置,再根据参数判断这次调用的真实意图。
AST 版:让 Shell 结构自己说话
正则版再怎么补,本质上还是在字符串上猜 Shell 语法。比如下面两条看着都有 | bash,实际含义完全不同:
bash
curl x | bash # 真管道:deny
echo a \| bash # 转义后的字面量:allow
heredoc 也一样,正文里出现 reboot 不代表它会作为命令执行。
于是我又加了一个 AST 入口:deny-compound-bypass-ast.ts
它用 web-tree-sitter 加载 tree-sitter-bash,把命令拆成 command、pipeline、redirect、process_substitution 等节点,再在真正的命令节点上做同一套安全分类。
AST 版不是另抄一套规则。仓库现在按职责拆开:
text
.claude/hooks/
├── deny-compound-bypass.ts # 零依赖正则入口
├── deny-compound-bypass-ast.ts # AST 优先、正则兜底入口
├── deny-compound-bypass.test.ts # 两个入口共用的端到端测试
└── lib/
├── ast-engine.ts # tree-sitter-bash 遍历与分类
├── regex-engine.ts # 正则扫描
├── runtime.ts # 输入解析、Claude/Codex 决策与输出
├── shell.ts # Shell 分词、引号、命令起点
├── paths.ts # 敏感路径与危险 rm 目标
├── git.ts # Git 写操作与只读白名单
├── reasons.ts # 提示文案
└── types.ts # 共享类型
AST 初始化、WASM 缺失、语法错误或超长命令都不会让 hook 直接失效:AST collector 返回不可用后,入口自动调用 regex-engine.ts。这是故意的 fail-closed------宁可退回已有的偏严判断,也不因 parser 出问题而静默放过。
安装依赖、跑两版测试:
bash
cd ~/.claude/hooks
bun install
bun run deny-compound-bypass.test.ts
要启用 AST 版,只需把注册命令中的入口换掉:
text
bun run ~/.claude/hooks/deny-compound-bypass-ast.ts
当前我的 Claude Code 和 Codex 配置仍注册正则入口;AST 版先作为可选的更准确实现保留。
注册:三家各挂各的
Claude Code ------ ~/.claude/settings.json。 Bash(*)、Read(*) 设成 allow,判断整个交给 hook;PreToolUse 拦命令,PostToolUse 做写后格式化。matcher 里用竖线把工具名合着写就行:
json
{
"permissions": { "allow": ["Bash(*)", "Read(*)"], "defaultMode": "bypassPermissions" },
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|Read", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/deny-compound-bypass.ts" }] }
],
"PostToolUse": [
{ "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/post-write-code.ts" }] }
]
}
}
Codex ------ ~/.codex/hooks.json。 它没有 Read matcher,PreToolUse 只挂 Bash;写文件那步 Codex 用的是 apply_patch,走 PostToolUse:
json
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/deny-compound-bypass.ts" }] }
],
"PostToolUse": [
{ "matcher": "^apply_patch$", "hooks": [{ "type": "command", "command": "bun run ~/.claude/hooks/post-write-code.ts", "timeout": 30 }] }
]
}
}
.env 这类敏感文件,Codex 没法靠 hook 的 Read 拦,得在 ~/.codex/config.toml 的 permission profile 里做 filesystem deny。
PostToolUse 是什么? 文件修改完成后的 Hook,写完自动跑 ESLint + LSP 修。两家喂来的入参还不一样:
- Claude Code :直接给改动的
file_path,单个文件。 - Codex :给的是
apply_patch的 patch 文本,得解析里头的*** Add/Update File:才知道改了哪些(可能多个)。
归一成一份文件列表,下游一套逻辑就够。(还有个坑 #32667:Codex 的 PostToolUse hook 不读 stdin 会 broken pipe,靠 await Bun.stdin.text() 读到 EOF 正好躲过。)
OpenCode ------ ~/.config/opencode/opencode.jsonc。 它接不了这套 hook,好在自己就带权限系统和 formatter,等于把"拦命令"和"写后格式化"两件事内建了:权限直接写规则,格式化按扩展名绑命令。
jsonc
{
"permission": {
"bash": { "*": "allow", "rm *": "ask", "git push*": "ask", "mkfs *": "deny" },
"read": { "*": "allow", ".env*": "ask", "~/.ssh/**": "ask" }
},
"formatter": {
"eslint": { "command": ["eslint", "--fix", "$FILE"], "extensions": [".ts", ".tsx", ".vue"] }
}
}
复合命令它靠 tree-sitter 自己拆;只是 git -C 还得在 permission.bash 里补一条更具体的规则。
实际效果
bash
# 拦:sudo + 环境变量双前缀,照样抓
$ whoami && X=1 sudo -i fdisk -l
→ 磁盘命令|命中:X=1 sudo -i fdisk -l
# 拦:只有危险目标才拦,dist 这种放行
$ pnpm i && rm -rf ./legacy/.git
→ 危险删除|命中:rm -rf ./legacy/.git
# 放:引号里的 reboot 是数据不是命令
$ strings ./bin | grep -iE "reboot|halt"
→ 正常执行
正则入口仍有几处填不平的边界:
echo a \| bash------转义的\|是字面竖线,会误当管道拦掉- heredoc 里的文本被当命令
$TARGET rm -rf------变量运行时才有值,算不出目标
前两个偏严(错拦,但安全),AST 版已经能正确放行;最后一个依赖运行时变量值,静态 AST 同样算不出来。两种入口如何选很直接:看重零依赖和启动速度就用正则版,看重 Shell 结构准确性就用 AST 版。
完整代码都在我的 dotfiles(beixiyo/dotfiles):
入口:正则版 deny-compound-bypass.ts、AST 版 deny-compound-bypass-ast.ts;
核心实现:lib/(AST / 正则引擎、运行时决策、Shell / Git / 路径规则)+ 两版共用测试+ package.json;
写后格式化 post-write-code.ts;
注册与各家配置 .claude/settings.json、.codex/hooks.json、opencode.jsonc。
参考链接
Claude Code
- 权限配置 --- 三层权限规则、Bash glob 匹配
- Hooks 机制 --- Hook 生命周期、
permissionDecision输出格式 - Settings --- settings.json 配置
Codex (OpenAI)
- 验证版本:
codex-cli 0.144.5,ask仍不支持(最后复核 2026-07-18) - 早期在
0.141.0首次验证;下列 Issue / PR 状态由 GitHub CLI 于 2026-06-19 查询,后续可能变化 - Hooks 文档 --- Hook 事件、配置位置、matcher 支持范围;
Read不是 Codex matcher - Schema 源码 ---
PreToolUsePermissionDecisionWire枚举里有ask(固定在 commit1e20272) - 运行时解析源码 --- 运行时把
permissionDecision:ask判成 unsupported(allow也仅在updatedInput场景接受) - Issue #27833(OPEN)---
PreToolUse deny对apply_patch不生效,写入类防护只能靠 Bash - Issue #32667(OPEN)--- PostToolUse hook 不读 stdin 会 broken pipe
- Issue #28437(OPEN)--- 请求支持
PreToolUse permissionDecision: ask - Issue #25555(OPEN)--- schema 允许但 parser 拒绝的值
- PR #20702、PR #20756、PR #26422(均 CLOSED,未 merge)--- 更早的 ask / schema 对齐尝试
OpenCode
- 验证版本:OpenCode
v1.17.8,dev分支 commit355a0bcf5bb5e6c7baa271a4b2439a40f286e55d,验证日期 2026-06-19 - Issue / PR 状态由 GitHub CLI 于 2026-06-19 查询,后续可能变化
- 权限文档 ---
allow/ask/deny;read默认 deny.env - Plugin 文档 ---
tool.execute.before、permission.asked等事件,但不是 Claude Code Hook 协议 - 权限源码 ---
evaluate()用最后匹配规则,默认ask - 权限 schema ---
Effect = allow | deny | ask - Shell 工具源码 ---
tree-sitter-bash/tree-sitter-powershell逐 command node 收集 permission pattern - Shell 权限测试 --- 覆盖
echo foo && echo bar拆成多个 pattern - Bash arity 源码 ---
gitalways pattern 按 2 个 token 生成前缀,git -C ...需额外小心 - PR #6319(MERGED)--- Permission rework
- Hook 兼容性 Issue #12472(OPEN)--- 请求兼容 Claude Code 的
PreToolUse/PostToolUse/StopHook 协议 - permission.ask Issue #7006(OPEN)--- Plugin SDK 定义了
permission.ask,但权限流程没触发它
Shell 解析
- Bash 引用规则 --- 单/双引号转义差异的权威说明
- tree-sitter-bash --- 想 100% 准确时的解析器方案