Claude Code Mods 把钩子搬进了进程内,你的威胁模型得重画

·阅读约12分钟·Evergreen Tools Team

Anthropic 在 Claude Code v2.1.287 里上线了 Mods,很多人第一眼看到的是「可以自己画界面了」。真正重要的不是能画界面,而是代码跑在哪里。settings hooks、skills、status line、MCP server 都是从外部工作的——各自跑一个脚本,或者给 Claude 塞文本、塞工具。mod 不是,它是一个由 JavaScript / TypeScript 事件处理器组成的插件,Claude Code 在自己的进程内调用它。范围是 CLI 与 Desktop 的 v2.1.290、VS Code 扩展的 v2.1.299。这一句话同时解释了你为什么能做到以前做不到的事,以及你为什么要重新算一遍风险。

暗色屏幕上的终端与代码

mod 不是 agent 旁边的脚本,而是 agent 身体里的代码

一、mod 到底是什么:一个目录,一个 register 函数

官方文档给的形态很朴素:一个插件目录,.claude-plugin/plugin.json 是插件 manifest,mods 不新增任何必填字段;hooks/hooks.json 里写一个 modules 数组,指向 hooks/register.js。这个 register.js 是入口,导出 register(on, options),options 里放 manifest 声明的 userConfig 值,带默认值。真正的逻辑都在 on 里:on 接收事件名、一个可选的 matcher(对事件字段的过滤,比如 { tool: 'Bash' })、以及 hook 函数本身,签名是 async ($, e, next) => next(e)。$ 是 mods API,e 是深度冻结的事件输入——想改就复制一份传给 next,别原地改。next 是下一个处理器,也就是中间件语义:跑完后面的 hook,再跑 Claude Code 自己的行为。写成这样,一个 mod 就不是「旁挂的脚本」,而是请求链上的一环。

# 1) The shape on disk. No required manifest fields are added by a mod.
deploy-guard/
  .claude-plugin/
    plugin.json            # plugin manifest (mods add no required fields)
  hooks/
    hooks.json             # { "modules": ["./register.js"] }
    register.js            # entry point: exports register(on, options)
  types/
    index.d.ts             # only needed if you use $.state or add a namespace

# hooks.json
{
  "modules": ["./register.js"]
}

二、$ 暴露了什么:几乎是整个 agent

这是这篇文档里最需要坐下来读十分钟的表格。$ 按命名空间分组:$.ui 有 open、close、panes、toast、notify、ask、selection,能画也能问;$.tool 能 register、call、check、list;$.model 能 complete、fork、classify;$.prompt 能 submit、read、fill;$.turn 能 abort;$.session 能拿 messages、cwd、model、turns、repo、usage;还有 $.fs、$.env、$.config、$.settings、$.command、$.agent。换句话说,一个 mod 能读写文件、跑命令、发网络请求、改另一个模型的调用、替用户提交提示词。文档自己也说得很直白:mod 是以你的权限运行的代码,它能读写你的文件、启动进程、发起网络请求;只从你信任的作者和市场安装。这不是免责声明,这是设计说明。你把一个 mod 加进仓库,等于把一段代码加进 agent 的进程。

// 2) register(on, options) is the whole entry point.
// on(event, matcher, hook). Return next(e) to pass through,
// { deny: reason } to block, or { value } to answer for the engine.
export function register(on, options) {
  const blocked = options.blockedCommands ?? ["rm -rf /"];

  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
    const cmd = String(e.input?.command ?? "");
    for (const bad of blocked) {
      if (cmd.includes(bad)) {
        return { deny: "deploy-guard: refused a destructive command" };
      }
    }
    return next(e);              // unchanged, continue down the chain
  });
}

三、顺序和时间都是显式的,忽略它们就会变慢

mod 的运行顺序由 tier 决定:prepend、user、append、builtin。next.origin 会告诉你事件是谁发的——Claude Code 自己是 { plugin: 'engine', tier: 'core' }。next.to(e, tier) 可以跳到后面的层级,比如跳过用户装的 mod,但只有 prependPlugins 或 appendPlugins 里的 mod 才能这么干。时间也是显式的:next.budget.ms 是整个时间上限,next.budget.remainingMs 是现在还剩多少。这两个字段必须用起来。一个 hook 卡住 2 秒,不会只让这个 hook 变慢,它会让整个工具调用变慢,而用户只会觉得「今天 Claude Code 有点卡」。文档还提到 next.error,只出现在 .catch 处理器里,kind 是 throw 或 timeout;还有 kind 为 re-entry 的情况——说明这次事件是从 mod 自己的 API 调用里发出来的,别写成死循环。

网络流量与事件流

每一次 mods API 调用,本身也是一个可被拦截的事件

// 3) The $ argument is the mods API. Every namespace method exists,
// and every one of those calls is itself an event.

on("turn.step", async ($, e, next) => {
  const { context, cost } = $.session.usage();
  // context: { tokens, window, percent }  cost: reported spend so far
  await $.ui.toast({
    text: `context ${context.percent}% of ${context.window}`,
    kind: context.percent > 80 ? "warn" : "info",
  });
  return next(e);
});

