Claude Code Mods 开发教程:写一个拦截危险 SQL 和发布命令的插件

0 阅读8分钟

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 怎么选

四样东西功能有重叠,我按官方对比表加上自己的理解整理了一下:

ModSettings hookSkillMCP Server
是什么插件里的 JS/TS 函数,在 Claude Code 进程内运行在生命周期事件上跑的 shell 命令 / HTTP 请求一份 SKILL.md 说明书给 Claude 提供工具的外部进程
能改什么工具调用、提示词、命令、模型请求、界面放行/拦截、改工具参数和结果Claude 知道什么、怎么做Claude 有哪些工具
能画界面吗能不能不能不能
用什么写JavaScript / TypeScript任意脚本 + settings.jsonMarkdown任意语言

我的选择原则:

  • 已经有现成脚本,只想拦一下 → 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 后端守卫插件

需求很具体:

  1. 通过 MySQL MCP 执行的 SQL,遇到 DROP TABLE、TRUNCATE TABLE,以及不带 WHERE 的 DELETE / UPDATE,直接拒绝
  2. Bash 里出现 mvn deploy、git push --force / -f、rm -rf,先弹窗问我
  3. 我的提示词里提到"发布 / 上线 / release"时,自动把当前分支名告诉 Claude
  4. 加一个 /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:守卫还没放行就挂了 → 返回 deny
  • next.called 为 true:已经放行、工具已经跑了 → 返回原结果,不会重复执行

Bash 那个 hook 我没加,因为它失败时最坏情况是"不弹窗",后面还有 Claude Code 自己的权限确认兜着。数据库那个没有第二道防线,必须加。

⚠️ 等待时间也有讲究:在 $.ui.ask 这类 mods API 调用里等用户,不计入 hook 的超时;在你自己的 Promise 里 await 会计入。超时被跳过,被拦住的命令就放行了。

5.2 tool.call 和 tool.check 的区别

刚上手容易混。我的理解:

tool.calltool.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
临时关掉所有自装 modclaude --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 能改权限确认框的内容吗?

不能。界面大部分都能改,唯独权限提示框不让动,这是故意的。


参考链接


如果你也给 Claude Code 写过 mod,欢迎评论区贴出来交流。