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