一个 Hook 堵住 Claude Code、Codex 的大部分权限"漏洞"

一个 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 这样连推了几个远程仓库

说到底,前缀匹配只认命令的第一个词。危险的东西挪到第二句,或者在命令名前面垫个 cdgit -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 不适合写逻辑。

  1. 解析 JSON 得拖个 jq(又多一个依赖),匹配靠 grep -qE,几个条件判断写出来就是一坨 if [ ... ]; then ... fi 嵌套。维护一周,你自己都不想再看。

  2. 它还有茴香豆的 N 种写法------同一件事好几种姿势,每种都埋着雷。赋值必须写死 x=1,你敢加个空格写成 x = 1,它就把 x 当命令去执行了;

  3. 判断字符串空不空,[ ][[ ]]test 随你挑,但是 [ $x = foo ] 里变量忘了加引号、值一为空就直接语法错。

  4. 引号更是重灾区:单引号里什么都不解析、连转义一个单引号自己都做不到,双引号里 $、反引号、\ 又各有各的脾气,再加上 $'...'$"..." 这种你八成没见过的写法。


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-sittertree-sitter-wasms

类型标注、正则字面量这些也是现成的。hook 每条命令都要跑一遍,冷启动这点差距是实打实的体验------所以运行时值得挑一下。

打磨脚本

先讲默认的正则入口。它要解决的不是"有没有出现危险单词",而是三个更具体的问题:

  1. 这个词真的是命令,还是引号里的普通文本?
  2. 这个命令前面套了环境变量、sudo 或复合命令后,还能不能识别?
  3. 拦截之后,能不能直接告诉我究竟是哪一段有问题?

最初的规则很直白:扫描整条命令,只要出现 mkfs| bashgit 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

这里的 reboothalt 都是搜索文本,| 是 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 -cbash -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 .envxxd ~/.ssh/id_rsa 会触发,普通文件不会
  • 环境变量看用法:裸 envprintenv 会输出全部变量;env X=1 cmd 只是给子进程赋值
  • Git 和 systemctl 看子命令:git logsystemctl status 放行,git pushsystemctl 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,把命令拆成 commandpipelineredirectprocess_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.tsAST 版 deny-compound-bypass-ast.ts

核心实现:lib/(AST / 正则引擎、运行时决策、Shell / Git / 路径规则)+ 两版共用测试package.json

写后格式化 post-write-code.ts

注册与各家配置 .claude/settings.json.codex/hooks.jsonopencode.jsonc

参考链接

Claude Code

Codex (OpenAI)

  • 验证版本:codex-cli 0.144.5ask 仍不支持(最后复核 2026-07-18)
  • 早期在 0.141.0 首次验证;下列 Issue / PR 状态由 GitHub CLI 于 2026-06-19 查询,后续可能变化
  • Hooks 文档 --- Hook 事件、配置位置、matcher 支持范围;Read 不是 Codex matcher
  • Schema 源码 --- PreToolUsePermissionDecisionWire 枚举里有 ask(固定在 commit 1e20272
  • 运行时解析源码 --- 运行时把 permissionDecision:ask 判成 unsupported(allow 也仅在 updatedInput 场景接受)
  • Issue #27833(OPEN)--- PreToolUse denyapply_patch 不生效,写入类防护只能靠 Bash
  • Issue #32667(OPEN)--- PostToolUse hook 不读 stdin 会 broken pipe
  • Issue #28437(OPEN)--- 请求支持 PreToolUse permissionDecision: ask
  • Issue #25555(OPEN)--- schema 允许但 parser 拒绝的值
  • PR #20702PR #20756PR #26422(均 CLOSED,未 merge)--- 更早的 ask / schema 对齐尝试

OpenCode

  • 验证版本:OpenCode v1.17.8dev 分支 commit 355a0bcf5bb5e6c7baa271a4b2439a40f286e55d,验证日期 2026-06-19
  • Issue / PR 状态由 GitHub CLI 于 2026-06-19 查询,后续可能变化
  • 权限文档 --- allow/ask/denyread 默认 deny .env
  • Plugin 文档 --- tool.execute.beforepermission.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 源码 --- git always pattern 按 2 个 token 生成前缀,git -C ... 需额外小心
  • PR #6319(MERGED)--- Permission rework
  • Hook 兼容性 Issue #12472(OPEN)--- 请求兼容 Claude Code 的 PreToolUse / PostToolUse / Stop Hook 协议
  • permission.ask Issue #7006(OPEN)--- Plugin SDK 定义了 permission.ask,但权限流程没触发它

Shell 解析

相关推荐
kyriewen1 小时前
我用了三周Claude Code Skills——总结出5条铁律,第3条最反直觉
前端·ai编程·claude
孤狼GPT1 小时前
ChatGPT、Codex与Pro:AI开发正在从“单工具竞争”走向“系统协同”
chatgpt·ai编程·codex·chatgpt pro·系统协同
东小西3 小时前
第9篇:《AI终于记住我是谁了:多轮对话的上下文记忆与压缩》
openai·ai编程
张彦峰ZYF3 小时前
从 Claude 到 Mythos:Anthropic 模型体系、对齐路线与企业级智能体实践分享
人工智能·claude·ai 安全·claude code·fable 5·mythos 5·企业级 ai
深蓝AI5 小时前
Grok Build 实战:xAI 开源编码智能体 CLI,原生 MCP 打通工具链
aigc·ai编程
小溪彼岸5 小时前
Claude HUD一款好用的Claude Code状态栏插件
claude
Jackson__5 小时前
AI Agent 的能力从哪里来?一文讲清后训练、上下文学习和外部能力
前端·agent·ai编程
神奇霸王龙6 小时前
Gemini CLI 中转站配置使用教程
人工智能·ai·ai作画·aigc·ai编程·gemini·goolge
KaneLogger7 小时前
花了2天写了个全平台的技能管理工具
aigc·agent·ai编程