Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings and tool policy out of reach of the…

The security default for organizations. Function hooks give every plugin a say on every event, in chain order, and the plugins a person installs sit in the user tier, beneath the organization's prepend tier and above its append tier. Some of what an organization sets today (its classic hooks, its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny rules in force on its machines) was never within a person's reach before function hooks; seated outermost, this plugin keeps exactly those out of the user tier's reach and adds no policy of its own. Everything else passes through untouched.
It has three moves and nothing else: continue past the user tier (next.to(e, "append")), refuse a user-tier caller or module by name ({ deny } when next.origin.tier is user, { refuse } when a module's pinned e.tier is), or pass (next(e)). A subject's provenance is the event's pinned e.provider; policy is read through $.settings.read({ source: "policy" }), one read serving a burst of tool calls; both fail closed, so an unreadable policy counts as a policy in force.
hooks/register.ts is the module; hooks/policy/ reads the managed settings it decides by.
| event | from the outermost seat |
|---|---|
classic.* | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
prompt.section, prompt.context, skill.prompt, attribution.text | Continue past the user tier: managed CLAUDE.md, rules and policy skills reach the model as written. A person's plugins keep prompt.submit and its additive context. |
prompt.compose | Continue past the user tier: the system prompt's list of sections is what the organization's tiers, the built-ins and the engine's own composition make it. A person's plugin neither drops, reorders nor rewrites a section, nor changes the facts the list is composed from, nor answers a list of its own in its place. The engine raises this event only when some loaded plugin hooks it, so where this plugin is seated every render of the system prompt runs the chain. |
settings.read | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
tool.describe, command.describe, agent.offer, agent.spawn | When the subject's pinned e.provider.tier is prepend or append (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by user, builtin or core passes. |
tool.register | A caller in prepend or append continues past the user tier. A user-tier caller is refused by name while managed settings hold allowedMcpServers (set at all, empty included); otherwise it passes. |
tool.list | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. |
tool.check | A deny that a settings rule decided holds over the user tier: when a person's plugin loosened the verdict it was handed, the dispatch is run again past the user tier, and if that verdict is a deny naming its rule, it is the answer. See Deny rules hold. Every other verdict passes as the chain left it. |
plugin.register | A hooks module in the user tier (one a person installed, named with --plugin-dir, or keeps in their mods folder) is refused while managed settings set this plugin's allowManagedModsOnly option; otherwise it passes. Modules in prepend, append and builtin are never asked about. |
| everything else | Passes: prompt.submit, turn.*, tool.call, command.run, command.register, session.*, ui.*, fs.*, http.fetch, process.run, store.*, clock.*, model.*, mcp.call, audio.*, agent.list, engine.create. |
Two, in managed settings, under this plugin's own pluginConfigs entry, keyed by the id the CLI builds the plugin in under (only this spelling of the id is read):
{
"pluginConfigs": {
"cc-plugin-sec-default@builtin": {
"options": { "allowManagedModsOnly": true }
}
}
}
allowManagedModsOnly: only the mods the organization deploys through managed settings, and the ones built into Claude Code, load. A hooks module a person installed, named with --plugin-dir or keeps in their mods folder is refused whenever it loads or reloads (one already running when the option is set keeps running until then), with one line that names it: mods are limited to your organization's by policy (allowManagedModsOnly); <plugin> was not loaded (in the debug log, and on screen where the session hot-reloads the mod's folder). A plain -p run has it in the debug log alone; the mod is still not loaded. Settings hooks, status lines and /goal are not touched by it.
user tier and is refused.$ served.$.settings.read({ source: "policy" })): the same entry in a person's, a project's or a --settings file neither turns it on nor off. On unless absent or false, so a mistyped "true" or 1 still locks. A value the settings schema rejects (null, an object), here or in any other pluginConfigs entry, makes the CLI ignore the whole pluginConfigs key with a settings warning, and the option reads as unset..catch refuses the module, with the same line, and names the failure in the debug log; so a policy that cannot be read keeps every person's mod out at load. Where this plugin is not seated there is no such rule, and mods load as they do without it.prependPlugins, that list must name this plugin (next section) for the option to apply.claude plugin test is not covered: it runs a mod's tests in an engine of their own and loads nothing into a session.allowModsToOverrideDenyRules: the plugins a person installs may answer over a settings deny rule on tool.check, as they could before this plugin held deny rules. Off unless it is the literal true; an option that reads as unset leaves deny rules holding. See Deny rules hold.
classic.*, prompt.section, prompt.context, prompt.compose, skill.prompt, attribution.text, settings.read, tool.describe, command.describe, agent.offer, agent.spawn, tool.register, tool.list, tool.check, plugin.register.
Hooking tool.check has a cost: the engine raises that event only when some loaded plugin hooks it, so where this plugin is seated every tool call now runs the tool.check chain, where before only a session with such a plugin did.
$settings.read, and ui.log: to the debug log, and for the one line a person reads when a deny rule held over a plugin of theirs. It continues to the append tier with next.to, which only a plugin in a managed tier may do.
On tool.check any hook may answer any verdict, so a plugin a person installs to stop the permission prompts (() => ({ decision: "allow" })) would also lift a deny rule, a managed one included. Where this plugin is seated it does not:
next.trace, whose tiers the engine pins: a link that never called next is measured against a deny, and since the engine lists neighbouring plugins that share a worker as one batch under its first member's tier, a prepend entry beneath this plugin counts as well as a user one. Whether a person's plugin did the loosening is never settled here, only by the next step.next.to(e, "append")). That verdict never passed through a person's plugin, so neither the decision nor the rule it names can have been rewritten or erased, and a plugin that answered without calling next changes nothing: the rules are evaluated in this run. The two runs differ by the user tier alone, so a deny here that names its rule is a deny rule the user tier loosened, and it is returned in place of the chain's answer.tool.check pins the question (tool, input, tool_use_id), so no hook can have the rules evaluated on one command and another run; a rewrite belongs to tool.call, which runs before any of this.<plugin> tried to lift a deny rule in your settings from a <tool> call (<rule>); the deny rule holds over the plugins you install (allowModsToOverrideDenyRules). Plugins the engine ran as one batch are named together, as it names them (audit+easy). A plain -p run has it in the debug log alone; the call is still denied with the rule's own message..catch answers from the one run it can read: a deny stands; a verdict no plugin of the person's loosened stands; one they loosened, or a run that rejected, is refused, since the deny rules were never consulted.An organization that wants the plugins its people install to override deny rules says so in managed settings, under this plugin's own options:
{
"pluginConfigs": {
"cc-plugin-sec-default@builtin": {
"options": { "allowModsToOverrideDenyRules": true }
}
}
}
Only the managed source is read ($.settings.read({ source: "policy" })), so the same key in a person's, a project's or a local settings file, or in --settings, is never consulted; only the literal true counts, and a policy that cannot be read leaves deny rules holding.
The CLI seats it first in the prepend tier wherever hooks modules load on a machine with managed settings or for a Team or Enterprise organization, unless managed settings define prependPlugins: then that list is the whole prepend tier, and the organization names sec-default@builtin in it at the position it wants, e.g. "prependPlugins": ["acme-guard@acme-tools", "sec-default@builtin"], or leaves it out. It is a plugin folder like any other, but its one move that matters, next.to, is refused outside a managed tier, so loading it with --plugin-dir seats a plugin that can only pass.
hooks/register.ts 116 lines1import type { On } from 'claude-code'
2
3import { admissionFailure } from './admission-failure'
4import HeldVerdict from './held-verdict'
5import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
6import { pastUsers } from './past-users'
7import Policy from './policy'
8import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
9
10/**
11 * The built-in's hooks, seated outermost: each keeps one control an
12 * organization has today out of reach of the plugins a person installs.
13 *
14 * Three moves: continue past the user tier (`next.to(e, "append")`), refuse
15 * a user-tier caller or module by name, or pass. Provenance is the event's
16 * pinned `provider` or `tier`; policy is `$.settings.read`; fail closed.
17 *
18 * @param on the engine's registrar
19 */
20export function register(on: On) {
21 const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
22 const told = new Set<string>()
23
24 on('classic.*', ($, e, next) => next.to(e, 'append'))
25
26 on('prompt.section', ($, e, next) => next.to(e, 'append'))
27 on('prompt.context', ($, e, next) => next.to(e, 'append'))
28 on('prompt.compose', ($, e, next) => next.to(e, 'append'))
29 on('skill.prompt', ($, e, next) => next.to(e, 'append'))
30 on('attribution.text', ($, e, next) => next.to(e, 'append'))
31
32 on('settings.read', ($, e, next) => next.to(e, 'append'))
33
34 on('tool.describe', ($, e, next) => pastUsers(e, next))
35 on('command.describe', ($, e, next) => pastUsers(e, next))
36 on('agent.offer', ($, e, next) => pastUsers(e, next))
37 on('agent.spawn', ($, e, next) => pastUsers(e, next))
38
39 on('tool.register', async ($, e, next) => {
40 const isOrgs =
41 next.origin.tier === 'prepend' || next.origin.tier === 'append'
42
43 if (isOrgs) {
44 return next.to(e, 'append')
45 }
46
47 const isRefused =
48 next.origin.tier === 'user' &&
49 (await Policy.decidedByPolicy(
50 readPolicy(() => $.settings.read(Policy.SOURCE)),
51 Policy.hasMcpAllowlist,
52 ))
53
54 return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e)
55 })
56
57 on('tool.list', async ($, e, next) =>
58 Policy.managedToolsRestored(
59 await readPolicy(() => $.settings.read(Policy.SOURCE)).catch(
60 () => undefined,
61 ),
62 await next.to(e, 'append'),
63 await next(e),
64 ),
65 )
66
67 on('tool.check', async ($, e, next) => {
68 const answer = await next(e)
69 const mods = HeldVerdict.loosenedByUsers(next.trace)
70
71 const shouldRecheck =
72 answer.decision !== 'deny' &&
73 mods.length > 0 &&
74 (await Policy.decidedByPolicy(
75 readPolicy(() => $.settings.read(Policy.SOURCE)),
76 Policy.denyRulesHold,
77 ))
78
79 if (!shouldRecheck) {
80 return answer
81 }
82
83 const held = await next.to(e, 'append')
84
85 if (!HeldVerdict.isRuleDeny(held)) {
86 return answer
87 }
88
89 for (const mod of mods.filter(name => !told.has(name))) {
90 told.add(mod)
91 $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))
92 }
93
94 return held
95 }).catch(async ($, e, next) => {
96 const last = await next(e).catch(() => undefined)
97
98 const shouldVouch = await Policy.decidedByPolicy(
99 readPolicy(() => $.settings.read(Policy.SOURCE)),
100 Policy.denyRulesHold,
101 )
102
103 return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
104 })
105
106 on('plugin.register', { tier: 'user' }, async ($, e, next) =>
107 Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
108 ? { refuse: managedModsOnlyRefusal(e.name) }
109 : next(e),
110 ).catch(($, e, next) => {
111 $.ui.log(admissionFailure(e.name, next.error), { to: 'debug' })
112
113 return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) }
114 })
115}
116hooks/admission-failure/index.ts 4 lines1export * from './admission-failure.js'
2
3export * as default from '.'
4hooks/held-verdict/index.ts 6 lines1export * from './caught-answer.js'
2export * from './held-notice.js'
3export * from './verdicts'
4
5export * as default from '.'
6hooks/managed-mods-only-refusal/index.ts 4 lines1export * from './managed-mods-only-refusal.js'
2
3export * as default from '.'
4hooks/past-users/index.ts 6 lines1export * from './past-users.js'
2export * from './provided'
3export * from './user-reachable-tiers'
4
5export * as default from '.'
6hooks/policy/index.ts 12 lines1export * from './create-policy-memo'
2export * from './decided-by-policy.js'
3export * from './deny-rules-hold.js'
4export * from './has-mcp-allowlist.js'
5export * from './is-managed-mods-only.js'
6export * from './managed-tools-restored'
7export * from './own-option'
8export * from './policy-memo-ms.js'
9export * from './source.js'
10
11export * as default from '.'
12hooks/tool-register-refusal/index.ts 4 lines1export * from './tool-register-refusal.js'
2
3export * as default from '.'
4hooks/admission-failure/admission-failure.ts 14 lines1import type { HookFailure } from 'claude-code'
2
3/**
4 * The debug line for a `plugin.register` hook of this plugin that failed:
5 * the module it was judging, how it failed, and what the failure said.
6 *
7 * @param name the judged plugin's name, as its own manifest gives it
8 * @param error why the hook failed, as its `.catch` reads it
9 * @returns the line
10 */
11export const admissionFailure = (name: string, error: HookFailure) =>
12 `plugin.register hook failed judging ${name} (${error.kind}): ` +
13 (error.message ?? 'no message')
14hooks/held-verdict/caught-answer.ts 26 lines1import type { EventResult, TraceEntry } from 'claude-code'
2
3import Verdicts from './verdicts'
4
5/**
6 * What the `tool.check` hook's failure handler answers from the one run it
7 * can read, the failed hook's last: that run's verdict, or a refusal.
8 *
9 * A deny stands, and so does a verdict no link that may hold a person's
10 * plugin loosened. A loosened one, or none at all, met no deny rule.
11 *
12 * @param last what that run settled on; undefined when it rejected
13 * @param trace that run's `next.trace`
14 * @returns the verdict the handler returns
15 */
16export function caughtAnswer(
17 last: EventResult<'tool.check'> | undefined,
18 trace: readonly TraceEntry<'tool.check'>[],
19) {
20 const isVouched =
21 last !== undefined &&
22 (last.decision === 'deny' || Verdicts.loosenedByUsers(trace).length === 0)
23
24 return isVouched ? last : Verdicts.UNCHECKED_DENY
25}
26hooks/held-verdict/held-notice.ts 16 lines1/**
2 * What a person reads, once for each plugin in a session, when a plugin they
3 * installed answered allow or ask over a deny rule in their settings.
4 *
5 * It names the option an administrator sets to let such plugins override.
6 *
7 * @param plugin the plugin's name, or its batch's, as the trace names it
8 * @param tool the tool the call named
9 * @param rule the deny rule that decided, as written
10 * @returns the line
11 */
12export const heldNotice = (plugin: string, tool: string, rule: string) =>
13 `${plugin} tried to lift a deny rule in your settings from a ${tool} ` +
14 `call (${rule}); the deny rule holds over the plugins you install ` +
15 '(allowModsToOverrideDenyRules)'
16hooks/held-verdict/verdicts/index.ts 8 lines1export * from './is-rule-deny.js'
2export * from './loosened-by-users.js'
3export * from './ranking'
4export * from './types'
5export * from './unchecked-deny.js'
6
7export * as default from '.'
8hooks/managed-mods-only-refusal/managed-mods-only-refusal.ts 11 lines1/**
2 * What a person reads when managed policy keeps a mod of theirs out: the
3 * rule, the option that set it, and the mod that was not loaded.
4 *
5 * @param name the refused plugin's name, as its own manifest gives it
6 * @returns the refusal line
7 */
8export const managedModsOnlyRefusal = (name: string) =>
9 "mods are limited to your organization's by policy " +
10 `(allowManagedModsOnly); ${name} was not loaded`
11