A mod runs with your permissions and inside Claude Code’s process, so mistakes in one are more expensive than mistakes in a script. This lesson covers what mods can do to you, how to restrict them, and what experience says to avoid.
Security and governance
A mod can read your files and secrets, see every prompt and tool call, approve tool calls on your behalf, and spend your usage. Before installing one, run claude plugin validate and actually read the calls: and hooks: lines. They tell you what the mod can reach and what it listens to.
When the built-in guard (sec-default) loads, a user’s mod can’t get past deny rules, managed PreToolUse hooks, or managed instructions. That includes a mod’s tool.check hook. sec-default loads outermost on a machine with managed settings, or for a Team or Enterprise organization, unless managed prependPlugins says otherwise. On a personal machine with neither, the documentation describes no such protection, so assume a mod you install can override your own deny rules. (“Managed” means set by an administrator, in settings the user can’t override.) And no mod can change what the permission prompt shows.
What a mod can get past:
askrules, the permission rules that make Claude Code prompt you.PreToolUsehooks that don’t come from managed settings.- The auto-mode classifier, for calls the mod approves.
- Your deny rules, for the mod’s own
$.fsand$.processcalls.
That last one is the surprising one. Deny rules restrict Claude. They don’t restrict a mod that reads files on its own.
For admins, in managed settings:
allowManagedModsOnly: Stops users’ own mods from loading.prependPluginsandappendPlugins: Set where your organization’s mods run relative to users’.disableSideloadFlags: Blocks--plugin-dir.disableAllHooks: Stops all mods and all hooks.
For you: --safe-mode for one session, or "disableAllHooks": true in your settings for every session.
Best practices
- Make guards fail closed: A hook that throws or reaches the host timeout gets skipped, so a guard lets the call through. Handle rejected operations with
.catch()and return a denial, but also race a pending operation against an internal deadline that returns a denial before the host’s 10-second deadline. Use the supported$.clock.sleeptimer, for example a five-second wait mapped to{ deny: "Guard deadline exceeded" }fortool.call;.catch()alone cannot settle a promise that hangs. Test success, rejection, and a never-settling operation. An internal timer still cannot survive a crashed or unloaded mod runtime: enforce invariants that must survive that failure with an external permission rule, managed hook, sandbox, or OS boundary. - Write deny text as an instruction: Claude reads it as the tool’s result, so tell it what to do instead.
- Keep waiting inside
$calls: A hook gets 10 seconds of its own running time. Waiting onnext()or$.ui.askdoesn’t count. Awaiting your own promises does. - Choose storage by how long a value has to last: A module variable is lost on every reload.
$.statelasts the session, and writing to it redraws whatever depends on it for you.$.storepersists across sessions and is shared by all of them. - Reload
$.stateafter/clear:/clear,/resume, and/branch(the commands that empty, reopen, or fork a conversation) reset it, so copy saved values back from$.storeinclassic.SessionStart. - Redraw explicitly for everything else: When something other than
$.statechanges what should be on screen, like a module variable or a value from$.store, call$.ui.invalidate('ui.render'), give every control akey, and checke.surface, because some elements only draw in the terminal or only in the desktop app. - Open panes only when the user asks: A pane your mod opens on its own needs a 144-column terminal. Use
$.ui.toast(a brief on-screen notification) to announce things instead.
Anti-patterns
- Code the validator rejects: Aliasing or destructuring
$, non-literal event names, twosession.starthooks with no matcher, and dynamicimport(). - Reaching for the usual JavaScript tools:
setTimeout,fetch, and the Node APIs don’t exist in a mod. Use$.clock,$.http,$.fs, and$.process. - Editing the installed copy: It’s cached by version. Develop against
--plugin-dir. - Treating a regular-expression guard as security: A pattern written for
--forcemissesgit push -f. See The Enforcement Ladder for ways to enforce a rule more reliably. - Assuming deny rules restrict the mod itself: They don’t. See above.
- Busy loops: They crash the shared worker thread that runs installed mods, and repeated crashes unload all of them.
- Changing system-prompt text on every request: It breaks the prompt cache (the provider’s cache of a conversation prefix it has already processed, which makes resending that prefix much cheaper), so every request pays full price. Every. Single. Time.
Advanced levers
turn.stepto route individual requests to another model, or to log cache hits.$.tool.registerto give Claude a new tool.$.model.completefor a model call of your own, with no conversation history.$.model.forkto ask a question over the current conversation, mostly served from cache. Handy for writing a handoff brief.- Timers (
$.clock.every), plus$.prompt.submitto start a turn from background work. $.session.sendto message another session.session.appendto rewrite conversation rows before they’re stored, for redaction, say.Rasterand$.ui.blitfor grids and animation in the terminal.- Policy mods:
plugin.registerto refuse other mods, plus hooks on$methods to audit them.
Learn from real code
If you want to learn from real code, start with the built-in mods (diff, agents-md, sec-default, telemetry) in anthropics/claude-code/mods. Then try Anthropic’s samples: token-weather, blast-radius, and replay-theater.
From the community, there’s OneWave’s ten example mods, and paddo’s ccseats write-up on real-world gotchas.
Read the validate output before you install any mod, and learn from the built-in ones first.