// The methods you will actually reach for first:
//   $.fs.read / write        $.tool.register / call
//   $.model.complete / classify    $.prompt.submit / read
//   $.session.messages / cwd / repo / authorize
//   $.ui.panes / open / notify / ask / selection

四、威胁模型:进程内的代码,进程外的信任

以前的扩展模型是「外面跑一个进程,用 stdio 或 HTTP 跟 agent 说话」。那种模型下,攻击面边界清楚:进程边界就是信任边界。mod 把这条边界抹了。它能替换 Claude Code 自己画的东西——工具调用的那一行、spinner、提问对话框;它能 hold 住一个工具调用,先问用户一句再决定放不放;它能改请求去另一个模型。这些能力放在企业里,每一条都需要审批。文档给了两个组织级开关的名字:allowManagedModsOnly(只跑属于你组织的 mod 和内置 mod,用户的 settings hooks 照旧)和 allowModsToOverrideDenyRules。第二个名字本身就是一个提醒:能被 mod 覆盖的 deny 规则,不算安全边界。

// 4) Order and time are both explicit. Ignoring them is how a mod
// turns a 30ms tool call into a 3-second one.
//   next.origin.tier : prepend | user | append | builtin
//   next.budget.ms   : total limit, next.budget.remainingMs : left now
//   next.to(e, tier) : skip ahead (append | builtin | core)

on("model.complete", async ($, e, next) => {
  if (next.budget.remainingMs < 50) return next(e);   // do not risk a timeout
  const cheap = await $.model.classify({
    state: e.request?.prompt ?? "",
    choice: "path=trivial,needs-frontier",
  });
  if (cheap.choice === "trivial" && next.origin.tier !== "prepend") {
    return next.to(e, "builtin");    // skip the mods a user installed
  }
  return next(e);
});

五、先做什么,以及先别做什么

如果要在自己仓库里试第一个 mod,我建议挑「只观察、不改」的那种:用 $.session.usage() 拿 context 的 tokens、window、percent 和 cost,在状态区显示一个数字。它的失败代价接近零,而且很快就能验证你对 API 的理解对不对。反过来,先别做的第一件事是「自己写一个权限网关」。理由不是能力问题,是审计问题:一个决定哪些 Bash 命令能跑的 mod,等于把安全策略藏进了一段 TypeScript 里,而没有人 review 过它。文档说 mods 未经沙箱化,但 sandbox 会限制直接访问,真正的做法是:版本锁定、只装你读过代码的 mod、要求 .catch 处理器、以及把「它有没有碰 $.fs 插件目录之外的东西」写进 review 清单。

# 5) The review questions that decide whether a mod ships.
#    A mod is code that runs with your permissions. It can read and
#    write files, start processes, and make network requests.
#    Anthropic's own advice: install mods only from authors and
#    marketplaces you trust.
mod_review:
  - id: deploy-guard
    version: 2.1.4
    pinned: true                  # pin the version, not a range
    reads:
      - ".claude-plugin/plugin.json"
      - "hooks/register.js"
      - "types/index.d.ts"
    questions:
      - "Does it touch $.fs outside the plugin root?"
      - "Does it call $.env.get or read credentials?"
      - "Does it hold a tool call long enough to hit next.budget?"
      - "Does it ship a .catch handler, or will an error kill the turn?"
    org_guard:
      allowManagedModsOnly: true  # org mods + built-ins only

📌 常见问题 FAQ

Mods 和我已经在用的 settings hooks 有什么区别?

settings hooks 是从外部工作的:它在设置文件里配置成一条 shell 命令、一个 HTTP 请求或一段提示词,由 Claude Code 去执行。mod 的 handler 是运行在 Claude Code 进程内的函数,所以它能画界面、重画 Claude Code 自己的界面元素、共享状态,也能拦截 API 调用。文档把两者都叫 hook,设置文件里那种叫 settings hook。

Mods 是沙箱化的吗?

不是。文档的原话是:mod 是以你的权限运行的代码,它可以读写你的文件、启动进程、发起网络请求,只从你信任的作者和市场安装。所以 install 这个动作本身就是一次安全决策。

一个 mod 能拿到我所有的会话内容吗?

通过 $.session,它能拿到 messages、cwd、repo、model、turns、usage 等。这是设计上就给的权限,不是漏洞。问题在于你装的那个 mod 会不会把这些内容发出去——而它能发网络请求。

组织怎么限制?

文档列出了组织级管理能力,其中两个开关是 allowManagedModsOnly(只允许组织自己的 mod 和 Claude Code 内置 mod 跑 hook,用户的 settings hooks 照旧运行)和 allowModsToOverrideDenyRules(允许 mod 覆盖 deny 规则,默认不该开)。

我该现在就写一个 mod 吗?

先对照一遍:你的需求能不能用 settings hook、skill 或 MCP server 解决?文档本身就叫你先比较。这三样都能从外部做完的事,就别急着把代码搬进进程里。真正只有 mod 能做的,是界面层、工具调用拦截与改写、以及 hook 之间共享状态这三类。