spacehub

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

claude-code / mods / sec-default / README.md
188 lines · 11.4 KBPreviewCodeRaw
1# sec-default
2
3The security default for organizations. Function hooks give every plugin a
4say on every event, in chain order, and the plugins a person installs sit
5in the user tier, beneath the organization's prepend tier and above its
6append tier. Some of what an organization sets today (its classic hooks,
7its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny
8rules in force on its machines) was never within a person's reach before
9function hooks; seated outermost, this plugin keeps exactly those out of the
10user tier's reach and adds no policy of its own. Everything else passes
11through untouched.
12
13It has three moves and nothing else: continue past the user tier
14(`next.to(e, "append")`), refuse a user-tier caller or module by name
15(`{ deny }` when `next.origin.tier` is `user`, `{ refuse }` when a module's
16pinned `e.tier` is), or pass (`next(e)`). A subject's provenance is the
17event's pinned `e.provider`; policy is read through
18`$.settings.read({ source: "policy" })`, one read serving a burst of tool
19calls; both fail closed, so an unreadable policy counts as a policy in force.
20
21`hooks/register.ts` is the module; `hooks/policy/` reads the managed
22settings it decides by.
23
24## The rows
25
26| event | from the outermost seat |
27| --- | --- |
28| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
29| `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. |
30| `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. |
31| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
32| `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. |
33| `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. |
34| `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. |
35| `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](#deny-rules-hold). Every other verdict passes as the chain left it. |
36| `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. |
37| 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`. |
38
39## Options an administrator sets
40
41Two, in managed settings, under this plugin's own `pluginConfigs` entry,
42keyed by the id the CLI builds the plugin in under (only this spelling of the
43id is read):
44
45```json
46{
47 "pluginConfigs": {
48 "cc-plugin-sec-default@builtin": {
49 "options": { "allowManagedModsOnly": true }
50 }
51 }
52}
53```
54
55`allowManagedModsOnly`: only the mods the organization deploys through
56managed settings, and the ones built into Claude Code, load. A hooks module
57a person installed, named with `--plugin-dir` or keeps in their mods folder
58is refused whenever it loads or reloads (one already running when the option
59is set keeps running until then), with one line that names it: `mods are
60limited to your organization's by policy (allowManagedModsOnly); <plugin>
61was not loaded`
62(in the debug log, and on screen where the session hot-reloads the mod's
63folder). A plain `-p` run has it in the debug log alone; the mod is still
64not loaded. Settings hooks, status lines and `/goal` are not touched by it.
65
66- The decision reads the tier the CLI pins on the module and nothing the
67 module says of itself: a person's copy carrying an organization mod's name
68 is still in the `user` tier and is refused.
69- Refused means nothing of the module joins: no hook, no tool, no command.
70 Its top-level code has run once by then, in the closed context every hooks
71 module is evaluated in, with no call on `$` served.
72- Only managed settings are read (`$.settings.read({ source: "policy" })`):
73 the same entry in a person's, a project's or a `--settings` file neither
74 turns it on nor off. On unless absent or `false`, so a mistyped `"true"` or
75 `1` still locks. A value the settings schema rejects (`null`, an object),
76 here or in any other `pluginConfigs` entry, makes the CLI ignore the whole
77 `pluginConfigs` key with a settings warning, and the option reads as unset.
78- It fails closed: when the read of managed settings is refused (a hook beneath
79 denies it) or this plugin's hook fails, its `.catch` refuses the module,
80 with the same line, and names the failure in the debug log; so a policy
81 that cannot be read keeps every person's mod out at load. Where this plugin
82 is not seated there is no such rule, and mods load as they do without it.
83- Where managed settings define `prependPlugins`, that list must name this
84 plugin (next section) for the option to apply.
85- `claude plugin test` is not covered: it runs a mod's tests in an engine of
86 their own and loads nothing into a session.
87
88`allowModsToOverrideDenyRules`: the plugins a person installs may answer
89over a settings deny rule on `tool.check`, as they could before this plugin
90held deny rules. Off unless it is the literal `true`; an option that reads
91as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
92
93## What it hooks
94
95`classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
96`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
97`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
98`plugin.register`.
99
100Hooking `tool.check` has a cost: the engine raises that event only when some
101loaded plugin hooks it, so where this plugin is seated every tool call now
102runs the `tool.check` chain, where before only a session with such a plugin
103did.
104
105## What it calls on `$`
106
107`settings.read`, and `ui.log`: to the debug log, and for the one line a
108person reads when a deny rule held over a plugin of theirs. It continues to
109the `append` tier with `next.to`, which only a plugin in a managed tier may
110do.
111
112## Deny rules hold
113
114On `tool.check` any hook may answer any verdict, so a plugin a person
115installs to stop the permission prompts (`() => ({ decision: "allow" })`)
116would also lift a deny rule, a managed one included. Where this plugin is
117seated it does not:
118
119- The hook first runs the chain as it is. If the answer is a deny, or no
120 link that may hold a person's plugin answered more permissively than the
121 verdict handed up to it, the answer passes: nothing of the person's
122 loosened anything, and a plugin that only listens adds no run of its own.
123 This is read off `next.trace`, whose tiers the engine pins: a link that never
124 called `next` is measured against a deny, and since the engine lists
125 neighbouring plugins that share a worker as one batch under its first
126 member's tier, a `prepend` entry beneath this plugin counts as well as a
127 `user` one. Whether a person's plugin did the loosening is never settled
128 here, only by the next step.
129- Otherwise it runs the dispatch once more with the user tier left out
130 (`next.to(e, "append")`). That verdict never passed through a person's
131 plugin, so neither the decision nor the rule it names can have been
132 rewritten or erased, and a plugin that answered without calling `next`
133 changes nothing: the rules are evaluated in this run. The two runs differ
134 by the user tier alone, so a deny here that names its rule is a deny rule
135 the user tier loosened, and it is returned in place of the chain's answer.
136- Any deny rule counts, whatever settings file it came from: a verdict
137 carries the rule as written, never where it was read from. A deny that
138 names no rule (a settings hook's, a tool's own check) is not held.
139- An organization's plugin (prepend or append) or a built-in that allows
140 over a deny rule takes part in both runs, so its answer stands (a prepended
141 one that loosens is what brings the second run about, so its hooks run
142 twice on such a call). An ask
143 that a person's plugin turns into an allow, with no deny rule behind it,
144 stands: that is what such a plugin is for.
145- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
146 can have the rules evaluated on one command and another run; a rewrite
147 belongs to `tool.call`, which runs before any of this.
148- The person is told once for each name in a session, in the transcript
149 and the debug log: `<plugin> tried to lift a deny rule in your settings
150 from a <tool> call (<rule>); the deny rule holds over the plugins you
151 install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
152 batch are named together, as it names them (`audit+easy`). A plain `-p`
153 run has it in the debug log alone; the call is still denied with the
154 rule's own message.
155- If the hook itself fails, its `.catch` answers from the one run it can
156 read: a deny stands; a verdict no plugin of the person's loosened stands;
157 one they loosened, or a run that rejected, is refused, since the deny
158 rules were never consulted.
159
160An organization that wants the plugins its people install to override deny
161rules says so in managed settings, under this plugin's own options:
162
163```json
164{
165 "pluginConfigs": {
166 "cc-plugin-sec-default@builtin": {
167 "options": { "allowModsToOverrideDenyRules": true }
168 }
169 }
170}
171```
172
173Only the managed source is read (`$.settings.read({ source: "policy" })`),
174so the same key in a person's, a project's or a local settings file, or in
175`--settings`, is never consulted; only the literal `true` counts, and a
176policy that cannot be read leaves deny rules holding.
177
178## Where it is seated
179
180The CLI seats it first in the prepend tier wherever hooks modules load on a
181machine with managed settings or for a Team or Enterprise organization,
182unless managed settings define `prependPlugins`: then that list is the
183whole prepend tier, and the organization names `sec-default@builtin` in it
184at the position it wants, e.g. `"prependPlugins": ["acme-guard@acme-tools",
185"sec-default@builtin"]`, or leaves it out. It is a plugin folder like any
186other, but its one move that matters, `next.to`, is refused outside a
187managed tier, so loading it with `--plugin-dir` seats a plugin that can
188only pass.