Level 3 Engineering and policy
Mods 301
At scale a mod stops being a personal tweak and becomes part of how an organization runs Claude Code. This page is for the people who write policy mods, review other people's mods, or decide which mods may load at all. It covers seats in the chain, admission, what the loader's static analysis does and doesn't promise, every failure mode we know of, and how to ship.
1. Seats in the chain
Hooks don't run in load order, whatever some early write-ups say. They run by tier, outermost first:
| Seat | Who sits there | Set by |
|---|---|---|
| 1. prepend | sec-default@builtin where the guard loads, then prependPlugins in listed order, then other org mods not in appendPlugins | Managed settings |
| 2. user | Mods the user installed. A mod runs before the mods it lists in dependencies. | The user |
| 3. append | appendPlugins | Managed settings |
| 4. builtin | Other built-in mods: agents-md, diff, telemetry… | Claude Code |
| 5. core | Claude Code's own behaviour, reported as { plugin: 'engine', tier: 'core' } | — |
Within one module, hooks run in the order on was called. Setting prependPlugins replaces the default, so list sec-default@builtin yourself or you lose the guard. Repository settings can never set these lists. A prepend or append mod can skip the user tier with next.to(e, 'append' | 'builtin' | 'core'), and any hook can read next.origin to see who raised the event. The hook chain draws the full path of a tool call, including where managed PreToolUse hooks sit.
2. Policy mods
Two events make a mod a policy engine:
plugin.registerfires for each hooks module about to load, withe.tierande.uses, the same listvalidateprints. A prepended mod can return{ refuse }. For example, it can refuse any user mod that callsprocess.runorhttp.fetch.- Every
$call is an event. A prepended hook onfs.read,http.fetchormodel.completesees, rewrites or refuses those calls from every mod seated after it.
The built-in sec-default guard is the reference policy mod, and its source is public in anthropics/claude-code. Where it loads, it stops user mods from changing the system prompt and managed instructions. It also stops them lifting a managed deny rule unless allowModsToOverrideDenyRules is set. An 11 October update widened what it holds: managed and sign-in environment variables against env.set, injected session.append rows, prompt.attachment, ui.log and all of session.compact. A user mod's compaction hook doesn't run where the guard is seated. A user mod can still override ask rules and non-managed PreToolUse blocks, and in auto mode a call a mod approves skips the classifier.
Write admission checks with .catch. A plugin.register hook that throws is skipped, and the mod it was checking loads.
3. The switches, in order
| Switch | Effect |
|---|---|
| Anthropic's remote kill switch | Turns installed mods off. No local setting overrides it. |
| Workspace trust | No mod loads before the trust prompt is accepted. |
--safe-mode / --bare | Turns off installed mods for the session. --bare keeps managed ones. Built-ins unaffected. |
disableAllHooks | In user settings, stops the user's mods and hooks. In managed settings, stops every installed mod, org mods included. |
allowManagedHooksOnly (managed) | Only org and built-in mods load, and users' settings hooks are blocked. |
allowManagedModsOnly | An option on the sec-default guard (pluginConfigs["cc-plugin-sec-default@builtin"].options), honoured only in managed settings. User mods stop, and users' settings hooks keep working. |
disableSideloadFlags (managed) | Rejects --plugin-dir and --plugin-url, and stops mods Claude writes from loading. |
strictKnownMarketplaces and allowlists | Decide whether a mod can be installed in the first place. |
An org mod counts as the organization's only when managed enabledPlugins lists it from a directory marketplace at an absolute path by relative path, so it loads in place. A GitHub, git, URL or npm source counts as a user's mod even when managed settings enable it.
4. An audit boundary, not a security boundary
The docs say both "the module runs in a sandbox of its own" and "mods aren't sandboxed". Both are true. The runtime is capability-isolated: no Node, no DOM, no file or network globals (timer globals do exist, despite the docs; see Field tests), and every effect is a $ call the loader can find statically. But $ hands out the user's full authority. Static analysis tells a reviewer what a mod can do. It doesn't stop it. Three gaps matter for policy:
$.fsand$.processdon't pass through permission rules.Read(.env)as a deny rule doesn't stop$.fs.read('.env'). A prepended hook onfs.readdoes.- Org network policy covers
$.http.fetchbut not a process a mod spawns. - A process a mod starts runs outside the Bash sandbox.
5. Failure modes
| Failure | What happens | Net effect |
|---|---|---|
Hook throws, overruns 10 s of own time, or answers the wrong shape before next | Skipped; the next link runs in its place | Fails open |
Same, after next resolved | That result stands; nothing runs twice | — |
.catch registered | Handler runs (1 s) with next.error.kind = throw, timeout or re-entry, and can deny | Fails closed, by choice |
$.ui.ask hook times out while holding a call | Hook skipped, so the held command runs | Fails open |
sec-default can't read managed settings or check deny rules | Refuses user mods or the call | Fails closed |
Invalid ui.render tree | Engine draws its own and logs the reason | Graceful |
| Top-level module code throws | hooks module did not load | — |
| A reload fails | Previous version stays loaded | — |
| A hook blocks the worker thread | That mod unloaded: "crashed the hooks worker" | — |
| Worker crashes three times with no single culprit | All non-built-in mods unloaded, org policy mods included, until /reload-plugins | Policy fails open |
session.end hooks | All of them share 1.5 s | Late saves are lost |
The worker-crash row is the one to plan around. A policy that must hold even when every mod is unloaded belongs in managed settings (deny rules, managed PreToolUse hooks), not only in a mod. Managed PreToolUse hooks run before any mod sees the call, and they run again on a call a mod rewrote.
6. Limits
| Limit | Value |
|---|---|
| Hook's own time | 10 s |
.catch handler | 1 s |
All session.end hooks together | 1.5 s |
$.process.run | 30 s default, 10 min max |
$.model.complete maxTokens default | 1,024 |
$.fs per file / $.store per plugin / $.http.fetch body | 4 MiB each |
ui.invalidate redraws | 10/s (30/s visible terminal) |
$.session.messages() | newest 4,096 |
| Names | [A-Za-z0-9_-], ≤ 64 |
| Registered tool description seen by the model (2.1.296) | 4,096 chars |
One test in claude plugin test | 5 s |
7. CI for mods
- Validate strictly.
claude plugin validate <dir> --strict --json. Fail the build ifcallsgrows unexpectedly or a gating hook loses its.catch(gatingHooksin the JSON). - Test.
claude plugin test <dir>exits 1 on failure and needs no network or sign-in. Run it in an empty folder first to check that mods can load in that environment at all. - Type-check against the build. The authoritative API is the declaration file each build writes (about 21,500 lines in 2.1.295). A headless run doesn't write
.claude-plugin/types/, so keep a pinned copy from theplugin-authoringskill's bundle and point a tsconfig at it. Regenerate it on every Claude Code upgrade; never edit it. - Pin the Claude Code version in CI, and re-run on upgrade. The API is early access, and seven of the first nine releases after launch changed something for mod authors.
8. Shipping
One file makes a repository a marketplace: .claude-plugin/marketplace.json listing the mod, with "source": "./" for a single-mod repo. Users install with one line at the prompt:
/plugin install <mod> --marketplace <owner>/<repo>
Put that line in the README. Since 2.1.295 validate suggests it when it's missing. Installs are cached by version, so bump version on every release. An edit to a working copy reaches users only as a new version and claude plugin update. The exception is a directory marketplace with relative sources, which is read in place: edit it, run /reload-plugins, done. That is the model for org mods. Plugins can also be submitted to the Claude directory.
9. When something does nothing
- Run
claude --debug. A skipped hook is logged with its plugin, event and reason. In a hot-reloading session a dim transcript line says it once.claude -pwrites it to stderr. /pluginshowsN mods active · names. Since 2.1.296 it also notes a plugin whose hooks were dropped because another enabled plugin has the same name.- "registered /x but no command.run hook answered it" means a command with no answer.
options do not fit plugin.json userConfigmeans the module didn't load because of a bad option. - Installed mods didn't load in the first session after upgrading to 2.1.289. If a mod seemed dead right after an update, that was probably why.
What to remember
- The chain is ordered by seat, not load order. Policy sits in prepend.
plugin.registerplus the fact that every$call is an event make a mod a policy engine.- Static analysis is an audit boundary.
$carries the user's full authority. - Mods fail open by default, and a worker crash loop unloads policy mods too. Back critical policy with managed settings.
- Validate strictly, test, type-check against the build and pin the version in CI.