Claude Code Mods Put Hooks Inside the Process, and That Rewrites Your Threat Model

·12 min read·Evergreen Tools Team

Anthropic shipped Mods in Claude Code v2.1.287, and the first thing most people notice is that you can draw your own UI. The interesting part is not the drawing. It is where the code runs. Settings hooks, skills, status lines and MCP servers all work from outside the CLI: each one runs a script, or hands Claude text or tools. A mod is a plugin made of JavaScript or TypeScript event handlers that Claude Code calls inside its own process, documented for v2.1.290 on the CLI and Desktop and v2.1.299 for the VS Code extension. That one sentence explains both what you can now build and why the risk calculation changed.

Terminal and code on a dark screen

A mod is not a script beside the agent. It is code inside the agent.

1. What a Mod Actually Is: One Directory, One register Function

The documented shape is plain. A mod is a plugin directory: .claude-plugin/plugin.json is the plugin manifest, and mods add no required fields to it. hooks/hooks.json holds a modules array pointing at the entry point, hooks/register.js — which exports register(on, options), where options carries the manifest's userConfig values with defaults filled in. All the logic lives in on: it takes an event name, an optional matcher that filters on the event's fields such as { tool: 'Bash' }, and the hook itself, with the signature async ($, e, next) => next(e). The $ argument is the mods API. e is the event input, deeply frozen plain data — copy it if you want to change it. next is the next handler in middleware order: it runs the hooks after this one, then Claude Code's own behaviour, and resolves to the event's result. Written that way a mod is not a script sitting beside the agent. It is a link in the request chain.

# 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"]
}

2. What $ Exposes: Practically the Whole Agent

This is the table worth ten quiet minutes. The mods API is grouped by namespace: $.ui gives you open, close, panes, toast, notify, ask, selection, so a mod can draw and can ask questions. $.tool registers, calls, checks and lists tools. $.model completes, forks and classifies. $.prompt submits, reads and fills. $.turn aborts. $.session hands over messages, cwd, model, turns, repo and usage. Then there is $.fs, $.env, $.config, $.settings, $.command and $.agent. In other words, a mod can read and write files, start processes, make network requests, reroute a request to a different model, and submit a prompt as the user. The docs say this plainly rather than burying it: a mod is code that runs with your permissions, it can read and write your files, start processes and make network requests, so install mods only from authors and marketplaces you trust. That is not a disclaimer. It is the design.

// 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
  });
}

3. Order and Time Are Explicit, and Ignoring Them Makes Everything Slow

The order mods run in is decided by tier: prepend, user, append, builtin. next.origin tells you who fired the event, with Claude Code itself reporting as { plugin: 'engine', tier: 'core' }. next.to(e, tier) skips ahead to a later tier — for example past the mods a user installed — but only a mod in prependPlugins or appendPlugins may call it. Time is explicit too: next.budget.ms is the whole limit and next.budget.remainingMs is what is left right now. Those two fields have to be used. A hook that stalls for two seconds does not make one hook slow; it makes the tool call slow, and the user experiences that as Claude Code being sluggish today. There is also next.error, available in a .catch handler only, whose kind is throw or timeout — plus a re-entry kind for when the event came from inside a mods API call made by that hook, which is the shape of an accidental infinite loop.

Network traffic and event streams

Every mods API call is also an event other mods can intercept

// 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

4. The Threat Model: Code Inside, Trust Outside

The older extension model was a separate process talking to the agent over stdio or HTTP. That model had a clean attack surface, because the process boundary was the trust boundary. A mod dissolves that boundary. It can replace what Claude Code draws itself — a tool call's row, the spinner, the dialog Claude asks questions in. It can hold a tool call while it asks the user something, then answer without running the tool. It can send one request to a different model. In an enterprise, every one of those is a change request. The docs name two organisation-level switches: allowManagedModsOnly, which runs only your organisation's mods and those built into Claude Code while users' settings hooks keep running, and allowModsToOverrideDenyRules. That second name is its own warning. A deny rule a mod can overrule is not a security boundary.

// 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);
});

5. What to Build First, and What Not To

For a first mod in your own repository, pick one that only observes. Call $.session.usage() to get context tokens, window and percent plus cost, and render the number in a pane. The cost of it failing is near zero, and it validates your reading of the API quickly. The first thing not to build is a permission gateway of your own. That is not a capability argument, it is an audit argument: a mod deciding which Bash commands may run hides your security policy inside a TypeScript file that nobody reviewed. The docs are clear that mods are not sandboxed, though the sandbox does restrict direct access. What actually helps is pinning versions, installing only mods whose code you have read, requiring a .catch handler, and putting one question on the review checklist: does this touch $.fs outside the plugin's own directory?

# 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

📌 Frequently Asked Questions

How is a mod different from the settings hooks I already use?

Settings hooks work from outside: you configure a shell command, an HTTP request or a prompt in a settings file and Claude Code runs it. A mod's handler is a function inside the Claude Code process, which is why it can draw UI, restyle what Claude Code draws itself, share state between hooks, and intercept mods API calls. The docs call both kinds hooks and name the settings-file kind a settings hook.

Are mods sandboxed?

No. The documented wording is that a mod is code that runs with your permissions, that it can read and write your files, start processes and make network requests, and that you should install mods only from authors and marketplaces you trust. Installing one is therefore a security decision in itself.

Can a mod read all of my session content?

Through $.session it can reach messages, cwd, repo, model, turns and usage. That access is by design rather than a flaw. The question that matters is whether the mod you installed sends that content somewhere, and it has network access to do so.

How does an organisation restrict mods?

The docs describe organisation-level management. Two switches matter most: allowManagedModsOnly, which only lets mods that count as your organisation's plus the built-in ones run their hooks, while users' settings hooks keep running; and allowModsToOverrideDenyRules, which permits mods to override deny rules and should stay off by default.

Should I write a mod today?

Compare first. Can a settings hook, a skill or an MCP server do the job? The docs tell you to compare them before writing a mod. If one of those three can do it from outside the process, do not move code inside. What genuinely needs a mod is interface work, intercepting and rewriting tool calls, and sharing state across hooks.