surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
sec-default
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 and the environment they set, its MCP allowlist, the permission rules and refusals in force on its machines) was never within a person's reach before function hooks; seated outermost, this plugin keeps exactly those, and the lines its own plugins log, 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.
The rows
| 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, and an ask a settings rule decided, hold 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 the stricter and is a deny, or names the rule behind it, it is the answer. See Deny rules hold. Every other verdict passes as the chain left it. |
ui.log | A line a plugin in prepend or append logs, this one's included, continues past the user tier: no user hook rewrites or drops it, and none runs inside the hook that logged it. Every other line passes. |
env.set | A variable managed settings set in env (its name in any case) is pinned: setting or unsetting it, a user-tier caller is refused by name and any other caller continues past the user tier, so no user hook rewrites the value. A variable the organization does not set passes. With no policy to read, every variable counts as pinned. allowModsToOverrideDenyRules does not unpin. |
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.* but ui.log, fs.*, http.fetch, process.run, env.get, store.*, clock.*, model.*, mcp.call, audio.*, agent.list, engine.create. |
Options an administrator sets
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.
- The decision reads the tier the CLI pins on the module and nothing the module says of itself: a person's copy carrying an organization mod's name is still in the
usertier and is refused. - Refused means nothing of the module joins: no hook, no tool, no command. Its top-level code has run once by then, in the closed context every hooks module is evaluated in, with no call on
$served. - Only managed settings are read (
$.settings.read({ source: "policy" })): the same entry in a person's, a project's or a--settingsfile neither turns it on nor off. On unless absent orfalse, so a mistyped"true"or1still locks. A value the settings schema rejects (null, an object), here or in any otherpluginConfigsentry, makes the CLI ignore the wholepluginConfigskey with a settings warning, and the option reads as unset. - It fails closed: when the read of managed settings is refused (a hook beneath denies it) or this plugin's hook fails, its
.catchrefuses 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. - Where managed settings define
prependPlugins, that list must name this plugin (next section) for the option to apply. claude plugin testis 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, and over every other deny and an ask rule with it: the one option covers all that holds there. Off unless it is the literal true; an option that reads as unset leaves them holding. See Deny rules hold.
What it hooks
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, ui.log, env.set, 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. So has hooking ui.log and env.set: each such call of any plugin's now runs a chain, and an env.set waits on the policy read.
What it calls on $
settings.read, and ui.log: to the debug log, and for the one line a person reads when something 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.
Deny rules hold
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 or an ask rule, a managed one included. Where this plugin is seated it does not:
- The hook first runs the chain as it is. If the answer is a deny, or no link that may hold a person's plugin answered more permissively than the verdict handed up to it, the answer passes: nothing of the person's loosened anything, and a plugin that only listens adds no run of its own. This is read off
next.trace, whose tiers the engine pins: a link that never callednextis measured against a deny, and since the engine lists neighbouring plugins that share a worker as one batch under its first member's tier, aprependentry beneath this plugin counts as well as auserone. Whether a person's plugin did the loosening is never settled here, only by the next step. - Otherwise it runs the dispatch once more with the user tier left out (
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 callingnextchanges nothing: the rules are evaluated in this run. The two runs differ by the user tier alone, so a verdict here that is stricter than the chain's answer is one the user tier loosened. A deny is returned in place of the chain's answer whatever decided it; an ask, when it names the settings rule that decided it (rule). - Any rule counts, whoever configured it: a verdict carries the rule as written, never where it was read from. A deny holds unnamed because it can stand in front of a rule's ask: lifted, the call would run with nobody asked. An ask that names no rule (the mode's own, a settings hook's, a check of the engine's own, an organization's plugin's) is not held.
- An organization's plugin (prepend or append) or a built-in that allows over a deny rule takes part in both runs, so its answer stands (a prepended one that loosens is what brings the second run about, so its hooks run twice on such a call). The mode's own ask that a person's plugin turns into an allow, with no rule behind it, stands: that is what such a plugin is for.
tool.checkpins the question (tool,input,tool_use_id), so no hook can have the rules evaluated on one command and another run; a rewrite belongs totool.call, which runs before any of this.- The person is told once for each name and kind in a session, in the transcript and the debug log:
<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). The kinds area deny rule,an ask ruleanda refusal(a deny that names no rule); the last carries neitherin your settingsnor a rule. Plugins the engine ran as one batch are named together, as it names them (audit+easy). A plain-prun has it in the debug log alone; the call is still denied, or asked about, with its own message. The line goes past the user tier (theui.logrow): a hook of theirs that heard it would run inside this plugin's owntool.checkhook, and the engine leaves a hook out of whatever is raised from inside it. - What this does not reach: a held ask is still an ask, so whatever answers asks for the person answers it, a classic
PermissionRequesthook of theirs included (allowManagedHooksOnlyis the control for those). And a command hook decides only if its command runs: it is spawned in the session's environment and a hook that fails to run decides nothing, so a variable its spawn depends on (PATH,CLAUDE_CODE_SHELL_PREFIX) that the organization does not set in managedenvis a person's to change, by their own settings as by a plugin's$.env.set. Set in managedenv, it is pinned (theenv.setrow). - If the hook itself fails, its
.catchanswers 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 rules were never consulted.
An organization that wants the plugins its people install to override deny rules, and all else that holds with them, 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 them all holding.
Where it is seated
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.