Claude Code 10 月 1 日上线了 Mods,可以用几十行 JavaScript 拦截、改写 Claude 的工具调用。我用它给自己的 MySQL MCP 和 Maven 发布命令加了一道闸,完整代码和踩坑都在下面。
适用版本:Claude Code CLI ≥ 2.1.287(本文 2.1.299 实测);
e.isReadOnly等字段要 2.1.304+。
一、为什么我要写 Mod
我日常让 Claude Code 连着好几个 MySQL 库(通过一个叫 mysql-mcp 的 MCP Server),查数据、核对报表都靠它。查询没问题,怕的是写操作:哪天它为了"清理测试数据"跑一句不带 WHERE 的 DELETE FROM,测试库就要重新造数了。
之前我用 settings 里的 PreToolUse hook 做拦截,写个 shell 脚本读 stdin 的 JSON,再用 jq 解析、grep 匹配。能用,但有两个问题:
- 脚本是独立进程,每次工具调用都要起一个,拦截逻辑一复杂就难维护
- 只能"放行 / 拒绝",想弹个选项问我一下、或者统计今天拦了几次,就很别扭
Mods 正好补上这块。官方文档的定义很直白:mod 是一个插件,里面是一组 JS/TS 事件处理函数,Claude Code 在自己的进程里调用它们。它能看到并改写工具调用、提示词、模型请求,还能往终端里加面板和按钮。
这篇文章适合:已经在用 Claude Code、写过一两个 hook 或 skill,想把"约束 AI 别乱来"这件事做得更细的后端同学。
二、Mods、settings hook、skill、MCP 怎么选
四样东西功能有重叠,我按官方对比表加上自己的理解整理了一下:
| Mod | Settings hook | Skill | MCP Server | |
|---|---|---|---|---|
| 是什么 | 插件里的 JS/TS 函数,在 Claude Code 进程内运行 | 在生命周期事件上跑的 shell 命令 / HTTP 请求 | 一份 SKILL.md 说明书 | 给 Claude 提供工具的外部进程 |
| 能改什么 | 工具调用、提示词、命令、模型请求、界面 | 放行/拦截、改工具参数和结果 | Claude 知道什么、怎么做 | Claude 有哪些工具 |
| 能画界面吗 | 能 | 不能 | 不能 | 不能 |
| 用什么写 | JavaScript / TypeScript | 任意脚本 + settings.json | Markdown | 任意语言 |
我的选择原则:
- 已经有现成脚本,只想拦一下 → settings hook 就够
- 反复往对话里粘同一段规范 → skill
- 要连外部系统 → MCP
- 要根据运行时状态判断、要弹窗问人、要统计、要画东西 → mod
⚠️ Mod 不跑在沙箱里。它和 Claude Code 同权限,能读写你能读写的所有文件、读环境变量里的密钥、发网络请求。别人分享的 mod,装之前先看代码,或者用下文的
claude plugin validate列出它挂了哪些事件、调用了哪些 API。
三、先跑通一个最小 Mod
3.1 目录结构
Mod 本质是插件,最小三个文件:
java-guard/
├── .claude-plugin/
│ └── plugin.json # 插件清单
└── hooks/
├── hooks.json # 指向代码文件
└── java-guard.mjs # hooks 模块,真正的逻辑
plugin.json:
{
"name": "java-guard",
"version": "0.1.0",
"description": "Guard risky SQL and shell commands for Java backend projects",
"author": { "name": "aiksyuan" }
}
hooks/hooks.json,一个 mod 只能有一个模块:
{
"modules": ["./java-guard.mjs"]
}
3.2 hooks 模块的骨架
模块导出一个 register(on) 函数,Claude Code 加载 mod 时调用一次。on(事件名, [匹配器], 处理函数) 注册 hook,处理函数拿到三个参数:
| 参数 | 含义 |
|---|---|
$ | mods API,读文件、跑进程、弹窗、注册命令都通过它 |
e | 事件对象,深度冻结,直接赋值会抛异常 |
next | 链上的下一个处理器,最后一个就是 Claude Code 自己的行为 |
先写一个只打日志的版本感受一下:
// hooks/java-guard.mjs —— 每次 Claude 调工具前在对话里打一行灰色日志
export function register(on) {
on('tool.call', async ($, e, next) => {
$.ui.log('Claude is about to use ' + e.tool)
// 原样放行
return next(e)
})
}
在项目目录里带上插件启动(终端执行):
# 只在这次会话里加载该目录下的插件
claude --plugin-dir ./java-guard
插件目录是被监听的,改完 .mjs 保存就会热重载,不用重启会话。确认加载成功:会话里输入 /plugin,标签栏下方会有一行类似 1 mod active · java-guard 的灰字。
3.3 hook 的三种玩法
整个 mod 体系就围绕 next 转,理解这三种写法就够用了:
// 1. 观察:做点事,然后原样往下传
on('tool.call', async ($, e, next) => {
const result = await next(e) // 等工具跑完
$.ui.log(e.tool + ' finished') // 事后记录
return result
})
// 2. 改写:传一个修改过的副本给 next
on('prompt.submit', async ($, e, next) => {
return next({ ...e, text: e.text.trim() })
})
// 3. 代答:不调用 next,直接返回结果,后面的处理器和 Claude Code 自己的逻辑都不会执行
on('tool.call', { tool: 'Bash' }, async () => {
return { deny: 'Bash is turned off in this project. Use the file tools.' }
})
第二个参数是匹配器,字段可以是字符串、数组或正则:
on('tool.call', { tool: 'Bash' }, hook) // 只匹配 Bash
on('tool.call', { tool: ['Edit', 'Write'] }, hook) // Edit 或 Write
on('tool.call', { tool: /^mcp__mysql-mcp__/ }, hook) // 某个 MCP Server 的全部工具
MCP 工具的名字是 mcp__<server名>__<工具名>,我的 MySQL MCP 叫 mysql-mcp,所以执行写操作的工具全名是 mcp__mysql-mcp__execute。工具参数直接挂在 e 上,Bash 是 e.command,这个 MCP 的 execute 工具参数叫 sql,就是 e.sql。
💡 不确定某个 MCP 工具的参数名,先写一个只
$.ui.log(JSON.stringify(e))的观察 hook,让 Claude 调一次,日志里就能看到完整字段。
四、实战:Java 后端守卫插件
需求很具体:
- 通过 MySQL MCP 执行的 SQL,遇到
DROP TABLE、TRUNCATE TABLE,以及不带 WHERE 的DELETE/UPDATE,直接拒绝 - Bash 里出现
mvn deploy、git push --force/-f、rm -rf,先弹窗问我 - 我的提示词里提到"发布 / 上线 / release"时,自动把当前分支名告诉 Claude
- 加一个
/guard-stats命令,看本次会话拦了几次
完整的 hooks/java-guard.mjs:
// java-guard: SQL and shell guard for Java backend projects
// 绝不允许通过 MCP 执行的语句
const DANGEROUS_SQL = /\b(drop\s+(table|database)|truncate\s+table)\b/i
// 不带 WHERE 的 DELETE / UPDATE
const NO_WHERE = /^\s*(delete\s+from|update)\b(?![\s\S]*\bwhere\b)/i
// 需要人工确认的 shell 命令
const RISKY_SHELL = /\bmvn\b.*\bdeploy\b|\bgit\s+push\b.*(--force|\s-f\b)|\brm\s+-rf?\b/
// 模块级变量,所有 hook 共享
let blocked = 0
export function register(on) {
// 1. 拦截发往 MySQL MCP 的危险 SQL
on('tool.call', { tool: /^mcp__mysql-mcp__/ }, async ($, e, next) => {
const sql = e.sql ?? ''
if (DANGEROUS_SQL.test(sql) || NO_WHERE.test(sql)) {
blocked += 1
$.ui.log('blocked SQL: ' + sql.slice(0, 80))
// deny 的文字会作为工具结果给 Claude 看,写成它能照着做的指令
return { deny: 'This SQL is blocked by java-guard (DROP/TRUNCATE or DELETE/UPDATE without WHERE). Show the SQL to the user and let them run it manually.' }
}
return next(e)
}).catch(async ($, e, next) => {
// fail-closed:守卫本身出错时,宁可不执行
if (next.called) return next(e)
return { deny: 'java-guard failed (' + next.error.kind + '), so this SQL was not run.' }
})
// 2. mvn deploy / 强推 / rm -rf 先问人
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (!RISKY_SHELL.test(e.command)) return next(e)
// 默认答案是拒绝,没人回答就不执行
let answer = 'Refuse'
try {
answer = await $.ui.ask('java-guard: run this command? ' + e.command, ['Run it', 'Refuse'])
} catch {
// 用户关掉了对话框,或者是 claude -p 这种没人能回答的场景
}
if (answer !== 'Run it') {
blocked += 1
return { deny: 'The user declined this command. Ask before trying a different approach.' }
}
// 放行后,Claude Code 自己的权限检查照常执行
return next(e)
})
// 3. 提到发布时,把当前分支作为额外上下文给 Claude
on('prompt.submit', async ($, e, next) => {
if (!/发布|上线|release|deploy/i.test(e.text)) return next(e)
const git = await $.process.run(['git', 'branch', '--show-current'])
// 不在 git 仓库里就跳过
if (git.exitCode !== 0) return next(e)
return next({ ...e, context: [...(e.context ?? []), 'Current git branch: ' + git.stdout.trim()] })
})
// 4. 注册 /guard-stats 命令
on('session.start', async ($, e, next) => {
const result = await next(e)
try {
// immediate: true 让命令在 Claude 干活时也能直接跑
await $.command.register({ name: 'guard-stats', description: 'Show how many calls java-guard blocked', immediate: true })
} catch {
// 名字被占用时 register 会抛异常,吞掉,别影响其他守卫
}
return result
})
on('command.run', { command: 'guard-stats' }, async () => {
return { text: 'java-guard blocked ' + blocked + ' call(s) in this session.' }
})
}
正则我先用 node 单独跑了一遍,结果如下:
| 输入 | 是否拦截 |
|---|---|
DROP TABLE t_user | 拦 |
delete from t_order | 拦 |
DELETE FROM t_order WHERE id=1 | 放 |
update t set a=1 | 拦 |
UPDATE t SET a=1 换行 WHERE id=2 | 放 |
mvn clean deploy -DskipTests | 问 |
mvn test | 放 |
git push -f origin main | 问 |
git push origin feat | 放 |
4.1 用 validate 检查 Mod
写完先别急着启动,claude plugin validate 会静态分析模块,列出挂了哪些事件、调了哪些 API:
# 在插件目录里执行
claude plugin validate .
我这份代码在 2.1.299 上的输出:
❯ ./java-guard.mjs hooks: tool.call{tool=/"^mcp__mysql-mcp__"/}, tool.call{tool=Bash}, prompt.submit, session.start, command.run{command=guard-stats}
❯ ./java-guard.mjs answers its own command: command.run{command=guard-stats}
❯ ./java-guard.mjs gating hook with .catch: tool.call{tool=/"^mcp__mysql-mcp__"/}
❯ ./java-guard.mjs gating hook without .catch: tool.call{tool=Bash}
❯ ./java-guard.mjs gating hook without .catch: prompt.submit
❯ ./java-guard.mjs calls: $.command.register, $.process.run, $.ui.ask, $.ui.log
✔ Validation passed
注意 gating hook without .catch 这两行,它在提醒你:这两个能拦东西的 hook 没写失败兜底。这正是下一节要讲的坑。装别人的 mod 之前跑这个命令也很有用,calls: 那行出现 $.http.fetch 而插件描述里只说"改个颜色",就该警惕了。
五、踩坑与进阶
5.1 守卫默认是 fail-open 的
这是我觉得最需要注意的一点。一个 hook 抛异常、超时、返回值格式不对,如果它还没调用 next,Claude Code 会跳过它,让下一个处理器接着跑。对普通 hook 这是好设计,坏一个 mod 不至于整个会话挂掉;对守卫类 hook 就是漏洞:守卫一报错,SQL 照样执行。
所以拦截类 hook 一定要挂 .catch,上面代码里的写法就是官方推荐的 fail-closed 模式:
next.called为 false:守卫还没放行就挂了 → 返回denynext.called为 true:已经放行、工具已经跑了 → 返回原结果,不会重复执行
Bash 那个 hook 我没加,因为它失败时最坏情况是"不弹窗",后面还有 Claude Code 自己的权限确认兜着。数据库那个没有第二道防线,必须加。
⚠️ 等待时间也有讲究:在
$.ui.ask这类 mods API 调用里等用户,不计入 hook 的超时;在你自己的 Promise 里await会计入。超时被跳过,被拦住的命令就放行了。
5.2 tool.call 和 tool.check 的区别
刚上手容易混。我的理解:
tool.call | tool.check | |||
|---|---|---|---|---|
| 时机 | 工具即将执行,next(e) 里会先走权限检查再执行工具 | Claude Code 做权限决策那一刻,在 PreToolUse 之后、弹权限框之前 | ||
| 参数位置 | 直接在 e 上,如 e.command | 在 e.input 上,如 e.input.command | ||
| 返回值 | { deny }、{ result } 或 next 的结果 | `{ decision: 'allow' | 'ask' | 'deny', reason }` |
| 适合 | 拦截、改参数、重试、代答、隐藏结果 | 根据当时状态决定要不要弹权限框 |
比如"在 main 分支上禁止 git push",用 tool.check 更贴切:
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
// 先拿 Claude Code 自己的决策
const decided = await next(e)
if (!e.input.command.includes('git push')) return decided
const branch = await $.process.run(['git', 'branch', '--show-current'])
if (branch.stdout.trim() !== 'main') return decided
return { decision: 'deny', reason: 'Push from a branch other than main' }
})
命令和路径都是固定的,就别写代码了,直接用权限规则 Bash(npm test) 这种。
2.1.304 起 tool.check 事件多了 e.isReadOnly,2.1.304 还加了 decider: "person",表示这个 ask 只能由人在权限框里批,没人可问时直接拒。我本机是 2.1.299,这两个没实测。
5.3 managed settings 下的优先级
公司如果用 managed settings 或 Team/Enterprise 账号登录,内置的 sec-default 守卫会最先加载,用户自己装的 mod 默认越不过你的 ask 规则和 PreToolUse hook。个人账号下则相反,mod 能把 ask 规则要弹框的调用直接批掉。写"自动批准"类 mod 时要清楚自己在哪种环境下。
链上的顺序大致是:组织的 mod → 你装的 mod → 组织 append 的 mod → Claude Code 内置的其他 mod。先加载的在最外层,最先看到事件、最后拿到结果。
5.4 状态别放模块变量里(如果你在乎热重载)
我上面用模块级 let blocked = 0 计数,简单够用,但热重载会把它清零。需要扛住重载的状态放 $.state(宿主持有),需要跨会话保留的放 $.store。官方 token-weather 示例就是用 $.state.get/set 存历史读数的。
5.5 hooks 模块里没有 Node API
模块里不能 import fs,也没有 setTimeout。文件用 $.fs,进程用 $.process.run(参数是数组,不走 shell,默认 30 秒超时,超时会 reject,记得 try/catch),网络用 $.http.fetch,定时用 $.clock.every。所有这些调用本身也是事件,所以组织可以写 mod 限制别的 mod 能碰什么。
六、分享给团队
本地用顺手了,可以做成 marketplace 给同事装。建一个目录放 .claude-plugin/marketplace.json:
{
"name": "team-mods",
"owner": { "name": "aiksyuan" },
"plugins": [{ "name": "java-guard", "source": "./java-guard" }]
}
本地安装(shell 里执行):
# 把 marketplace 目录注册进来,再按 插件名@marketplace名 安装到用户级
claude plugin marketplace add ./team-mods
claude plugin install java-guard@team-mods --scope user
推到 Git 仓库后,同事在会话里:
/plugin marketplace add your-org/team-mods
/plugin install java-guard@team-mods
/reload-plugins
七、速查表
| 场景 | 写法 |
|---|---|
| 加载本地插件调试 | claude --plugin-dir ./my-mod |
| 静态检查 | claude plugin validate ./my-mod |
| 跑 mod 的单元测试 | claude plugin test ./my-mod |
| 看当前加载了哪些 mod | 会话里 /plugin |
| 临时关掉所有自装 mod | claude --safe-mode |
| 永久关掉自装 mod | ~/.claude/settings.json 里 "disableAllHooks": true |
| 拒绝工具调用 | 不调 next,返回 { deny: '原因' } |
| 给 Claude 加隐形上下文 | prompt.submit 里 next({ ...e, context: [...] }) |
| 拦截用户的提示词 | 返回 { drop: '原因' } |
| 弹窗问人 | await $.ui.ask(问题, [选项...]) |
| 失败即拒绝 | on(...).catch(...) 里判断 next.called |
八、常见问题
Q:mod 写好了但完全没反应?
先 /plugin 看有没有 mods active 那行。没有的话检查三处:Claude Code 版本是否 ≥ 2.1.287;hooks.json 里 modules 路径是不是相对 hooks/ 目录写的;是不是在 Desktop 的 WSL 会话里,WSL 会话不加载插件。
Q:早期体验时设置过 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS,要删吗?
要删。2.1.287 以后会忽略这个变量,设成 0 也关不掉 mod,留着只会误导自己。
Q:claude -p 跑批处理时,$.ui.ask 会卡住吗?
不会,没人可问时 $.ui.ask 直接 reject。所以 catch 里一定要落到安全的默认值,我上面是默认 Refuse。
Q:同一个事件能注册两次吗?
同一个事件不带匹配器注册两次,模块会加载失败,报 on("session.start") is registered twice without a matcher。session.start 要做的事全放在一个 hook 里。
Q:mod 能改权限确认框的内容吗?
不能。界面大部分都能改,唯独权限提示框不让动,这是故意的。
参考链接
- Customize Claude Code with mods(官方发布文章,2026-10-01)
- Mods overview - Claude Code Docs
- React to events with a mod
- Use the mods API
- Claude Code changelog
- 官方示例 mods:token-weather / blast-radius / replay-theater
如果你也给 Claude Code 写过 mod,欢迎评论区贴出来交流。