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
269 lines · 17.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, the approval it requires for a connector's
9tool) was never within a person's reach before function hooks; seated
10outermost, this plugin keeps exactly those out of the user tier's reach and
11adds no policy of its own. Everything else passes through 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.
20Every hook carries a `.catch` but the five that only pass prompt content
21and attribution text over the user tier (`prompt.section`,
22`prompt.context`, `prompt.compose`, `skill.prompt`, `attribution.text`),
23and no `.catch` depends on a call on `$` succeeding: one that reads policy
24reads it itself, a read that rejects or throws counts as a policy in force,
25and a line that cannot be logged changes no answer.
26
27`hooks/register.ts` is the module; `hooks/policy/` reads the managed
28settings it decides by.
29
30## The rows
31
32| event | from the outermost seat |
33| --- | --- |
34| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. If the hook fails before continuing, its `.catch` continues past the user tier all the same. |
35| `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. |
36| `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. |
37| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. If the hook fails before continuing, its `.catch` continues past the user tier all the same. |
38| `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. If the hook fails before it decided, its `.catch` decides the same way. |
39| `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. If the hook fails before it decided, its `.catch` decides the same way on a policy read of its own, and a policy it cannot read counts as an allowlist in force. |
40| `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. If the hook fails, its `.catch` answers the organization's listing whole. |
41| `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). So does the ceiling an organization set on a tool (`e.ceiling`): when the chain's answer is more permissive than the ceiling, the dispatch is run again past the user tier, and if that verdict is stricter, it is the answer. See [An organization's ceiling holds](#an-organizations-ceiling-holds). Every other verdict passes as the chain left it. |
42| `ui.log` | A caller whose tier is `user`, `builtin` or `core` passes. Any other caller's line (an organization's plugin in `prepend` or `append`, this plugin's own lines included) continues past the user tier: no plugin a person installed hears, rewrites or drops it. See [Its lines pass over the user tier](#its-lines-pass-over-the-user-tier). |
43| `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. |
44| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `command.run`, `command.register`, `session.*`, `ui.*` but `ui.log`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
45
46## Options an administrator sets
47
48Two, in managed settings, under this plugin's own `pluginConfigs` entry,
49keyed by the id the CLI builds the plugin in under (only this spelling of the
50id is read):
51
52```json
53{
54 "pluginConfigs": {
55 "cc-plugin-sec-default@builtin": {
56 "options": { "allowManagedModsOnly": true }
57 }
58 }
59}
60```
61
62`allowManagedModsOnly`: only the mods the organization deploys through
63managed settings, and the ones built into Claude Code, load. A hooks module
64a person installed, named with `--plugin-dir` or keeps in their mods folder
65is refused whenever it loads or reloads (one already running when the option
66is set keeps running until then), with one line that names it: `mods are
67limited to your organization's by policy (allowManagedModsOnly); <plugin>
68was not loaded`
69(in the debug log, and on screen where the session hot-reloads the mod's
70folder). A plain `-p` run has it in the debug log alone; the mod is still
71not loaded. Settings hooks, status lines and `/goal` are not touched by it.
72
73- The decision reads the tier the CLI pins on the module and nothing the
74 module says of itself: a person's copy carrying an organization mod's name
75 is still in the `user` tier and is refused.
76- Refused means nothing of the module joins: no hook, no tool, no command.
77 Its top-level code has run once by then, in the closed context every hooks
78 module is evaluated in, with no call on `$` served.
79- Only managed settings are read (`$.settings.read({ source: "policy" })`):
80 the same entry in a person's, a project's or a `--settings` file neither
81 turns it on nor off. On unless absent or `false`, so a mistyped `"true"` or
82 `1` still locks. A value the settings schema rejects (`null`, an object),
83 here or in any other `pluginConfigs` entry, makes the CLI ignore the whole
84 `pluginConfigs` key with a settings warning, and the option reads as unset.
85- It fails closed: when the read of managed settings is refused (a hook beneath
86 denies it) or this plugin's hook fails, its `.catch` refuses the module,
87 with the same line, and names the failure in the debug log; so a policy
88 that cannot be read keeps every person's mod out at load. Where this plugin
89 is not seated there is no such rule, and mods load as they do without it.
90- Where managed settings define `prependPlugins`, that list must name this
91 plugin (next section) for the option to apply.
92- `claude plugin test` is not covered: it runs a mod's tests in an engine of
93 their own and loads nothing into a session.
94
95`allowModsToOverrideDenyRules`: the plugins a person installs may answer
96over a settings deny rule on `tool.check`, as they could before this plugin
97held deny rules. Off unless it is the literal `true`; an option that reads
98as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
99
100## What it hooks
101
102`classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
103`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
104`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
105`ui.log`, `plugin.register`.
106
107Hooking `tool.check` has a cost: the engine raises that event only when some
108loaded plugin hooks it, so where this plugin is seated every tool call now
109runs the `tool.check` chain, where before only a session with such a plugin
110did. The same goes for `ui.log`: every line a plugin logs runs that chain.
111
112## What it calls on `$`
113
114`settings.read`, and `ui.log`: to the debug log, and for the one line a
115person reads when a deny rule or an organization's ceiling held over a
116plugin of theirs. Both pass over the user tier. It continues to
117the `append` tier with `next.to`, which only a plugin in a managed tier may
118do.
119
120## Deny rules hold
121
122On `tool.check` any hook may answer any verdict, so a plugin a person
123installs to stop the permission prompts (`() => ({ decision: "allow" })`)
124would also lift a deny rule, a managed one included. Where this plugin is
125seated it does not:
126
127- The hook first runs the chain as it is. If the answer is a deny, or no
128 link that may hold a person's plugin answered more permissively than the
129 verdict handed up to it, the answer passes: nothing of the person's
130 loosened anything, and a plugin that only listens adds no run of its own.
131 This is read off `next.trace`, whose tiers the engine pins: a link that never
132 called `next` is measured against a deny, and since the engine lists
133 neighbouring plugins that share a worker as one batch under its first
134 member's tier, a `prepend` entry beneath this plugin counts as well as a
135 `user` one. Whether a person's plugin did the loosening is never settled
136 here, only by the next step.
137- Otherwise it runs the dispatch once more with the user tier left out
138 (`next.to(e, "append")`). That verdict never passed through a person's
139 plugin, so neither the decision nor the rule it names can have been
140 rewritten or erased, and a plugin that answered without calling `next`
141 changes nothing: the rules are evaluated in this run. The two runs differ
142 by the user tier alone, so a deny here that names its rule is a deny rule
143 the user tier loosened, and it is returned in place of the chain's answer.
144- Any deny rule counts, whatever settings file it came from: a verdict
145 carries the rule as written, never where it was read from. A deny that
146 names no rule (a settings hook's, a tool's own check) is not held.
147- An organization's plugin (prepend or append) or a built-in that allows
148 over a deny rule takes part in both runs, so its answer stands (a prepended
149 one that loosens is what brings the second run about, so its hooks run
150 twice on such a call). An ask
151 that a person's plugin turns into an allow, with no deny rule behind it,
152 stands: that is what such a plugin is for. The one ask that does not is
153 the ask an organization requires for a tool: see
154 [An organization's ceiling holds](#an-organizations-ceiling-holds).
155- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
156 can have the rules evaluated on one command and another run; a rewrite
157 belongs to `tool.call`, which runs before any of this.
158- The person is told once for each name in a session, in the transcript
159 and the debug log: `<plugin> tried to lift a deny rule in your settings
160 from a <tool> call (<rule>); the deny rule holds over the plugins you
161 install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
162 batch are named together, as it names them (`audit+easy`). A plain `-p`
163 run has it in the debug log alone; the call is still denied with the
164 rule's own message.
165- If the hook itself fails, its `.catch` answers from the one run it can
166 read: a deny stands; a verdict no plugin of the person's loosened stands;
167 one they loosened, or a run that rejected, is refused, since the deny
168 rules were never consulted.
169
170An organization that wants the plugins its people install to override deny
171rules says so in managed settings, under this plugin's own options:
172
173```json
174{
175 "pluginConfigs": {
176 "cc-plugin-sec-default@builtin": {
177 "options": { "allowModsToOverrideDenyRules": true }
178 }
179 }
180}
181```
182
183Only the managed source is read (`$.settings.read({ source: "policy" })`),
184so the same key in a person's, a project's or a local settings file, or in
185`--settings`, is never consulted; only the literal `true` counts, and a
186policy that cannot be read leaves deny rules holding.
187
188## An organization's ceiling holds
189
190An organization's administrators can require approval for a connector's
191tool. The engine then asks for every call of it, whatever the permission
192mode, an allow rule, the auto-mode classifier or a settings hook says, and
193names that on `tool.check` as the question's `ceiling` (`"ask"`): the most
194permissive verdict the organization lets a call of the tool reach. Where
195this plugin is seated, the ceiling holds over the plugins a person
196installs:
197
198- The hook first runs the chain as it is. If the answer is no more
199 permissive than the ceiling (an ask or a deny under an `ask` ceiling), or
200 the tool has no ceiling, this hold does nothing and adds no run of its
201 own.
202- Otherwise it runs the dispatch once more with the user tier left out
203 (`next.to(e, "append")`), exactly as for a deny rule. If that verdict is
204 stricter than the chain's answer, it is returned in its place: the ask
205 the organization requires, or whatever stricter verdict its own tiers and
206 the engine settled on (a deny rule's deny included, whatever
207 `allowModsToOverrideDenyRules` says: an allow is not within the ceiling,
208 so the person's plugins have no say in that call). It never returns a
209 verdict more permissive than the chain's.
210- Whether the chain's answer is over the ceiling is read off the answer and
211 the question alone, never off `next.trace`: however a person's plugin
212 arrived at an allow, with or without calling `next`, the answer is put
213 to the run that leaves the user tier out. The trace only names who is
214 told.
215- `tool.check` pins `ceiling` with the rest of the question and the engine
216 sets it from the tool, so a hook can neither ask beneath under another
217 ceiling (its hook fails) nor write one into its answer (it is dropped).
218 A ceiling this plugin does not know (anything but `allow`, `ask` or
219 `deny`) is ranked as a deny: every answer but a deny is put to the run
220 that leaves the user tier out, and the failure handler answers a deny.
221- An organization's plugin (prepend or append) or a built-in that allows
222 over the ceiling takes part in both runs, so its answer stands, at the
223 cost of the second run on such a call. An ask or a deny from a person's
224 plugin is within the ceiling and stands as any other.
225- No option lifts it. `allowModsToOverrideDenyRules` is about deny rules in
226 settings files and is not read for this; the ceiling is changed where the
227 organization's administrators set it.
228- The person is told once for each name in a session, in the transcript
229 and the debug log: `<plugin> tried to lift the limit your organization
230 set on <tool> (ask); the limit holds over the plugins you install`. Only
231 a link that itself answered over the ceiling is named, as the engine
232 names it (a batch by its members joined). A plain `-p` run has it in the
233 debug log alone; the call is still put to the person, or to the host
234 that answers for them.
235- If the hook itself fails, its `.catch` lets nothing over the ceiling
236 through: whatever it would answer for the deny rules (above), a verdict
237 more permissive than the ceiling becomes the ceiling's own
238 (`{ decision: "ask" }`, saying the limit could not be checked). On that
239 path an organization plugin's allow over the ceiling becomes the ask
240 too. A run that rejected leaves no verdict to hold at the ceiling, and
241 none is made up: what the handler answers for the deny rules stands, as
242 it does for any other tool.
243
244## Its lines pass over the user tier
245
246What an organization's plugin tells a person is the organization's to
247word, like its prompt content and its settings. So `ui.log` from any caller
248but one in `user`, `builtin` or `core` continues past the user tier
249(`next.to(e, "append")`): the line reaches the transcript and the debug log
250through the organization's own tiers and the built-ins alone, and no plugin
251a person installed hears, rewrites or drops it. That covers this plugin's
252own lines (the ones a person reads when a deny rule or a ceiling held) and
253those of any other plugin the organization deploys.
254
255What a person's plugins, the built-ins and the engine log is heard by the
256user tier as before. If the hook fails before continuing, its `.catch`
257continues past the user tier whoever the caller is.
258
259## Where it is seated
260
261The CLI seats it first in the prepend tier wherever hooks modules load on a
262machine with managed settings or for a Team or Enterprise organization,
263unless managed settings define `prependPlugins`: then that list is the
264whole prepend tier, and the organization names `sec-default@builtin` in it
265at the position it wants, e.g. `"prependPlugins": ["acme-guard@acme-tools",
266"sec-default@builtin"]`, or leaves it out. It is a plugin folder like any
267other, but its one move that matters, `next.to`, is refused outside a
268managed tier, so loading it with `--plugin-dir` seats a plugin that can
269only pass.